Laravel · Laravel

Laravel Pennant Feature Flags

How Laravel Pennant's database and array drivers handle feature flags, why flag state needs to be resolvable per-user and per-request, and where flag lifecycle cleanup gets skipped.

John Kihiu12 min read

Pennant is Laravel's first-party feature flag package, and its whole design fits in one sentence: flags are named booleans (or richer values) resolved per "scope" — usually the authenticated user — with a driver that decides where that resolution gets stored. It's deliberately smaller than LaunchDarkly or Unleash. If you need percentage rollouts, dashboards for non-engineers, or cross-team flag governance, Pennant isn't trying to compete with that. It's for the common case: ship code behind a flag, roll it out to a cohort, remove the flag.

The array driver vs. the database driver

Pennant ships with two drivers. The array driver resolves flags in memory per-request and never persists anything — every request re-evaluates the flag's closure from scratch. That's fine for flags whose logic is pure (e.g. "is this user in the beta group based on their id"), but useless the moment you want a flag whose value, once decided, should stay stable for a user even if the underlying condition changes. The database driver solves that: the first time a flag is checked for a scope, Pennant persists the resolved value to a `feature_flags` (actually `features`) table, and every subsequent check reads that stored value instead of re-running the closure. This is the behavior most teams actually want and don't realize they need until a user complains a feature "flickered."

PHP · DEFINING AND CHECKING A FLAG
use Laravel\Pennant\Feature;

// app/Providers/AppServiceProvider.php
Feature::define('new-billing-ui', function (User $user) {
    if ($user->isInternalTester()) {
        return true;
    }
    return Lottery::odds(1, 10)->winner(); // 10% rollout, persisted per user
});

// In a controller or Blade view
if (Feature::for($user)->active('new-billing-ui')) {
    return view('billing.new');
}

// Blade directive
@feature('new-billing-ui')
    
@else
    
@endfeature

Rich values, not just booleans

Pennant flags don't have to resolve to true/false. A flag's closure can return a string, an enum, or any serializable value, which makes Pennant usable for simple A/B/n variant assignment ("control", "variant-a", "variant-b") rather than just on/off toggles. This overlaps with proper experimentation tooling, but for a lightweight internal test — three copy variants on a signup page, say — it avoids pulling in a separate A/B testing service for something Pennant already covers.

Scope resolution is per-class, not just per-user

Pennant's default scope is the authenticated user, but you can flag against any model — a Team, a Tenant, an Organization — by passing it explicitly to Feature::for($team). For B2B products, flagging at the tenant level rather than the user level is usually the correct default: it keeps a feature consistent for everyone inside one account.

The part teams skip: flag cleanup

A feature flag that's been at 100% rollout for three months and never removed is not a flag anymore, it's dead conditional logic plus a database table that keeps growing. Because the database driver persists a row per scope per flag, an old flag checked against every user in a growing user base becomes real storage and query overhead, not just code smell. Pennant has no built-in nagging mechanism for this — it's a process discipline problem, not a tooling one. Treat "remove the flag and the branch it guards" as part of the definition of done for the rollout, not a someday cleanup task.

Purging is manual and easy to forget

Feature::purge('old-flag-name') deletes all stored resolutions for a flag from the database driver. Run it once you've removed the flag's code paths — otherwise the rows sit in the features table indefinitely, and if you ever reintroduce a flag with the same name, stale values from the old rollout can resurface.

Testing with Pennant

Pennant provides `Feature::define` overrides and `Feature::activateForEveryone()` / `Feature::deactivateForEveryone()` helpers specifically for tests, so a feature test can force a flag on or off without depending on the real resolution logic (lottery odds, tester lists) being deterministic. Skipping this and letting tests hit the real flag closure is how you end up with a flaky test suite where a percentage-based rollout occasionally fails CI for no code reason.

Wrapping up

Pennant is the right size for the common feature-flag case: gate a change behind a per-user or per-tenant flag, roll it out gradually, and remove it once it's fully shipped. Reach for the database driver by default so resolved values stay stable, scope flags to the entity that should see consistent behavior, and treat flag removal as part of the rollout's definition of done rather than a cleanup task nobody owns.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.