Laravel Pennant: Feature Flags with Scope, Storage, and Custom Drivers
#laravel #feature-flags #pennant #multi-tenant #testing

Laravel Pennant: Feature Flags with Scope, Storage, and Custom Drivers

4 min read Mohamed Said Mohamed Said

Laravel Pennant Beyond the Basics

Most teams discover Pennant, add a few Feature::define() calls, and ship. That works fine until you need per-tenant flags, a custom storage backend, or a rollout strategy that isn't a simple percentage. This article covers the parts of Pennant that rarely appear in introductory posts.


Scoping Flags to a Tenant

Pennant resolves features against a scope — by default the authenticated user. In a multi-tenant app you usually want the team or organisation as the scope, not the individual.

// AppServiceProvider::boot()
Feature::resolveScopeUsing(fn ($driver) => request()->user()?->currentTeam);

Now every Feature::active('billing-v2') call is automatically scoped to the current team. You can also pass an explicit scope anywhere:

Feature::for($team)->active('billing-v2');

This is critical when a job runs outside an HTTP request. Inject the team and pass it explicitly rather than relying on the global resolver:

class ActivateBillingJob implements ShouldQueue
{
    public function __construct(private Team $team) {}

    public function handle(): void
    {
        if (Feature::for($this->team)->active('billing-v2')) {
            // ...
        }
    }
}

Choosing the Right Storage Driver

Pennant ships with two drivers: database and array. The array driver is perfect for tests — it never persists. The database driver stores one row per (feature, scope) pair.

For high-traffic apps, that table can become a hot spot. A common fix is to front the database driver with a cache layer using a custom driver.

// PennantServiceProvider::boot()
Feature::extend('cached-db', function (Application $app) {
    return new CachedDatabaseDriver(
        $app->make(DatabaseDriver::class),
        $app->make(Repository::class), // Cache
        ttl: 300
    );
});
class CachedDatabaseDriver implements Driver
{
    public function __construct(
        private Driver $db,
        private Repository $cache,
        private int $ttl,
    ) {}

    public function get(string $feature, mixed $scope): mixed
    {
        $key = "pennant:{$feature}:" . $this->scopeKey($scope);

        return $this->cache->remember($key, $this->ttl,
            fn () => $this->db->get($feature, $scope)
        );
    }

    public function set(string $feature, mixed $scope, mixed $value): void
    {
        $this->db->set($feature, $scope, $value);
        $this->cache->forget("pennant:{$feature}:" . $this->scopeKey($scope));
    }

    private function scopeKey(mixed $scope): string
    {
        return $scope instanceof Model ? $scope->getKey() : (string) $scope;
    }

    // Implement define(), getAll(), delete(), purge() by delegating to $this->db
}

Set PENNANT_STORE=cached-db in your env and register the driver name in config/pennant.php.


Rich Feature Definitions with Class-Based Resolvers

Inline closures in Feature::define() get messy. Extract logic into invokable classes and bind them through the container:

Feature::define('new-checkout', NewCheckoutFeature::class);
class NewCheckoutFeature
{
    public function __construct(private RolloutService $rollout) {}

    public function __invoke(Team $team): bool
    {
        return $this->rollout->teamIsInCohort($team, 'checkout-beta');
    }
}

Pennant resolves the class through the service container, so constructor injection works out of the box. This makes the resolver independently unit-testable:

it('activates new checkout for beta cohort teams', function () {
    $rollout = Mockery::mock(RolloutService::class);
    $rollout->shouldReceive('teamIsInCohort')->andReturn(true);

    $feature = new NewCheckoutFeature($rollout);

    expect($feature($this->team))->toBeTrue();
});

Testing Without Leakage

Always use the array driver in tests to avoid database hits and cross-test contamination:

// phpunit.xml or Pest's beforeEach
config(['pennant.default' => 'array']);

Or activate/deactivate inline:

Feature::activate('billing-v2');
Feature::deactivate('billing-v2');

Pennant resets the array store between requests automatically, but in long-running Octane workers you must call Feature::flushCache() at the start of each request — add it to a middleware.


Key Takeaways

  • Scope flags to teams, not users, in multi-tenant apps — use resolveScopeUsing globally and Feature::for() in jobs.
  • The database driver can become a bottleneck; wrap it in a custom cached driver for read-heavy workloads.
  • Class-based resolvers are container-resolved, making them trivially testable in isolation.
  • Always run the array driver in tests and flush the cache in Octane middleware to prevent state leakage.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can Pennant scope feature flags to a team instead of the authenticated user?
Yes. Call `Feature::resolveScopeUsing()` in a service provider to return your team model as the default scope, or pass an explicit scope with `Feature::for($team)->active('flag-name')` anywhere in your codebase.
Q02 How do I prevent Pennant from hitting the database on every request?
Implement a custom driver that wraps the built-in `DatabaseDriver` with a cache layer. Register it via `Feature::extend()` and set it as the default store. Remember to invalidate the cache key in the `set()` method.
Q03 How should I handle Pennant in Laravel Octane workers?
Pennant's in-memory cache persists across requests in long-running workers. Add a middleware that calls `Feature::flushCache()` at the start of each request to prevent stale flag values leaking between users.

Continue reading

More Articles

View all