skip to content

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.