skip to content

Pennant Feature Flags

Pennant defines feature flags resolved per scope such as a user or team, stores each result, and checks them in code, Blade and middleware. Interviewers ask how a rollout stays stable per user.

on this pageshow

explore

questions

5

In Laravel Pennant, how do you define a feature with Feature::define(), check it with Feature::active(), and what scope is used by default?

level: juniorimportance: must knowfreq 30%

answer

  1. define in a service provider's boot()
  2. the closure receives the scope
  3. default scope: the authenticated user
  4. Feature::for($team)->active()
  5. pennant:feature makes app/Features classes

basics

~10 s

Feature::define('new-checkout', fn (User $user) => ...) registers a resolver, usually in AppServiceProvider::boot(). Feature::active('new-checkout') checks it for the default scope, the authenticated user, and Feature::for($scope) checks another user, team or value.

solid answer

~40 s

Laravel Pennant is Laravel's first-party feature-flag package. You define a flag with `Feature::define('new-checkout', fn (User $user) => ...)` in a service provider's `boot()`; the closure receives the **scope** and returns the flag's initial value, such as `true`, `false`, a rich value, or a `Lottery::odds(1, 10)` for a 10% rollout. `Feature::active('new-checkout')` checks it against the default scope, which is the authenticated user from the default guard; `Feature::for($user->team)->active(...)` checks another scope, and `inactive()` and `value()` read it other ways. The first check stores the resolved value, so later checks return it without calling the closure. `php artisan pennant:feature NewCheckout` generates a class-based feature in `app/Features` with a `resolve()` method, which needs no registration.

code

php · 19 lines
php
<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Support\Lottery;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Feature::define('new-checkout', fn (User $user) => match (true) {
            $user->is_staff => true,
            default => Lottery::odds(1, 10),
        });
    }
}

go deeper

for a junior

Recall Feature::define() in a service provider, Feature::active() to check, and that the authenticated user is the default scope.

for a middle

Explain scopes and for(), class-based features from pennant:feature with the Name attribute, and that the first resolution is stored and reused.

for a senior

Anticipate null scopes in jobs and commands, choose a team or organisation scope deliberately, and keep stored names stable across refactors.

for a principal

Decide what a flag's scope should be for each rollout, and how flag definitions are owned, reviewed and retired across teams.

## What Pennant is **Laravel Pennant** (`laravel/pennant`, version 1.26 on this pin) is a small, first-party feature-flag package. A **feature** is a named switch or value; a **scope** is whatever the feature is decided for, most often a user, sometimes a team or an organisation. Pennant answers one question: for this feature and this scope, what is the value? Install it with `composer require laravel/pennant`, publish its config and migration with `php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"`, and run `migrate` to create the `features` table that the default `database` store uses. ## Defining a feature with a closure Closure features are defined with `Feature::define()`, typically in `AppServiceProvider::boot()`. The closure receives the scope and returns the **initial value**: - `true` or `false` for a simple on or off flag; - a rich value such as `'variant-b'`; - an `Illuminate\Support\Lottery`, such as `Lottery::odds(1, 10)`, which Pennant draws immediately to get a boolean. For the reserved scenario, rolling out a new checkout to 10% of users while staff always see it, the definition reads: staff get `true`, everyone else gets `Lottery::odds(1, 10)`. When a definition is only a lottery, you can pass it without a closure: `Feature::define('new-checkout', Lottery::odds(1, 10))`. ## Class-based features `php artisan pennant:feature NewCheckout` generates `App\Features\NewCheckout` with a `resolve(mixed $scope): mixed` method. Class features: 1. need no `define()` call, because Pennant registers them the first time they are checked; 2. are resolved through the container, so their constructors can take dependencies; 3. are stored under the fully qualified class name unless you add the `#[Name('new-checkout')]` attribute, which decouples stored rows from your namespace layout. You check them by class: `Feature::active(NewCheckout::class)`. ## Checking a feature | Call | Returns | |---|---| | `Feature::active('new-checkout')` | true when the value is anything other than `false` | | `Feature::inactive('new-checkout')` | true when the value is `false` | | `Feature::value('purchase-button')` | the stored value itself | | `Feature::allAreActive([...])`, `someAreActive([...])` | combined checks | | `Feature::when('new-checkout', fn () => ..., fn () => ...)` | runs one closure or the other | ## The scope, and how to change it Without `for()`, Pennant uses the **default scope**, which is `auth()->guard()->user()`: the user from the default guard, or `null` when nobody is logged in. Three ways to change that: - `Feature::for($user->team)->active('billing-v2')` checks a different scope for one call; - `Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team)` changes the default for the whole app; - adding the `HasFeatures` trait to a model lets you write `$user->features()->active('new-checkout')`. The closure's parameter type matters. A resolver typed `User $user` cannot accept `null`, so in a queued job or an unauthenticated route, where the default scope is `null`, Pennant returns `false` without calling it. Type the parameter `?User` if guests should be decided too. ## What happens on the first check The first time `new-checkout` is checked for user 42, Pennant runs the resolver, draws the lottery, and stores the result for that feature and scope. Every later check for user 42 reads the stored value; the closure is not called again. That persistence, not the closure, is what keeps a rollout stable, and it is also why changing a definition does not affect users who were already resolved until you purge the stored values. ## Interview traps - **"Pennant decides randomly on every request."** Only the first check per scope runs the resolver; afterwards the stored value answers. - **"`active()` means the value is `true`."** Any value except `false` is active, which matters for rich values such as button colours. - **"Class features need registering."** They register themselves on first use; only closure features need `define()`. - **"Guests get the flag like anyone else."** With a non-nullable resolver they get `false`, because their scope is `null`. In tests, the simplest control is to re-define the feature at the start of the test, for example `Feature::define('new-checkout', true)`, which overrides the provider's definition for that test.

  • In Laravel Pennant, what does Feature::active('new-checkout') return inside a queued job when the resolver is typed fn (User $user)?
    `false`. A job has no authenticated user, so the default scope is `null`; the resolver's non-nullable `User` type cannot accept it, so Pennant skips the closure, dispatches `UnexpectedNullScopeEncountered`, and resolves the feature as `false`. Pass the user explicitly with `Feature::for($user)` inside jobs.
  • Why add #[Name('new-checkout')] to a Laravel Pennant class-based feature?
    By default Pennant stores a class feature under its fully qualified class name, such as `App\Features\NewCheckout`. Moving or renaming the class would orphan every stored value. The `Name` attribute stores a stable string instead, so refactors keep existing users' results.

saying these in an interview costs you the question

  • Pennant re-runs the define() closure on every check.
  • Pennant's default scope is the current request or session.
  • Class-based features must also be registered with Feature::define().
  • Feature::active() returns true only when the value is exactly true.
  • A resolver typed User receives null for guests and must handle it.
open as a page

With Laravel Pennant, how does a Lottery-based 10% rollout stay stable per user, and how do the database and array drivers change that?

level: middleimportance: should knowfreq 26%

basics

~20 s

Pennant draws the lottery on a user's first check and stores the result in the features table by feature name and scope, so later checks reuse it. The array driver keeps results in memory only, so each request draws again.

open as a page

In Laravel Pennant, how do activate(), deactivate(), activateForEveryone() and purge() change stored flag values, and when would you run pennant:purge?

level: middleimportance: should knowfreq 18%

basics

~20 s

activate() and deactivate() store a value for one scope, forget() deletes it, and activateForEveryone() overwrites every stored row for a feature. purge() deletes a feature's rows so the next check re-runs its definition; pennant:purge does that from a deploy.

open as a page

Checking a Laravel Pennant flag inside a loop over 500 users fires hundreds of queries and returns false in queued jobs. Why, and how do you fix both?

level: seniorimportance: should knowfreq 20%

basics

~20 s

Each Feature::for($user)->active() looks up one scope, so a loop queries once per user; Feature::for($users)->loadMissing() fetches them in bulk first. Queued jobs have no authenticated user, so the null default scope makes a User-typed resolver resolve false.

open as a page

In a Laravel app using Pennant, how do the @feature Blade directive and the EnsureFeaturesAreActive middleware gate UI and routes, and what do they return?

level: middleimportance: nice to knowfreq 16%

basics

~20 s

@feature('new-checkout') renders its block when the flag is active for the default scope, and @feature('name', 'value') compares a rich value. EnsureFeaturesAreActive::using('new-checkout') on a route aborts with 400 when any listed feature is inactive, unless whenInactive() sets another response.

open as a page