Vai al contenuto principale

Cos’è Laravel Pennant

Laravel Pennant è un pacchetto leggero per i feature flag. Ti permette rollout progressivi, A/B test e trunk-based development.

Cosa sono i feature flag

I flag separano deploy e rilascio: il codice va in produzione ma la funzionalità è controllata da config.

Installazione

1

Pacchetto

composer require laravel/pennant
2

Pubblica config e migration

php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
3

Migrate

php artisan migrate

Configurazione

config/pennant.php gestisce lo store:
DriverDescrizione
databasePersistente in DB (default)
arrayIn memoria (test/uso temporaneo)
'default' => env('PENNANT_STORE', 'database'),

Definizione delle feature

Closure-based

Nel boot di un service provider:
<?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),
        });
    }
}
Se la definizione è solo una Lottery puoi omettere la closure:
Feature::define('site-redesign', Lottery::odds(1, 1000));

Class-based

php artisan pennant:feature NewApi
app/Features/NewApi.php:
<?php

namespace App\Features;

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

class NewApi
{
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

Nome memorizzato

use Laravel\Pennant\Attributes\Name;

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

Metodo before

Eseguito in memoria prima dello store; se non null prevale.
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 per kill switch o rollout schedulati.

Verifica delle feature

active / inactive

use Laravel\Pennant\Feature;

if (Feature::active('new-api')) {
    // Nuova API
}
Classe:
use App\Features\NewApi;

if (Feature::active(NewApi::class)) {
    // ...
}
Altri metodi:
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']);

when / unless

return Feature::when(NewApi::class,
    fn () => $this->resolveNewApiResponse($request),
    fn () => $this->resolveLegacyApiResponse($request),
);

return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);

Trait HasFeatures

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 () => /* ... */,
);

Blade

@feature('site-redesign')
    {{-- Attiva --}}
@else
    {{-- Non attiva --}}
@endfeature

@featureany(['site-redesign', 'beta'])
    {{-- Almeno una attiva --}}
@endfeatureany

Middleware

Il middleware EnsureFeaturesAreActive richiede la feature: se inattiva restituisce 400.
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

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

Cache in-memory

Pennant memorizza i risultati per richiesta.
Feature::flushCache();

Scope

Specifica dello scope

Default: utente autenticato. Con for():
Feature::for($user)->active('new-api');
Feature::for($user->team)->active('billing-v2');
Esempio per team:
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);
});

Scope predefinito

Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);
Dopo:
Feature::active('billing-v2');
// equivalente a
Feature::for($user->team)->active('billing-v2');

Scope nullable

Se lo scope è null (route senza autenticazione, Artisan), la definizione deve accettarlo altrimenti restituisce false.
Feature::define('new-api', fn (User|null $user) => match (true) {
    $user === null => true,
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

Valori ricchi

Non solo boolean:
Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));
Recupero:
$color = Feature::value('purchase-button');
Blade con valore:
@feature('purchase-button', 'blue-sapphire')
    {{-- blue-sapphire --}}
@elsefeature('purchase-button', 'seafoam-green')
    {{-- seafoam-green --}}
@elsefeature('purchase-button', 'tart-orange')
    {{-- tart-orange --}}
@endfeature
Con valori ricchi, tutto ciò che non è false è considerato attivo.
Feature::when('purchase-button',
    fn ($color) => /* riceve $color */,
    fn () => /* inattiva */,
);

Più feature contemporaneamente

Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]
Tutte:
Feature::all();
Per includere anche le class-based nel risultato:
Feature::discover();

Eager loading

// NG
foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

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

foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}
Solo mancanti:
Feature::for($users)->loadMissing([
    'new-api',
    'purchase-button',
    'notifications-beta',
]);

Aggiornamento dei valori

Manuale

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

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

Feature::activate('purchase-button', 'seafoam-green');
Dimenticare per rivalutare:
Feature::forget('purchase-button');

Bulk

Feature::activateForEveryone('new-api');
Feature::activateForEveryone('purchase-button', 'seafoam-green');
Feature::deactivateForEveryone('new-api');

Purge

Feature::purge('new-api');
Feature::purge(['new-api', 'purchase-button']);
Feature::purge();
Comandi Artisan:
php artisan pennant:purge new-api

php artisan pennant:purge new-api purchase-button

php artisan pennant:purge --except=new-api --except=purchase-button

php artisan pennant:purge --except-registered

Test

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'));
}
Classe:
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 per i test

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

Driver personalizzato

Implementa 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 {}
}
Feature::extend('redis', function (Application $app) {
    return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
});
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],
],

ObiettivoMetodo
Installazionecomposer require laravel/pennant
DefinizioneFeature::define('name', fn ($user) => ...)
VerificaFeature::active('name')
Blade@feature('name') ... @endfeature
UpdateFeature::activate('name') / deactivate
BulkFeature::activateForEveryone('name')
TestFeature::define('name', true)
PurgeFeature::purge('name')

Prossimi passi

Gestione errori

Gestione delle eccezioni.

Laravel Pulse

Dashboard di monitoraggio.
Ultima modifica il 13 luglio 2026