Laravel Pennant: Real-World Patterns Beyond the Docs
Practical Pennant patterns not covered in the official docs: team-scoped flags for multi-tenant SaaS, emergency kill switches, dark launches, A/B test result tracking, feature flags in queued jobs, and a simple management Artisan command.
Now Feature::active('billing-v2') automatically checks against the current team — no for() call needed anywhere in the codebase. All members of a team get the same experience, and consistency is guaranteed automatically.Roll out gradually based on how long a team has been a customer:
use App\Models\Team;use Illuminate\Support\Carbon;use Illuminate\Support\Lottery;use Laravel\Pennant\Feature;Feature::define('new-billing', function (Team $team) { // Teams registered after Jan 2024 get immediate access if ($team->created_at->isAfter(new Carbon('2024-01-01'))) { return true; } // 2022–2023 registrations: slow 10% rollout if ($team->created_at->isAfter(new Carbon('2022-01-01'))) { return Lottery::odds(1 / 10); } // Oldest customers: cautious 1% rollout return Lottery::odds(1 / 100);});
For early-access agreements (enterprise accounts, design partners), activate manually after the definition runs:
When a production bug surfaces, you want to disable the feature without rolling back code or modifying the database. Class-based features support a before method that runs in-memory before the stored value is retrieved.
<?phpnamespace App\Features;use App\Models\User;use Illuminate\Support\Facades\Config;class NewCheckout{ /** * Emergency kill switch — set FEATURES_NEW_CHECKOUT_DISABLED=true to disable globally. */ public function before(User $user): mixed { if (Config::boolean('features.new_checkout_disabled', false)) { return false; // Disable immediately for everyone } // Keep admins enabled for debugging if ($user->isAdmin()) { return true; } return null; // null passes through to resolve() } public function resolve(User $user): mixed { return $user->isPremium() || Lottery::odds(1 / 5); }}
Set FEATURES_NEW_CHECKOUT_DISABLED=true in your environment and the feature goes dark instantly — no deploy, no database query, no downtime window.
Returning null from before passes control to resolve(). Returning any non-null value — including false — short-circuits the stored value for the duration of the request.
Automate a date-triggered global release using the before method, no cron job required:
public function before(User $user): mixed{ $rolloutDate = Config::get('features.new_api.rollout_date'); if ($rolloutDate && Carbon::parse($rolloutDate)->isPast()) { return true; // Auto-release when the date passes } return null; // Before the date: fall through to resolve()}public function resolve(User $user): mixed{ return $user->isInternalTeamMember();}
# .envFEATURES_NEW_API_ROLLOUT_DATE=2025-06-01
Past that date the feature becomes active for everyone. No deploy needed, no manual activation — the change is fully automated from the moment you set the env variable.
The official docs mention FeatureRetrieved, but the correct event for tracking variant assignment is FeatureResolved — it fires only when the value is resolved for the first time for a scope.
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 at the moment it is first determined analytics()->identify($event->scope?->id, [ 'ab_purchase_button' => $event->value, ]);});
Record conversions separately by reading the persisted flag value:
FeatureResolved vs FeatureRetrieved: FeatureResolved fires only on first evaluation (variant assignment). FeatureRetrieved fires on every check (page views, API calls). Use FeatureResolved for attribution; use FeatureRetrieved for impression counts.
Queued jobs run outside the HTTP context, so there is no authenticated user. Accessing Feature::active() without an explicit scope defaults to null, which returns false for most flag definitions — silently breaking the expected behavior.
class SendWeeklyDigest implements ShouldQueue{ public function __construct( private readonly User $user ) {} public function handle(): void { // BAD: no authenticated user in queue context → always false // if (Feature::active('new-digest-layout')) { ... } // GOOD: pass the scope explicitly if (Feature::for($this->user)->active('new-digest-layout')) { $this->sendNewLayout($this->user); } else { $this->sendLegacyLayout($this->user); } }}
Alternatively, evaluate the flag before dispatching and pass the resolved boolean to the job:
// Evaluate in the controller where the session exists$useNewLayout = Feature::for($user)->active('new-digest-layout');SendWeeklyDigest::dispatch($user, $useNewLayout);
When you rename a class-based feature, Pennant’s stored key changes (it uses the fully qualified class name by default), causing all persisted flag values to be lost. Fix the stored name before renaming:
use Laravel\Pennant\Attributes\Name;// Renaming CheckoutV2 → NewCheckoutExperience// The stored key stays 'checkout-v2' — no data loss#[Name('checkout-v2')]class NewCheckoutExperience{ public function resolve(User $user): mixed { return $user->isPremium(); }}
Add the Name attribute before refactoring the class name, and the database records remain intact.