Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Pennant Laravel Package

laravel/pennant

Laravel Pennant is a simple, lightweight feature flag library for Laravel. Define flags in a service provider and check them with Feature::active() to roll out features gradually to users, segments, or percentages of traffic.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate

Package discovery registers the service provider, so there is nothing to wire up in config/app.php. The publish step gives you two things worth reading first:

  1. config/pennant.php — the default store (normally PENNANT_STORE=database).
  2. The published migration — it creates the features table used by the database driver.

Choose the store immediately. The array driver keeps values in memory for the current request only; the database driver persists per-scope overrides across requests. Almost every real feature-flag rollout needs database, so set it before you write your first flag, not after you wonder why activate() "did nothing".

Define your first flag in AppServiceProvider::boot() (or a dedicated FeatureServiceProvider registered in bootstrap/providers.php):

use Illuminate\Support\Facades\Feature;
use App\Models\User;

Feature::define('new-billing-dashboard', fn (User $user) => $user->isBetaTester());

Check it anywhere:

if (Feature::active('new-billing-dashboard')) { ... }

Feature::for($user)->active('new-billing-dashboard');   // explicit scope
Feature::for($user)->inactive('new-billing-dashboard'); // negated

Blade:

@feature('new-billing-dashboard')
    <x-new-dashboard />
@else
    <x-legacy-dashboard />
@endfeature

A good first use case is exactly this shape: a closure flag scoped to a model attribute, checked in one controller or Blade view, then flipped on for a pennant:purge-able subset of users later.

Implementation Patterns

Closure flags vs. class-based features

Closures in a service provider are fine for a handful of flags. Once flags need dependencies injected or their own tests, move them to classes:

php artisan make:feature NewBillingDashboard

Each generated class implements Laravel\Pennant\Feature with a resolve($scope) method. Register the whole directory once instead of listing them:

Feature::discover(); // scans app/Features

Class-based features are resolved from the container, so constructor injection works. Pennant also auto-resolves the flag type when you pass a class string to define().

Rich values, not just booleans

Flags can return any serializable value — use them for gradual variant rollouts:

Feature::define('purchase-button', fn (User $user) => $user->isVip() ? 'blue' : 'green');

$variant = Feature::for($user)->value('purchase-button');

Combine with @feature/@featureany (added in 1.12) for template-level choices, and prefer ->value() over ->active() whenever the flag is not a pure boolean.

Scope discipline

  • Feature::active('x') with no for() resolves against the authenticated user for the current request.
  • Feature::for($team)->active('x') scopes to any model.
  • Global/kill-switch flags use Feature::for(null) or the Feature::globally() helper (1.25+).

If your app is team-centric rather than user-centric, standardise on Feature::for($team) everywhere and avoid mixing default-scope calls into those code paths — mixing produces flags that look "randomly on" for the same user.

Sticky overrides vs. resolution logic

Feature::for($user)->activate('new-billing-dashboard');    // pin on
Feature::for($user)->deactivate('new-billing-dashboard');  // pin off
Feature::for($user)->forget('new-billing-dashboard');      // drop override, back to resolver

Feature::activateForEveryone('new-billing-dashboard');     // database driver only
Feature::deactivateForEveryone('new-billing-dashboard');

deactivate() writes an explicit false row, so the resolver never runs again for that scope; forget() deletes the row so resolution logic takes over again. Use forget() for "undo my test override", deactivate() for a deliberate opt-out.

Before hooks for environment overrides

Feature::before(function (User $user = null, string $feature) {
    if (app()->isLocal()) {
        return true; // short-circuit everything locally
    }
});

This is the cleanest place for "always on in local/staging" and for emergency kill switches. 1.14 extended before-hook support to named features, so hooks apply consistently regardless of how a flag was defined.

Route-level gating

Alias the middleware ('features' => Laravel\Pennant\Middleware\EnsureFeaturesAreActive::class) and use:

Route::get('/billing', BillingController::class)->middleware('features:new-billing-dashboard');

A failed check returns 403 with a default message (1.17). The middleware accepts multiple comma-separated features and gained an "all" mode in 1.5 — decide per route whether "any" or "all" is what you mean.

Admin tooling and events

Feature::defined() gives you the registered flags and Feature::for($user)->values() / loadAll() gives you the state for one scope — enough to build an internal toggle screen. Toggling a flag mid-request means the in-request cache is stale, so call Feature::flushCache() after writing. State changes fire events (1.3+), which is the right hook for audit logging.

Cleanup

Schedule the purge command for retired flags:

Schedule::command('pennant:purge')->daily();

1.7 added the ability to exclude features from purging — important for long-lived flags that should keep their stored overrides.

Gotchas and Tips

The array store is a trap for the unwary. Feature::activate() on the default array driver works within the request, then vanishes. If a toggle "works in tinker but not in the browser", the store is almost always the cause. Check config('pennant.default') before debugging anything else.

Null scope in queues, commands and tests. Outside an HTTP request there is no authenticated user, so default-scope checks silently resolve against null. Pass the scope explicitly (Feature::for($user->id) or Feature::for($user)) — and if you pass an ID, your resolver closure receives the ID, not the model, so attribute-based rules break. Pass models when the logic needs attributes; Pennant serialises them as Class|id.

Morph maps matter. Serialised scope keys use your morph map when configured. Enable it before you accumulate rows, otherwise renaming a model class orphans every stored override for that scope.

Boolean coercion. active() casts truthiness: a stored 'false' string is true. Store real booleans, or read with value() and compare explicitly. Conversely, if you store 0 or numeric strings, be on 1.17.1+ — earlier releases mis-handled a 0 value coming back from the database driver.

Deactivate-for-everyone is driver-specific. activateForEveryone/deactivateForEveryone need a driver that supports global writes (the database driver does; array does not). Custom drivers must implement the corresponding contract methods.

Write amplification. activate() is an INSERT/UPDATE with a unique constraint. 1.8 hardened the race, and 1.18.5 improved bulk writes, but do not call activate() per row in a loop over thousands of users without batching. On the read side, if a single request checks many flags for one scope, use loadAll() (1.10) instead of N individual active() calls.

Caching inside a request. Resolution results are memoised per scope for the request. Mutating a flag and then re-reading it in the same request requires Feature::flushCache(). In Octane or long-running queue workers, keep an eye on state that outlives a request.

Undefined flags throw. Renaming a flag without updating a call site produces an exception at runtime, not a silent false. Keep flag names in constants or backed enums (PHP Enum support landed in 1.24) so a rename is a single edit and static analysis catches the rest.

Migration connection quirk. If your pennant store points at a non-default connection, publish/edit the migration before running it. 1.16 fixed the migration to respect the configured connection — older versions or stale published migrations will create the table in the wrong place.

Config cache. Changing the store or table name requires php artisan config:clear/config:cache; a cached config pointing at the old store is a genuinely confusing failure mode.

Scope type hints are validated. Resolver closures and feature classes declare their scope type, and Pennant checks it (a primitive-type bug was fixed in 1.18.3). Widening to User|Team|null when a flag is used both globally and per-model saves you from "unexpected scope" errors.

Prefer short-lived flags. Define a retirement date when you create a flag, gate the new code path, then delete both the flag and the old branch — the purge command should be boring, not archaeology.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
bernskioldmedia/laravel-matomo
bernskioldmedia/laravel-harvest
bernskiold/laravel-data-scrubber
bernskiold/laravel-currency-converter
bernskiold/laravel-campaign-monitor
bernskioldmedia/laravel-autotranslate
siberfx/telegram-bot
roy-sandip/laravel-view-extended
gregsphotography/site-builder-core
roy-sandip/laravel-view
anodyne/blade-fluentui-icons
webx-ui/module-catalog-collections
webx-ui/module-catalog-feeds
makidizajnerica/laravel-dto
matviib/scheduler
webx-ui/templates
anodyne/blade-tabler-icons
bref/laravel-websockets
anodyne/blade-hugeicons
1994/flatpack-statamic