Shipping a risky change behind a deploy is a bet: either the whole release works or you roll the whole release back. Feature flags let you separate deploying code from releasing behaviour, so a rewritten checkout, a new search backend, or a Livewire replacement for an old Blade page can go to production dark, be switched on for your own team, then for 5% of customers, then for everyone — without another deploy.
Laravel's first-party package for this is Pennant. It is small, it is boring in the good way, and it fits into Blade, middleware, jobs and tests without a service subscription. This tutorial covers a production setup: defining flags, scoping them to users and tenants, gradual rollout, purging stale flags, and — the part most teams get wrong — how to actually delete a flag once it has served its purpose.
Assumes a Laravel 11/12/13 app on PHP 8.2+.
Install
composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate
Pennant ships two drivers, configured in config/pennant.php:
array— in-memory, resolved per request. Nothing is persisted, so every request re-evaluates the flag's resolver. Good for flags driven entirely by code (plan tier, config value) and the default for tests.database— resolved once per scope and stored in thefeaturestable. Good for random rollouts, per-customer overrides, and anything an operator needs to toggle at runtime.
Set the default with PENNANT_STORE=database for production. The distinction matters: with the database driver, a resolver that returns a random 10% is evaluated once per user and then frozen, which is exactly what you want. With array, the same user can flip in and out of the rollout on every request.
Define your first flag
Simple flags live in a service provider:
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Lottery;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;
class FeatureServiceProvider extends ServiceProvider
{
public function boot(): void
{
Feature::define('new-checkout', fn (User $user) => match (true) {
$user->isInternal() => true,
$user->onPlan('enterprise') => false,
default => Lottery::odds(1, 20),
});
}
}
Read it anywhere:
if (Feature::active('new-checkout')) {
// new path
}
In Blade:
@feature('new-checkout')
<x-checkout.v2 />
@else
<x-checkout.v1 />
@endfeature
Note what the resolver above encodes: staff always get the new path, enterprise accounts are explicitly excluded until we are confident, and everyone else has a 1-in-20 chance. Because the store is database, that lottery result is written once per user and stays stable.
Class-based features
Once a flag has more than a couple of lines of logic, or needs its own tests, give it a class:
php artisan pennant:feature NewSearchBackend
<?php
namespace App\Features;
use App\Models\User;
use Illuminate\Support\Lottery;
class NewSearchBackend
{
/**
* The feature's default value.
*/
public function resolve(User $user): mixed
{
if ($user->team?->opted_into_beta) {
return true;
}
return Lottery::odds(config('features.new_search_rollout'), 100);
}
}
Reference it by class name, which gives you refactor-safe flag names:
use App\Features\NewSearchBackend;
if (Feature::active(NewSearchBackend::class)) {
return $this->searchWithOpenSearch($query);
}
return $this->searchWithLike($query);
A tip from client work: keep the name string stable if you already have rows in the features table. Pennant stores the class name by default; you can pin it with a public $name property so a namespace refactor does not orphan every stored value.
Scope: the part people get wrong
By default Pennant scopes flags to the authenticated user. Most B2B applications do not want that — you want the whole team to see the same product, or the flag should follow a tenant, not a person.
Pass the scope explicitly:
Feature::for($user->team)->active('new-checkout');
Or change the default scope application-wide:
use App\Models\Team;
use Illuminate\Support\Facades\Auth;
use Laravel\Pennant\Feature;
Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);
Your resolvers then type-hint Team instead of User. Mixing scopes across flags in one application is legal but confusing; pick a default and deviate deliberately.
null scope is how you express a global flag:
Feature::define('maintenance-banner', fn () => false);
Feature::for(null)->activate('maintenance-banner');
Rich values, not just booleans
Flags can return anything JSON-serialisable, which makes A/B tests and staged config changes easy:
Feature::define('purchase-button-colour', fn (User $user) => Arr::random([
'blue-sapphire', 'seafoam-green', 'tart-orange',
]));
$colour = Feature::value('purchase-button-colour');
@feature('purchase-button-colour', 'seafoam-green')
{{-- ... --}}
@endfeature
Rich values are also the clean way to roll out a limit: return 50 for most tenants, 500 for the ones on the new infrastructure, and read Feature::value('upload-limit') in the validator instead of hard-coding.
Middleware and queued jobs
Gate a whole route group:
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;
Route::middleware(EnsureFeaturesAreActive::using('new-checkout'))
->group(function () {
Route::get('/checkout/v2', CheckoutV2Controller::class);
});
The middleware returns 400 by default; customise it in a service provider with EnsureFeaturesAreActive::whenInactive(...) if you would rather redirect or 404.
Queued jobs are the classic footgun. A job has no authenticated user, so Feature::active('x') inside handle() resolves against a null scope and quietly returns the wrong answer. Two safe options:
// 1. Capture the decision at dispatch time and pass it along.
ProcessOrder::dispatch($order, useNewPipeline: Feature::active('new-checkout'));
// 2. Or resolve explicitly against a scope inside the job.
public function handle(): void
{
if (Feature::for($this->order->team)->active('new-checkout')) {
// ...
}
}
Option 1 is usually better: the user saw one version of the product, and the background work should match what they saw.
Eager loading
Checking five flags for every row in a list means five queries per row with the database driver. Load them up front:
Feature::for($users)->loadMissing([
'new-checkout',
'new-search-backend',
]);
In a Blade loop over a collection, one loadMissing before the loop turns N×M queries into one. Feature::all() on the current scope is also cheaper than several individual active() calls, and Pennant caches in-memory for the rest of the request either way.
Testing flags
Never let tests depend on a lottery. Force values:
use Laravel\Pennant\Feature;
it('shows the new checkout when the flag is on', function () {
Feature::define('new-checkout', true);
$this->actingAs($user = User::factory()->create())
->get('/checkout')
->assertSee('Pay now');
});
it('falls back to the old checkout', function () {
Feature::define('new-checkout', false);
$this->actingAs(User::factory()->create())
->get('/checkout')
->assertSee('Complete order');
});
Set PENNANT_STORE=array in phpunit.xml so nothing leaks between tests. And write both tests, every time: a flag with only the "on" path tested is how the fallback rots and the rollback stops working.
Operating flags in production
Toggling is done through the API — from Tinker, a small admin action, or a deploy script:
use Laravel\Pennant\Feature;
// Everyone, globally
Feature::for(null)->activate('new-checkout');
// One customer, for a support ticket
Feature::for($team)->activate('new-checkout');
Feature::for($team)->deactivate('new-checkout');
Pennant's Artisan commands cover cleanup:
# Remove all stored values for a flag so resolvers run fresh
php artisan pennant:purge new-checkout
# Purge everything except the flags you still care about
php artisan pennant:purge --except=new-checkout
Add to your deployment pipeline:
php artisan pennant:purge --except-registered
That clears stored values for flags no longer defined in code — the cleanup step that keeps the features table from becoming a graveyard.
Pennant also fires events (FeatureRetrieved, FeatureUpdated, UnknownFeatureResolved). Listen for UnknownFeatureResolved and log it in production: it fires when code asks for a flag that no longer exists, usually because a class was renamed and something still references the old name.
The retirement plan
The real cost of feature flags is not the package, it is the flags nobody removed. Every stale flag is a permanent if in your codebase and an untested branch of behaviour.
We give every flag an expiry when it is created:
- Name it and date it. A comment above the definition: what it gates, who owns it, and the date it should be gone.
- Roll out in steps — internal, 5%, 25%, 100% — and give each step long enough to see errors in your monitoring.
- Bake it in. Once a flag has been at 100% for a full release cycle, delete the flag definition, delete the old branch, delete the flag's tests, and run
pennant:purge. This is a small, boring pull request. Do it while the context is still fresh. - Audit quarterly. Diff the distinct
namevalues in thefeaturestable against the flags still defined in code. Anything older than a quarter is a bug report against the team, not a feature.
A flag that is never removed has turned into config. If it genuinely is config — permanent per-plan behaviour — move it out of Pennant and into your plan/entitlement model where it belongs.
When Pennant is not enough
Pennant is a library, not a platform. If you need a non-engineer UI for toggles, audit logs of who flipped what, scheduled rollouts or flags shared across several applications and languages, look at a hosted flag service and keep Pennant for the in-app cases. For a single Laravel application with an engineering team doing the toggling, Pennant plus a small Filament or Nova resource over the features table covers almost everything.
Feature flags are the cheapest insurance available on a risky Laravel release — a rewrite, a framework upgrade, a database migration with a new read path. If you are planning one of those and want the rollout designed so it can be undone in seconds, see our Laravel feature enhancement and version upgrade services, or get in touch.