|
| 1 | +# Social Posts — "Anatomy of a Wheels Package" |
| 2 | + |
| 3 | +**Status:** Copy-paste ready. Second post in the post-GA series after the rate-limiter article. |
| 4 | +**Pairs with:** [web/content/blog/posts/anatomy-of-a-wheels-package.md](../../../web/content/blog/posts/anatomy-of-a-wheels-package.md) |
| 5 | +**Post date:** 2026-05-22 (same day as the article) |
| 6 | +**Tone:** Post-GA, present tense, how-to angle. Picks up the "now go build with it" thread from the rate-limiter post and applies it to the framework's other inversion: plugins → packages. |
| 7 | + |
| 8 | +**Canonical URL** (use everywhere): |
| 9 | +- `https://blog.wheels.dev/posts/anatomy-of-a-wheels-package` |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Slack (#wheels-dev) |
| 14 | + |
| 15 | +``` |
| 16 | +New on the blog: Anatomy of a Wheels Package — authoring, mixins, the registry. |
| 17 | +
|
| 18 | +<https://blog.wheels.dev/posts/anatomy-of-a-wheels-package|Full post> |
| 19 | +
|
| 20 | +What it covers: |
| 21 | +• The filesystem-as-registry inversion — drop a directory under vendor/, it activates. No registration step, no set(plugins=[...]). |
| 22 | +• The package.json manifest, field by field — and the explicit-opt-in default for provides.mixins (`none`, not `global` like 3.x plugins) |
| 23 | +• Why `controller` is the everyday mixin target — Wheels views run in the controller's variables scope, so a controller mixin is also a view helper |
| 24 | +• The requires / replaces / suggests dependency graph, topological load order, circular-dep handling |
| 25 | +• The mapping alias — how `wheels-sentry` becomes `wheelsSentry` so siblings can use `new wheelsSentry.SentryClient()` instead of the verbose vendor-path form |
| 26 | +• ServiceProviderInterface — when you need register/boot hooks instead of (or alongside) mixins |
| 27 | +• Error isolation — one bad package can never take down the app |
| 28 | +
|
| 29 | +Side effect of writing the post: two doc-drift fixes (#2734) — both the public Packages guide and CLAUDE.md were documenting the inter-package dependency field as `dependencies` (the legacy 3.x plugin shape). The loader has always read `requires`. Anyone copying the example would have shipped a package whose declared dependencies were silently ignored. Same PR also tightens the guide's wheelsVersion description — incompatible packages are hard-skipped, not just "logged." |
| 30 | +``` |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## LinkedIn |
| 35 | + |
| 36 | +``` |
| 37 | +New on the Wheels blog: Anatomy of a Wheels Package — authoring, mixins, the registry. |
| 38 | +
|
| 39 | +For most of Wheels' history, extending the framework meant writing a plugin. Maybe one of the well-known ones — a Stripe wrapper, an admin scaffold, a search-form helper. Maybe a half-finished one that lived in plugins/ and never made it to its own repo. The plugin model worked, more or less. It also stopped scaling around the point where "which file is currentUser() actually defined in" became an unanswerable question. |
| 40 | +
|
| 41 | +Wheels 4.0 quietly replaces that whole layer. Plugins still load (with a deprecation warning), but the canonical extension surface is now packages — and the inversion at the centre of the new design is small enough to miss on a first read: the filesystem is the registry. There is no plugin registration step, no set(plugins=[...]), no boot-time enumeration in config/settings.cfm. You drop a directory under vendor/, the loader finds it, and it activates. To remove a package, you delete the directory. That is the entire activation model. |
| 42 | +
|
| 43 | +The post walks an end-to-end wheels-greeter example and names every field in package.json. Topics covered: |
| 44 | +
|
| 45 | +— The manifest's provides.mixins allowlist (application, dispatch, controller, mapper, model, base, the four DB adapters, test) and why default is "none" instead of "global" — the same opt-in posture the rest of the framework moved to in 4.0. |
| 46 | +— Why controller is the everyday answer: Wheels views execute inside the controller's variables scope, so a controller mixin is also a view helper. No separate "view" target exists because there does not need to be one. |
| 47 | +— Per-method mixin overrides via metadata annotation — annotate a single method with mixin="model" or mixin="none" to opt it out of the package default. |
| 48 | +— The requires / replaces / suggests dependency graph — hard, exclusion, soft, in that order — and topological load order so dependents see their dependencies already installed. |
| 49 | +— The mapping alias system — how wheels-sentry becomes wheelsSentry so a sibling CFC can do new wheelsSentry.SentryClient() instead of the verbose CreateObject string form (hyphens aren't valid CFML identifiers, which is why this exists). |
| 50 | +— ServiceProviderInterface for packages that need a register/boot lifecycle to bind services with the DI container or do cross-package wiring. |
| 51 | +— Error isolation — every package loads in its own try/catch, so a broken package gets recorded in failedPackages and the app continues booting. |
| 52 | +
|
| 53 | +A side note in the post: writing it surfaced two documentation-drift bugs. Both the public Packages guide and CLAUDE.md were calling the manifest's dependency field "dependencies" — the shape inherited from 3.x plugins. The modern PackageLoader has always read "requires" (plus "replaces" and "suggests" for replacements and soft edges). Anyone copying the example would have shipped a package whose declared dependencies were silently ignored — no error, no warning, just broken at the first missing dep. Same PR also tightens the guide's wheelsVersion description: not just "logged" but a hard skip. An incompatible package is excluded from the load order before its CFC is instantiated. |
| 54 | +
|
| 55 | +Read: https://blog.wheels.dev/posts/anatomy-of-a-wheels-package |
| 56 | +
|
| 57 | +#CFML #Wheels #Packages #Plugins #FrameworkDesign #WebDevelopment |
| 58 | +``` |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +## X / Twitter |
| 63 | + |
| 64 | +**Hero tweet (unnumbered):** |
| 65 | +``` |
| 66 | +New on the Wheels blog — Anatomy of a Wheels Package. |
| 67 | +
|
| 68 | +The filesystem is the registry. Drop a directory under vendor/, the loader finds it, it activates. No registration step, no set(plugins=[...]). |
| 69 | +
|
| 70 | +https://blog.wheels.dev/posts/anatomy-of-a-wheels-package |
| 71 | +``` |
| 72 | + |
| 73 | +**Reply 1:** |
| 74 | +``` |
| 75 | +1/ Three things changed when packages replaced plugins: |
| 76 | +
|
| 77 | +• Activation is "directory exists" — no Application.cfc edit |
| 78 | +• Mixin default is `none`, not `global` — opt-in surface area |
| 79 | +• Dependencies are explicit (requires / replaces / suggests) with topological load order |
| 80 | +
|
| 81 | +The first one is the inversion. The other two follow from it. |
| 82 | +``` |
| 83 | + |
| 84 | +**Reply 2:** (outer fence is `~~~~` so the inner ```` ```json ```` block renders correctly in the Markdown preview) |
| 85 | + |
| 86 | +~~~~ |
| 87 | +2/ The whole manifest for a controller-mixin package: |
| 88 | +
|
| 89 | +```json |
| 90 | +{ |
| 91 | + "name": "wheels-greeter", |
| 92 | + "version": "0.1.0", |
| 93 | + "wheelsVersion": ">=4.0", |
| 94 | + "provides": { "mixins": "controller" } |
| 95 | +} |
| 96 | +``` |
| 97 | +
|
| 98 | +Drop the dir + the entry CFC in vendor/, reload, every controller has your methods. |
| 99 | +~~~~ |
| 100 | + |
| 101 | +**Reply 3:** |
| 102 | +``` |
| 103 | +3/ Why `controller` is the everyday mixin target: |
| 104 | +
|
| 105 | +Wheels views execute inside the controller's variables scope. A method mixed into the controller is also callable from the view. There is no separate "view" target because there does not need to be one. |
| 106 | +
|
| 107 | +wheels-basecoat, wheels-i18n, wheels-seo-suite — all controller mixins. |
| 108 | +``` |
| 109 | + |
| 110 | +**Reply 4:** |
| 111 | +``` |
| 112 | +4/ Side effect of writing the post: two doc-drift fixes (#2734). |
| 113 | +
|
| 114 | +The guide + CLAUDE.md called the manifest dependency field "dependencies" (3.x plugin shape). The loader has always read "requires." Anyone copying the example shipped a package whose deps were silently ignored. |
| 115 | +
|
| 116 | +Both docs now match the code. |
| 117 | +``` |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +## GitHub Discussions |
| 122 | + |
| 123 | +**Title:** `Post-GA blog: Anatomy of a Wheels Package` |
| 124 | + |
| 125 | +```markdown |
| 126 | +Second in the post-GA series. The rate-limiter post took the middleware pipeline — this one takes the other big 4.0 inversion: plugins → packages. Specifically, what happens when "extension point" stops being a registration step and starts being a directory on disk. |
| 127 | + |
| 128 | +**Read:** https://blog.wheels.dev/posts/anatomy-of-a-wheels-package |
| 129 | + |
| 130 | +The post is an end-to-end tour of the package system, from a worked `wheels-greeter` example through to publication on the `wheels-dev/wheels-packages` registry. It walks through: |
| 131 | + |
| 132 | +- **The activation model** — filesystem-as-registry. `PackageLoader.cfc` scans `vendor/*` on boot, skips itself and hidden dirs, resolves a dependency graph, loads in topological order. To install you drop a directory; to remove you delete one. No `set(plugins=[...])` call survives. |
| 133 | +- **The mixin allowlist** — `application`, `dispatch`, `controller`, `mapper`, `model`, `base`, four DB adapters, `test`, plus the special `global` (legacy default) and `none` (modern default). Why `controller` is the everyday answer: Wheels views execute inside the controller's `variables` scope, so a controller mixin is callable from views and partials too. There is no `view` target because there doesn't need to be one. |
| 134 | +- **Per-method overrides** — `mixin="model"` or `mixin="none"` as a metadata annotation on a single method overrides the package-level default. The loader validates the whole package's annotations before mutating any mixin table, so a typo on method N never leaves methods 1..N-1 partially registered. |
| 135 | +- **The `requires` / `replaces` / `suggests` graph** — hard deps fail the package if missing; `replaces` excludes a named package (migration paths); `suggests` is a soft edge that influences load order without failing on absence. |
| 136 | +- **The mapping alias** — `wheels-sentry` → `wheelsSentry`. Hyphens aren't valid CFML identifiers, so the loader auto-derives a lower-camel-case alias and registers it as an `application.mappings` entry. Sibling CFCs can do `new wheelsSentry.SentryClient()` instead of the verbose `CreateObject("component", "vendor.wheels-sentry.SentryClient")`. |
| 137 | +- **ServiceProviderInterface** — the optional two-phase `register(container)` / `boot(app)` lifecycle for packages that need to bind services or do cross-package wiring. Split because resolution-during-registration is brittle when other providers haven't registered yet. |
| 138 | +- **Error isolation** — every package loads in its own try/catch. A broken package is recorded in `failedPackages` and the app continues booting. No partial application of mixins. |
| 139 | + |
| 140 | +## Side note: two doc-drift bugs surfaced while writing this |
| 141 | + |
| 142 | +Drafting the post turned up two real doc/code mismatches in the package reference, both fixed in the same PR ([#2734](https://github.com/wheels-dev/wheels/pull/2734)). |
| 143 | + |
| 144 | +- The public `Packages` guide and `CLAUDE.md` documented the inter-package dependency field as `dependencies` — three places, all wrong. The shape is inherited from the legacy 3.x plugin manifest (`box.json`'s `dependencies` struct). The modern `PackageLoader` has always read `requires`, plus `replaces` and `suggests` for replacements and soft edges. Anyone copying the example manifest would have shipped a package that loaded cleanly but silently ignored its declared dependencies — no error, no warning, just broken the first time something tried to use the absent dep. All three docs are now corrected to use `requires`, and the example manifests round out with `replaces` and `suggests` so the full graph syntax lives in one place. |
| 145 | +- The same guide described `wheelsVersion` mismatches as "logged" — accurate but soft. The actual behaviour is a hard skip: an incompatible package is excluded from the load order before its CFC is ever instantiated, recorded in `failedPackages`, and the log entry names both the constraint and the running version. "Logged" undersells the consequence; if your package requires `>=4.0` and you deploy it onto a 3.x app, it does not partial-load, it doesn't degrade gracefully, it simply isn't there. The guide now says so. |
| 146 | + |
| 147 | +Neither was a code change — both are doc fixes — but they're the kind of drift that costs an hour the first time you hit it. The article closes with the "what changed while writing this" section so the rationale doesn't get lost. |
| 148 | + |
| 149 | +## What's next in the post-GA series |
| 150 | + |
| 151 | +The remaining three titles from the second batch: |
| 152 | + |
| 153 | +1. *Wheels + Claude* — building a feature via the stdio MCP |
| 154 | +2. *Beyond findAll* — scopes, enums, the chainable query builder |
| 155 | +3. *From Empty Directory to Deployed SaaS* — end-to-end with generators, multi-tenancy, jobs, browser tests, `wheels deploy` |
| 156 | + |
| 157 | +Feedback on the packages post — what's confusing, what's missing, what you'd want a future post to cover — welcome in this thread. The author-facing reference guide lives at https://guides.wheels.dev/v4-0-1-snapshot/digging-deeper/packages/ if you want the full field-by-field treatment. |
| 158 | +``` |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## Posting checklist |
| 163 | + |
| 164 | +- [ ] Article live at `https://blog.wheels.dev/posts/anatomy-of-a-wheels-package` |
| 165 | +- [ ] PR #2734 merged (article + doc fixes for `dependencies` → `requires` and `wheelsVersion` clarification) |
| 166 | +- [ ] Slack post in `#wheels-dev` |
| 167 | +- [ ] LinkedIn post from the Wheels org account |
| 168 | +- [ ] X / Twitter hero + 4-reply thread from `@wheels_dev` |
| 169 | +- [ ] GitHub Discussions thread under "Show and tell" or equivalent category |
| 170 | +- [ ] Verify all four channels link to the same canonical URL |
0 commit comments