Architecting an addon system that doesn't fight the core
If your plugin architecture is bleeding into every core controller, you didn't build an addon system — you built a tax on your future self. Here's what I learned designing xCloud's.
The first version of the addon system I designed had a problem I didn't notice for a few weeks: every time we shipped a new paid module, three files in the core had to change. Not config — actual logic. The "decoupled" architecture was a polite fiction.
That's the moment you realize your addon system is a fancy folder name. The real work is drawing a boundary the core can respect, forever, even when the product manager's in a rush and the deadline is tomorrow.
What an addon actually is
Before anything else, you need a sharp answer to: what can live in an addon, and what can't. We ended up with three rules.
- An addon is a capability the core doesn't know about at compile time. If the core has to know, it's not an addon, it's a feature.
- An addon must be disablable per tenant without breaking the core's data model. If turning it off corrupts state, it was load-bearing and shouldn't have been an addon.
- An addon owns its own persistence. It can read from core tables, but it writes to its own.
Those three rules killed a lot of arguments. They also killed a few shortcuts we wanted to take, which is the point.
Register, don't hardcode
Every addon in xCloud registers itself on boot. The core exposes a small set of service interfaces — Billable, SiteScoped, WebhookConsumer, a handful of others — and addons pick the ones they implement.
// addon manifest
return [
'key' => 'mailbox',
'name' => 'Mailbox',
'version' => '1.4.0',
'provides' => [Billable::class, SiteScoped::class, WebhookConsumer::class],
'requires' => ['core' => '>=2.0'],
'migrations'=> __DIR__.'/database/migrations',
];
The core's job is to honor those interfaces. The addon's job is to stay inside them. When either side reaches across the line, you get the mess I shipped the first time.
The interface is a promise. The promise is only useful if you refuse to break it even once.
Enablement as a first-class concept
Multi-tenant SaaS addons are not "installed" like WordPress plugins — they're enabled, per tenant, with a flick of a billing switch. That changes the shape of the code.
- Every addon capability is gated through a single
addon.enabled(tenant, key)check. Not scattered across ten controllers. - The UI reads the same check. If you turn Mailbox off, the Mailbox nav item disappears without a deploy.
- Feature flags become trivial. Rolling out a new addon version is a tenant filter on one gate, not a branch in seven files.
The tempting anti-pattern here is to put enablement checks inside the addon itself. Don't. The core gate is the one the billing team can trust; the addon's internal checks are for its own logic.
Quotas and quotas and quotas
Anything shipped as a paid addon will, eventually, be abused. Mail delivery is obvious — people send too many messages — but DNS zones, staging slots, and cron jobs all have their own versions of the problem.
We push every addon through a small quota SDK: declare the resource, the unit, the limit, and the overage behavior. The core enforces. The addon asks permission. The billing system reads usage.
The payoff is that when someone says "hey, can we add a hard cap on outbound webhooks per minute?", nobody has to go hunting through addon code. It's a one-line declaration and a test.
Migrations without regret
Addon databases are the part people get wrong. You want them isolated enough that uninstalling an addon is clean, but connected enough that joins aren't a nightmare.
What worked for us:
- Each addon owns a table prefix (
mbx_,cf_, etc.). One glance tells you the owner. - Addons reference core entities by stable external IDs, never by raw foreign keys into core tables.
- Uninstall is a real code path — not "just stop running the feature". It drops the prefix, clears its queues, and emits a
addon.uninstalledevent.
The last one matters more than people think. If uninstall isn't a tested path, it isn't a path.
Webhooks are the addon's front door
Most addons eventually need to talk to third parties — mail delivery gets bounces, DNS reacts to Cloudflare changes, SSL cares about LE's ACME challenges. Instead of letting each addon invent its own webhook handling, the core ships one:
// addon-side
class MailboxBounceHandler implements WebhookConsumer {
public function topic(): string { return 'mailbox.bounce'; }
public function verify(Request $r): bool { /* HMAC */ }
public function handle(WebhookEvent $e): void { /* ... */ }
}
Core routes the request, authenticates it, stashes it, dedupes it, and hands the addon a parsed event. The addon writes the interesting five lines instead of the boring eighty.
What I'd do differently
Honestly? Start harder. The first version of any addon system should be more restrictive than you think is reasonable. It's always easier to loosen a contract than to fix one addon that took a shortcut and inspired four more.
Draw the line early. Let the line hurt a little. The line is what makes the system still make sense in year three.
If I had the last two years back, I'd write the interface registry and the enablement gate on day one — before shipping any addon — and let those two files refuse to compile when somebody reached across. The core's job is to say no for you, consistently, while you're too tired to notice.
That's the whole essay. An addon system isn't a folder. It's a refusal to let the core learn things it doesn't need to know.