Laravel Pennant es un paquete ligero y sencillo para gestionar feature flags. Con ellos puedes desplegar nuevas funcionalidades de forma progresiva, realizar tests A/B o complementar el desarrollo trunk-based.
Las feature flags separan el despliegue del código de su lanzamiento. Puedes desplegar el código en producción y activar o desactivar la funcionalidad únicamente mediante configuración.
Define las features con el método define del facade Feature. Habitualmente se hace en el boot de un service provider. La closure recibe un «scope» (por defecto, el usuario autenticado).
En las features basadas en clase puedes definir el método before. Se ejecuta en memoria antes de acudir al almacenamiento; si devuelve un valor distinto de null, ese será el resultado.
class NewApi{ public function before(User $user): mixed { if (Config::get('features.new-api.disabled')) { return $user->isInternalTeamMember(); } } public function resolve(User $user): mixed { // ... }}
before resulta útil para desactivar una feature de emergencia ante un bug o para programar un lanzamiento a una fecha concreta.
En las plantillas Blade dispones de la directiva @feature.
@feature('site-redesign') {{-- 'site-redesign' está activo --}}@else {{-- 'site-redesign' está inactivo --}}@endfeature@featureany(['site-redesign', 'beta']) {{-- Alguna de las dos está activa --}}@endfeatureany
El middleware EnsureFeaturesAreActive indica que una ruta requiere una feature activa. Si no lo está, devuelve 400 Bad Request.
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;Route::get('/api/servers', function () { // ...})->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api'));
Con whenInactive puedes personalizar la respuesta.
EnsureFeaturesAreActive::whenInactive( function (Request $request, array $features) { return new Response(status: 403); });
Dentro de una misma petición, Pennant cachea en memoria los resultados de las features. Comprobar la misma flag varias veces no genera consultas adicionales.Para vaciar la caché manualmente utiliza flushCache.
Por defecto el scope es el usuario autenticado, pero puedes indicar cualquier otro con for.
// Comprobar para un usuario concretoFeature::for($user)->active('new-api');// Comprobar para un equipoFeature::for($user->team)->active('billing-v2');
Ejemplo de gestión por equipo:
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);});
Cuando el scope es null (rutas sin autenticación, comandos Artisan…), si la definición no lo admite se devuelve automáticamente false. Si necesitas gestionar el caso null, tipa el argumento como nullable.
Si compruebas features dentro de un bucle pueden aparecer problemas de rendimiento. Con load puedes precargar los valores.
// Mal: se genera una consulta por iteraciónforeach ($users as $user) { if (Feature::for($user)->active('notifications-beta')) { $user->notify(new RegistrationSuccess); }}// Bien: se cargan todos de antemanoFeature::for($users)->load(['notifications-beta']);foreach ($users as $user) { if (Feature::for($user)->active('notifications-beta')) { $user->notify(new RegistrationSuccess); }}
Para cargar solo lo que aún no está cargado, utiliza loadMissing.
// Activar para el scope por defectoFeature::activate('new-api');// Desactivar para un scope concretoFeature::for($user->team)->deactivate('billing-v2');// Asignar valor enriquecidoFeature::activate('purchase-button', 'seafoam-green');
Para olvidar el valor almacenado utiliza forget. La próxima comprobación reevaluará la definición.
Si eliminas o cambias una feature, puedes borrar los valores del almacenamiento con purge.
// Una sola featureFeature::purge('new-api');// VariasFeature::purge(['new-api', 'purchase-button']);// TodasFeature::purge();
También puedes hacerlo desde Artisan, ideal para integrarlo en la pipeline de despliegue.
php artisan pennant:purge new-api# Variasphp artisan pennant:purge new-api purchase-button# Todas salvo las indicadasphp artisan pennant:purge --except=new-api --except=purchase-button# Todas menos las registradas en un service providerphp artisan pennant:purge --except-registered
En los tests puedes controlar el valor devuelto redefiniendo la feature con Feature::define.
tab=Pest
use Laravel\Pennant\Feature;test('permite controlar el valor de la feature', 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'));}
Lo mismo con features basadas en clase.
tab=Pest
test('permite controlar el valor de la feature', 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));}
Si los drivers integrados no cumplen tus necesidades, puedes crear uno personalizado implementando Laravel\Pennant\Contracts\Driver.
<?phpnamespace 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 {}}
Regístralo con extend en el método boot de un service provider.
Feature::extend('redis', function (Application $app) { return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);});