Practical patterns for Laravel Pennant beyond the basics. Team-scoped flags, emergency kill switches, dark launches, admin commands, and know-how not covered in the official docs.
For the basics, see the guide page. This page introduces practical patterns not covered in the official docs.
That’s all it takes for Feature::active('billing-v2') to automatically target the current team. Because everyone on the same team gets the same result, UI consistency is preserved regardless of the member.Here’s an example of a gradual rollout based on a team’s signup date.
use App\Models\Team;use Illuminate\Support\Carbon;use Illuminate\Support\Lottery;use Laravel\Pennant\Feature;Feature::define('new-billing', function (Team $team) { // Teams that registered in 2024 or later are enabled immediately if ($team->created_at->isAfter(new Carbon('2024-01-01'))) { return true; } // Teams from 2022-2023 roll out at 10% if ($team->created_at->isAfter(new Carbon('2022-01-01'))) { return Lottery::odds(1 / 10); } // Older teams start conservatively at 1% return Lottery::odds(1 / 100);});
To enable only for specific teams (e.g. early access for enterprise customers):
// Manually enable for a specific team after deployFeature::for($earlyAccessTeam)->activate('new-billing');
When a bug is discovered in production, you can instantly disable a feature without rolling back code. Adding a before method to a class-based feature makes the check run before the storage value is consulted.
<?phpnamespace App\Features;use App\Models\User;use Illuminate\Support\Facades\Config;class NewCheckout{ /** * Emergency switch that lets you disable via config on production bugs */ public function before(User $user): mixed { // Controlled via config/features.php value, or env if (Config::boolean('features.new_checkout_disabled', false)) { return false; // Immediately disable for everyone } // Admins always enabled (for debugging) if ($user->isAdmin()) { return true; } return null; // Return null to fall through to normal resolve() } public function resolve(User $user): mixed { return $user->isPremium() || Lottery::odds(1 / 5); }}
Setting the environment variable FEATURES_NEW_CHECKOUT_DISABLED=true alone stops the feature without touching the database. A kill switch that requires no deploy.
When before returns null, resolve() runs. Returning false immediately treats the flag as inactive. Return null in non-emergency situations.
The case where you want to automatically enable for all users at a specific date/time. You can implement this in the before method.
public function before(User $user): mixed{ $rolloutDate = Config::get('features.new_api.rollout_date'); if ($rolloutDate && Carbon::parse($rolloutDate)->isPast()) { return true; // Once past the rollout date, everyone is enabled } return null; // Otherwise fall through to normal resolve()}public function resolve(User $user): mixed{ // Before rollout, internal team members only return $user->isInternalTeamMember();}
# .envFEATURES_NEW_API_ROLLOUT_DATE=2025-04-01
Now, once 2025-04-01 passes, the feature automatically ships to all users. No deploy, no DB update, no Artisan command needed.
A pattern where you run new logic on production data without showing it to users, and compare results to the old logic. If nothing goes wrong, you release simply by flipping the flag.
class RecommendationController{ public function index(Request $request) { $legacyResult = $this->getLegacyRecommendations($request->user()); // In shadow mode, run the new algorithm and compare, but show legacy if (Feature::for($request->user())->active('recommendation-v2-shadow')) { try { $newResult = $this->getNewRecommendations($request->user()); // Log differences (not shown to users) if ($legacyResult !== $newResult) { Log::channel('shadow_mode')->info('recommendation diff', [ 'user_id' => $request->user()->id, 'legacy' => $legacyResult, 'new' => $newResult, ]); } } catch (\Throwable $e) { Log::error('shadow mode error', ['error' => $e->getMessage()]); } } // Response is always the legacy result return response()->json($legacyResult); }}
Once you’ve confirmed the logs show no differences, switch the flag to recommendation-v2 to release.
The official docs mention the FeatureRetrieved event, but do not show a real A/B test aggregation pattern.The FeatureResolved event fires only the first time a feature’s value is resolved. Use it to record a user’s variant assignment.
use Laravel\Pennant\Events\FeatureResolved;use Illuminate\Support\Facades\Event;// Register in AppServiceProvider or EventServiceProviderEvent::listen(function (FeatureResolved $event) { if ($event->feature !== 'purchase-button') { return; } // Record the variant assignment when it happens analytics()->identify($event->scope?->id, [ 'ab_purchase_button' => $event->value, ]);});
Record conversions separately when they happen, and you can compute the conversion rate per variant.
// On completed purchaseEvent::dispatch(new PurchaseCompleted($user, $product));
Difference between FeatureResolved and FeatureRetrieved: FeatureResolved fires only on the initial evaluation, while FeatureRetrieved fires on every check. Use FeatureResolved for assignment tracking and FeatureRetrieved for pageview tracking.
In queued jobs there is no authenticated user, so feature checks can behave unexpectedly. Pass an explicit scope to the job.
class SendWeeklyDigest implements ShouldQueue{ public function __construct( private readonly User $user ) {} public function handle(): void { // BAD: always false because no authenticated user // if (Feature::active('new-digest-layout')) { ... } // GOOD: explicitly pass the user if (Feature::for($this->user)->active('new-digest-layout')) { $this->sendNewLayout($this->user); } else { $this->sendLegacyLayout($this->user); } }}
You can also evaluate the flag at dispatch time and pass the result to the job.
// Evaluate in the controller and pass to the job$useNewLayout = Feature::for($user)->active('new-digest-layout');SendWeeklyDigest::dispatch($user, $useNewLayout);
Safe class refactoring for feature names (the Name attribute)
When renaming a class-based feature, changing the flag name stored in the DB would reset the flag for all users. Use the Name attribute to fix the stored name.
use Laravel\Pennant\Attributes\Name;// Old class name: CheckoutV2 → New class name: NewCheckoutExperience// Always stored in the DB as 'checkout-v2'#[Name('checkout-v2')]class NewCheckoutExperience{ public function resolve(User $user): mixed { return $user->isPremium(); }}
Now you can rename the class freely while keeping the DB data as-is.