Pennant in Laravel 13: feature flags for every tenant
Laravel Pennant describes itself as a feature flag package "without the cruft". On my multi-tenant platform it carries a bigger job: the entire entitlement layer. 51 tenant capabilities and 23 platform flags resolve through two Pennant stores on every request. The package held. The lessons came from three bugs and one migration.
How do you scope a feature flag to a tenant?#
Pennant's default scope is the authenticated user. That is the right answer for a single-tenant app and the wrong one for mine. My platform gives every tenant its own database, so the question a flag answers is never "does this user get the feature". It is "has this tenant bought the capability". One line in my capability provider replaces the scope resolver, and it hands back a small value object that serializes to the plain tenant key. No model class names in the scope column, no morph map, just the UUID. Ask for a capability outside an initialized tenant context and the resolver throws instead of returning a quiet default.
The tenant catalog is a 51-case enum: guest checkout, theme presets, referral programs, support tickets, the analytics module. Each case carries its own catalog default, and the resolution chain is two steps long. An explicit grant row for this tenant wins; otherwise the default applies. Pennant's own capability: middleware sits in front of routes and answers a missing capability with a 404, which is how the whole app treats surfaces a tenant has no business opening.
A second enum holds 23 platform-side switches: new-tenant provisioning, self registration, custom domains, the analytics boards. Those are my doors above the tenants', so they live in their own store and never mix with the capability rows.
Two stores and a connection tenancy cannot touch#
Both stores live in one config file, and the interesting part is what they have in common:
'database' => [
'driver' => 'database', // intercepted by my custom creator
'connection' => 'central',
'table' => 'tenant_capability_grants',
],
'central' => [
'driver' => 'Database', // capital D on purpose
'connection' => 'central',
'table' => 'platform_flags',
],Each store pins its connection by name, and the vendor's database driver re-reads that name on every query. Tenancy swaps Laravel's default connection when a tenant context starts, so this pin is what keeps the grants in the central database even while tenant code runs. The placement is the security model: some tenants hold root credentials over their own database, and entitlement truth must never live where a tenant can write. There is nothing to forge and no mirror to keep in sync.
The driver key casing is load-bearing, which is a sentence I do not enjoy writing. The lowercase database key gets intercepted by a custom creator I register, and the capitalized Database key routes to the vendor's built-in. Get the casing wrong and the platform store resolves to the capability table. A comment block above the config explains it for whoever comes next, because nobody memorizes this.
Why my driver never writes on a read#
Stock Pennant has a convenient habit: the database driver persists the computed value on first read. As a rollout snapshot that is friendly. As an entitlement store it is a bug you have not found yet. The first read of a grantless tenant would freeze the catalog default into a stored row, and a later catalog flip would silently never reach the tenants that never got an explicit grant.
So the creator swaps in a subclass that removes the write path. Rows appear only on explicit writes through the seam and disappear on delete, and reads resolve from the catalog every time. A feature test reads, writes, and reads again to prove that the first read stored nothing. The subclass must also be registered before the first Feature interaction of the boot cycle, or the manager resolves the vendor driver once and the swap never happens.
The cache that bleeds across tenants#
Pennant keeps resolved values in an in-memory decorator cache. The vendor flushes it on queue job boundaries and on Octane request boundaries. It does not flush on a tenancy switch, and my scheduler walks tenants one after another in a single process. The failure is quiet: a loop that initializes tenant after tenant can serve a value resolved under tenant A to tenant B, and nothing throws. This is the same family as the scheduler surprises that came with the database-per-tenant setup, and it gets the same treatment: a listener on the tenancy-initialized event flushes the flag cache and drops my per-tenant memo, and every write through the seam flushes both again. Long-lived workers need the memo dropped on job boundaries too, or the next job inherits the previous job's resolved catalog.
The TTL bug that kept gated routes open#
Grants can expire. A tenant gets a capability for a trial window, and the stored value is not a bool but a JSON object carrying a deadline. Here is the trap. Pennant counts any non-false value as active. An expired grant is an array in PHP, and an array is not false, so it stayed active forever. The route middleware compares strictly against false, and gated routes kept serving after the deadline passed. My own seam normalized the value correctly on its reads, which made the bug harder to see: two code paths disagreed about the same row.
The fix, shipped on 7 October, moved normalization into the driver itself. A stored deadline evaluates against the clock, plain bools pass through, and anything else stays active, exactly as the vendor semantics intend. One seam, one interpretation, and a test that pins an expired grant reading off on every path.
What I would tell a team starting this#
Three habits carry the whole thing. First, a single writer: an architecture test walks app/ and fails the build if the Feature facade appears anywhere outside four sanctioned files, so every read and write goes through one seam I can reason about. Second, two-layer gating: a platform master door and the tenant capability both have to agree, which is what makes a soft launch possible. Third, honesty about cost: every per-request check is a query against the central database and the query budget notices, so the catalog resolves once per tenant per process and stays memoized until something writes.
Trials run through the same seam. A tenant admin flips one capability on for a fixed window, once per the tenant's lifetime, and the marker and the grant commit in one transaction so a retry cannot spend the trial twice.
None of this needed a flag platform vendor. Pennant is small enough to read in an afternoon, and on Laravel 13, which shipped in March, it has behaved the same as on its predecessors, which is the compliment I actually mean. Gates decide tooling questions for me the same way they decide product features: a ban on AI coding tools fails where a gate holds, and that argument comes out of a Snyk security session.