Deploying code and releasing a feature do not have to happen at the same time. A feature flag lets a team ship dormant code, enable it for internal users, expand it to stable cohorts, and disable it quickly when metrics deteriorate. Laravel Pennant provides this workflow through a small API, explicit scopes, and persistent drivers.
This guide focuses on disciplined use: defining flags, choosing scopes, rolling out, implementing a kill switch, testing both paths, and removing a flag after it has completed its job.
What problems do feature flags solve?
| Scenario | How a flag helps |
|---|---|
| Canary release | Enable a change for staff or a small cohort first. |
| Trunk-based development | Merge incomplete behavior while keeping the new path disabled. |
| A/B testing | Resolve a variant value rather than a simple boolean. |
| Risk control | Disable a new path without rolling back the entire deployment. |
A feature flag is not authorization. Authorization answers what a user is permitted to do; a flag selects which experience or implementation is currently being rolled out.
Install Laravel Pennant
composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate
The database driver persists resolved values. The array driver is useful for tests and non-persistent scenarios.
Define an initial flag
use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;
Feature::define('checkout-v2', fn (User $user) => match (true) {
$user->isInternalTeamMember() => true,
$user->hasSupportHold() => false,
default => Lottery::odds(1, 20),
});
The first check resolves and stores a result for that scope. The same user therefore keeps a stable experience instead of moving randomly between paths on each request.
Choose the rollout scope deliberately
User scope is common, but a SaaS application may need to roll out by team or organization so every member receives the same behavior.
if (Feature::for($user->team)->active('billing-v2')) {
return redirect()->route('billing.v2');
}
Choose the scope before rollout. Switching from users to teams midway creates a new set of stored resolutions and makes behavior harder to explain.
Use class-based features for important logic
final class CheckoutV2
{
public function before(User $user): mixed
{
if (config('features.checkout_v2_disabled')) {
return $user->isInternalTeamMember();
}
return null;
}
public function resolve(User $user): bool
{
return $user->created_at->isAfter(now()->subMonths(3));
}
}
The before() method runs ahead of stored values and can act as a kill switch. An incident can disable the feature broadly without deleting prior assignments, allowing the same cohort to return after a fix.
Check the flag at the main behavior boundary
return Feature::for($user)->active('checkout-v2')
? $newCheckout->handle($cart)
: $legacyCheckout->handle($cart);
Keeping the decision near the primary branch makes both paths easier to understand and remove. Avoid scattering the same flag across controllers, views, jobs, and services when a coordinating layer can make one decision.
Blade offers the @feature directive, while Pennant middleware can protect an entire route behind active features.
Use rich values for controlled variants
Feature::define('checkout-button', fn (User $user) =>
$user->country_code === 'VN' ? 'compact' : 'standard'
);
$variant = Feature::value('checkout-button');
Rich values suit interface variants and rollout parameters. Keep the value set small and stable, and record it with analytics. A feature store should not become an arbitrary business-configuration database.
Avoid N+1 checks across many scopes
Feature::for($users)->load(['notifications-beta']);
foreach ($users as $user) {
if (Feature::for($user)->active('notifications-beta')) {
// ...
}
}
Pennant caches checks within one request, but iterating over many users can still trigger many database queries. Eager load the values needed by batch workflows.
Test both paths
Feature::define('checkout-v2', true);
$this->actingAs($user)
->post('/checkout', $payload)
->assertRedirect('/orders/success');
Feature::define('checkout-v2', false);
Cover both enabled and disabled behavior until the legacy path is removed. Redefine feature results in tests so they do not depend on random cohort assignment.
Plan the rollout sequence
- Deploy schema and code that support both old and new paths.
- Enable internal users and monitor errors, latency, and business metrics.
- Enable a small, stable cohort.
- Increase exposure when predefined thresholds remain healthy.
- Enable everyone and keep observing for a safety period.
- Remove the legacy path, flag definition, stored values, and temporary dashboards.
Database migrations must remain backward compatible during the rollout. Disabling a flag cannot recover the legacy path if a deployment already removed a column it requires.
Clean up completed flags
php artisan pennant:purge checkout-v2
Every flag should have an owner, purpose, creation date, and removal condition. Long-lived flags multiply branches and tests. A feature flag is a transition mechanism, not a default permanent state.
Pennant checklist
- Names describe behavior instead of a short-lived ticket number.
- The scope matches the rollout unit: user, team, or global.
- High-risk changes have a kill switch.
- Metrics are segmented by variant and include stop thresholds.
- Both enabled and disabled paths are tested.
- The schema supports both paths during transition.
- An owner and flag-removal plan are recorded.




No comments yet. Be the first to share your thoughts.