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
resolveScopeUsingglobally andFeature::for()in jobs. - The
databasedriver 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
arraydriver in tests and flush the cache in Octane middleware to prevent state leakage.