Saltar al contenido principal

Qué es Laravel Pennant

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.

Qué son las feature flags

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.

Instalación

1

Instalar el paquete

Instala Pennant con Composer.
composer require laravel/pennant
2

Publicar la configuración y las migraciones

Publica los archivos con el comando Artisan vendor:publish.
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
Se generan config/pennant.php y las migraciones en database/migrations.
3

Ejecutar las migraciones

Se crea la tabla features, en la que Pennant guarda los valores de las feature flags.
php artisan migrate

Configuración

En config/pennant.php configuras el driver de almacenamiento. Pennant admite dos drivers:
DriverDescripción
databasePersiste los valores en una BD relacional (por defecto)
arrayAlmacena en memoria (para tests y usos puntuales)
// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),

Definir features

Definición basada en closure

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).
<?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),
        });
    }
}
Lógica de la feature del ejemplo:
  • Los miembros del equipo interno la tienen siempre ON.
  • Los clientes de alto tráfico la tienen OFF.
  • El resto tienen un 1 % de probabilidad de tenerla ON.
La primera vez que se comprueba la feature se persiste el resultado de la closure. Las siguientes veces se utiliza el valor almacenado.
Si la definición solo devuelve una Lottery, puedes omitir la closure.
Feature::define('site-redesign', Lottery::odds(1, 1000));

Definición basada en clase

Pennant también permite definir features como clase. No es necesario registrarlas en el service provider.
php artisan pennant:feature NewApi
La clase generada se ubica en app/Features. Basta con implementar el método resolve.
<?php

namespace App\Features;

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

class NewApi
{
    /**
     * Resuelve el valor inicial de la feature
     */
    public function resolve(User $user): mixed
    {
        return match (true) {
            $user->isInternalTeamMember() => true,
            $user->isHighTrafficCustomer() => false,
            default => Lottery::odds(1 / 100),
        };
    }
}

Personalizar el nombre almacenado

Por defecto se guarda el nombre de clase totalmente cualificado. Puedes personalizarlo con el atributo Name.
use Laravel\Pennant\Attributes\Name;

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

Interceptar la comprobación con before

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.

Comprobar features

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

Con active compruebas si la feature está activa. Por defecto se comprueba contra el usuario autenticado.
use Laravel\Pennant\Feature;

if (Feature::active('new-api')) {
    // Lógica para la nueva API
}
En features basadas en clase pasa el nombre de la clase.
use App\Features\NewApi;

if (Feature::active(NewApi::class)) {
    // ...
}
Otros métodos útiles:
// ¿Todas activas?
Feature::allAreActive(['new-api', 'site-redesign']);

// ¿Alguna activa?
Feature::someAreActive(['new-api', 'site-redesign']);

// ¿Feature inactiva?
Feature::inactive('new-api');

// ¿Todas inactivas?
Feature::allAreInactive(['new-api', 'site-redesign']);

// ¿Alguna inactiva?
Feature::someAreInactive(['new-api', 'site-redesign']);

Ejecución condicional (when / unless)

Con when ejecutas una closure solo si la feature está activa.
return Feature::when(NewApi::class,
    fn () => $this->resolveNewApiResponse($request),
    fn () => $this->resolveLegacyApiResponse($request),
);
unless hace lo contrario: ejecuta la primera closure cuando la feature está inactiva.
return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);

Trait HasFeatures

Añadiendo HasFeatures al modelo User, puedes comprobar features directamente desde el modelo.
use Laravel\Pennant\Concerns\HasFeatures;

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

// Obtener el valor
$value = $user->features()->value('purchase-button');

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

Directivas Blade

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

Middleware

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);
    }
);

Caché en memoria

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.
Feature::flushCache();

Scopes

Indicar el scope

Por defecto el scope es el usuario autenticado, pero puedes indicar cualquier otro con for.
// Comprobar para un usuario concreto
Feature::for($user)->active('new-api');

// Comprobar para un equipo
Feature::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);
});

Personalizar el scope por defecto

Con Feature::resolveScopeUsing puedes personalizar el scope por defecto.
Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);
Después, si omites for, se usa el scope por defecto.
Feature::active('billing-v2');
// Equivalente a:
Feature::for($user->team)->active('billing-v2');

Scope opcional (nullable)

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.
Feature::define('new-api', fn (User|null $user) => match (true) {
    $user === null => true,
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

Valores enriquecidos

Las features pueden devolver valores distintos de un booleano. Por ejemplo, para controlar el color de un botón en un test A/B.
Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));
Para obtener el valor utiliza value.
$color = Feature::value('purchase-button');
En Blade puedes ramificar por valor.
@feature('purchase-button', 'blue-sapphire')
    {{-- blue-sapphire activo --}}
@elsefeature('purchase-button', 'seafoam-green')
    {{-- seafoam-green activo --}}
@elsefeature('purchase-button', 'tart-orange')
    {{-- tart-orange activo --}}
@endfeature
Cuando se usan valores enriquecidos, cualquier valor distinto de false se considera activo.
Si el valor enriquecido se pasa a when, la primera closure recibe dicho valor.
Feature::when('purchase-button',
    fn ($color) => /* $color contiene el valor */,
    fn () => /* Inactivo */,
);

Obtener varias features

Con values puedes obtener varios valores a la vez.
Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]
Con all obtienes el valor de todas las features definidas.
Feature::all();
Para incluir las features basadas en clase en el resultado de all, llama a discover en un service provider.
Feature::discover();
Con eso se registran todas las clases del directorio app/Features.

Eager loading

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ón
foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

// Bien: se cargan todos de antemano
Feature::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.
Feature::for($users)->loadMissing([
    'new-api',
    'purchase-button',
    'notifications-beta',
]);

Actualizar valores

Actualización manual

activate y deactivate cambian el estado ON/OFF.
// Activar para el scope por defecto
Feature::activate('new-api');

// Desactivar para un scope concreto
Feature::for($user->team)->deactivate('billing-v2');

// Asignar valor enriquecido
Feature::activate('purchase-button', 'seafoam-green');
Para olvidar el valor almacenado utiliza forget. La próxima comprobación reevaluará la definición.
Feature::forget('purchase-button');

Actualización masiva

activateForEveryone y deactivateForEveryone aplican el valor a todos los scopes almacenados.
Feature::activateForEveryone('new-api');
Feature::activateForEveryone('purchase-button', 'seafoam-green');
Feature::deactivateForEveryone('new-api');

Purgar features

Si eliminas o cambias una feature, puedes borrar los valores del almacenamiento con purge.
// Una sola feature
Feature::purge('new-api');

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

// Todas
Feature::purge();
También puedes hacerlo desde Artisan, ideal para integrarlo en la pipeline de despliegue.
php artisan pennant:purge new-api

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

# Todas salvo las indicadas
php artisan pennant:purge --except=new-api --except=purchase-button

# Todas menos las registradas en un service provider
php artisan pennant:purge --except-registered

Tests

Redefinir features

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));
}

Configurar el store para pruebas

Puedes indicar el store con una variable de entorno en phpunit.xml.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <php>
        <env name="PENNANT_STORE" value="array"/>
    </php>
</phpunit>

Drivers personalizados

Si los drivers integrados no cumplen tus necesidades, puedes crear uno personalizado implementando 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 {}
}
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'), []);
});
Después indícalo en config/pennant.php.
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],
],

Resumen

ObjetivoCómo hacerlo
Instalar Pennantcomposer require laravel/pennant
Definir una featureFeature::define('name', fn ($user) => ...)
Comprobar una featureFeature::active('name')
Comprobar en Blade@feature('name') ... @endfeature
Actualizar el valorFeature::activate('name') / deactivate
Aplicar a todos los scopesFeature::activateForEveryone('name')
Controlarla en testsRedefinir con Feature::define('name', true)
Purgar del almacenamientoFeature::purge('name')

Próximos pasos

Debug y gestión de errores

Aprende cómo maneja Laravel las excepciones y los informes de errores.

Laravel Pulse

Añade a tu aplicación un panel de monitorización de rendimiento.
Última modificación el 13 de julio de 2026