Passer au contenu principal

Qu’est-ce que Laravel Pennant

Laravel Pennant est un package léger et simple pour les feature flags. Les flags permettent un rollout progressif, des A/B tests et complètent le développement trunk-based.

Concept

Les flags séparent déploiement et release : le code est en production, l’activation est contrôlée par configuration.

Installation

1

Installer le package

composer require laravel/pennant
2

Publier config et migrations

php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
Cela crée config/pennant.php et une migration dans database/migrations.
3

Migrer

Crée la table features où Pennant stocke les valeurs.
php artisan migrate

Configuration

Deux drivers de stockage supportés :
DriverDescription
databasePersistance en RDBMS (défaut)
arrayEn mémoire (tests, usage temporaire)
// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),

Définition de features

Basée sur une closure

Utilisez Feature::define dans un service provider boot. La closure reçoit le « scope » (généralement l’utilisateur authentifié).
<?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-api', fn (User $user) => match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        });
    }
}
Logique :
  • Interne → activé
  • Client à fort trafic → désactivé
  • Sinon → 1 % de chances
La valeur est calculée et persistée au premier check ; les checks suivants réutilisent la valeur.
Si la définition retourne juste une loterie, omettez la closure.
Feature::define('site-redesign', Lottery::odds(1, 1000));

Basée sur une classe

Pas d’enregistrement dans un provider.
php artisan pennant:feature NewApi
Fichier créé dans app/Features ; implémentez resolve.
<?php

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Lottery;

class NewApi
{
    /**
     * Résout la valeur initiale.
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

Personnaliser le nom stocké

Par défaut, la classe FQN. Utilisez l’attribut Name.
use Laravel\Pennant\Attributes\Name;

#[Name('new-api')]
class NewApi
{
    // ...
}

Intercepter avec before

Cette méthode s’exécute avant la lecture du stockage : si elle retourne autre chose que null, cette valeur est utilisée.
class NewApi
{
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }
    }

    public function resolve(User $user): mixed
    {
        // ...
    }
}
Utile pour un kill switch en cas de bug ou un rollout programmé.

Vérification

Feature::active() / Feature::inactive()

Cible l’utilisateur authentifié par défaut.
use Laravel\Pennant\Feature;

if (Feature::active('new-api')) {
    // Nouveau code
}
Avec une classe :
use App\Features\NewApi;

if (Feature::active(NewApi::class)) {
    // ...
}
Autres helpers :
Feature::allAreActive(['new-api', 'site-redesign']);

Feature::someAreActive(['new-api', 'site-redesign']);

Feature::inactive('new-api');

Feature::allAreInactive(['new-api', 'site-redesign']);

Feature::someAreInactive(['new-api', 'site-redesign']);

Exécution conditionnelle (when / unless)

return Feature::when(NewApi::class,
    fn () => $this->resolveNewApiResponse($request),
    fn () => $this->resolveLegacyApiResponse($request),
);
unless inverse la logique.
return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);

Trait HasFeatures

Ajoutez-le au modèle User pour interroger directement.
use Laravel\Pennant\Concerns\HasFeatures;

class User extends Authenticatable
{
    use HasFeatures;
}
if ($user->features()->active('new-api')) {
    // ...
}

$value = $user->features()->value('purchase-button');

$user->features()->when('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

Directives Blade

@feature('site-redesign')
    {{-- Si actif --}}
@else
    {{-- Si inactif --}}
@endfeature

@featureany(['site-redesign', 'beta'])
    {{-- L'un ou l'autre actif --}}
@endfeatureany

Middleware

EnsureFeaturesAreActive protège une route ; sans le flag, 400 Bad Request.
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::get('/api/servers', function () {
    // ...
})->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api'));
Personnalisez la réponse via whenInactive.
EnsureFeaturesAreActive::whenInactive(
    function (Request $request, array $features) {
        return new Response(status: 403);
    }
);

Cache en mémoire

Pennant cache les résultats par requête. Vider manuellement :
Feature::flushCache();

Scopes

Spécifier un scope

Par défaut, l’utilisateur authentifié. Avec for, n’importe quoi.
Feature::for($user)->active('new-api');

Feature::for($user->team)->active('billing-v2');
Flag par équipe :
Feature::define('billing-v2', function (Team $team) {
    if ($team->created_at->isAfter(new Carbon('1st Jan, 2023'))) {
        return true;
    }

    if ($team->created_at->isAfter(new Carbon('1st Jan, 2019'))) {
        return Lottery::odds(1 / 100);
    }

    return Lottery::odds(1 / 1000);
});

Personnaliser le scope par défaut

Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);
Après cela :
Feature::active('billing-v2');
// équivalent à
Feature::for($user->team)->active('billing-v2');

Scope null

Sans authentification (Artisan, etc.), une définition non typée pour null retourne automatiquement false. Typez avec nullable si vous voulez le gérer :
Feature::define('new-api', fn (User|null $user) => match (true) {
    $user === null => true,
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

Valeurs riches

Les flags peuvent retourner autre chose qu’un booléen — par exemple pour un A/B test de couleur.
Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));
Lecture :
$color = Feature::value('purchase-button');
Blade branché sur la valeur :
@feature('purchase-button', 'blue-sapphire')
    {{-- blue-sapphire actif --}}
@elsefeature('purchase-button', 'seafoam-green')
    {{-- seafoam-green actif --}}
@elsefeature('purchase-button', 'tart-orange')
    {{-- tart-orange actif --}}
@endfeature
Avec valeurs riches, tout ce qui n’est pas false est considéré comme actif.
Avec when, la valeur est passée à la première closure.
Feature::when('purchase-button',
    fn ($color) => /* $color est la valeur */,
    fn () => /* Inactif */,
);

Lecture multiple

values récupère plusieurs valeurs.
Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]
all récupère toutes les valeurs définies.
Feature::all();
Pour inclure les classes de feature dans all(), appelez discover dans un provider.
Feature::discover();
Cela enregistre toutes les classes de app/Features.

Eager loading

Dans une boucle, les checks entraînent des requêtes. Chargez à l’avance avec load.
// KO : requête par itération
foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

// OK : chargement en amont
Feature::for($users)->load(['notifications-beta']);

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}
loadMissing ne charge que ce qui manque.
Feature::for($users)->loadMissing([
    'new-api',
    'purchase-button',
    'notifications-beta',
]);

Modifier les valeurs

Manuel

activate / deactivate :
Feature::activate('new-api');

Feature::for($user->team)->deactivate('billing-v2');

Feature::activate('purchase-button', 'seafoam-green');
forget supprime la valeur stockée ; elle sera recalculée.
Feature::forget('purchase-button');

En masse

activateForEveryone / deactivateForEveryone s’appliquent à tous les scopes en stock.
Feature::activateForEveryone('new-api');
Feature::activateForEveryone('purchase-button', 'seafoam-green');
Feature::deactivateForEveryone('new-api');

Purge

Retirez les valeurs quand vous supprimez ou modifiez une feature.
Feature::purge('new-api');

Feature::purge(['new-api', 'purchase-button']);

Feature::purge();
Version Artisan (utile en CI/CD) :
php artisan pennant:purge new-api

# Plusieurs
php artisan pennant:purge new-api purchase-button

# Purger tout sauf certaines
php artisan pennant:purge --except=new-api --except=purchase-button

# Purger tout sauf ce qui est enregistré dans un provider
php artisan pennant:purge --except-registered

Tests

Redéfinir en test

Dans un test, redéfinissez pour contrôler la valeur.
tab=Pest
use Laravel\Pennant\Feature;

test('it can control feature values', function () {
    Feature::define('purchase-button', 'seafoam-green');

    expect(Feature::value('purchase-button'))->toBe('seafoam-green');
});
tab=PHPUnit
use Laravel\Pennant\Feature;

public function test_it_can_control_feature_values(): void
{
    Feature::define('purchase-button', 'seafoam-green');

    $this->assertSame('seafoam-green', Feature::value('purchase-button'));
}
Idem avec les classes.
tab=Pest
test('it can control feature values', function () {
    Feature::define(NewApi::class, true);

    expect(Feature::value(NewApi::class))->toBeTrue();
});
tab=PHPUnit
use App\Features\NewApi;

public function test_it_can_control_feature_values(): void
{
    Feature::define(NewApi::class, true);

    $this->assertTrue(Feature::value(NewApi::class));
}

Store pour les tests

Réglez dans phpunit.xml.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <php>
        <env name="PENNANT_STORE" value="array"/>
    </php>
</phpunit>

Driver personnalisé

Implémentez Laravel\Pennant\Contracts\Driver.
<?php

namespace App\Extensions;

use Laravel\Pennant\Contracts\Driver;

class RedisFeatureDriver implements Driver
{
    public function define(string $feature, callable $resolver): void {}
    public function defined(): array {}
    public function getAll(array $features): array {}
    public function get(string $feature, mixed $scope): mixed {}
    public function set(string $feature, mixed $scope, mixed $value): void {}
    public function setForAllScopes(string $feature, mixed $value): void {}
    public function delete(string $feature, mixed $scope): void {}
    public function purge(array|null $features): void {}
}
Enregistrez avec extend.
Feature::extend('redis', function (Application $app) {
    return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
});
Puis dans config/pennant.php :
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],
],

Récapitulatif

ObjectifMéthode
Installercomposer require laravel/pennant
DéfinirFeature::define('name', fn ($user) => ...)
VérifierFeature::active('name')
Dans Blade@feature('name') ... @endfeature
ModifierFeature::activate('name') / deactivate
Tous les scopesFeature::activateForEveryone('name')
En testRedéfinir via Feature::define('name', true)
PurgerFeature::purge('name')

Prochaines étapes

Gestion des erreurs

Traitement des exceptions et rapports.

Laravel Pulse

Tableau de bord de performance.
Dernière modification le 13 juillet 2026