Se explican patrones prácticos de Laravel Pennant más allá de lo básico. Presentamos scopes por equipo, kill switches de emergencia, dark launches, comandos de gestión y otros conocimientos que no se ven solo con la documentación oficial.
Para el uso básico, consulta la página de la guía. En esta página presentamos patrones prácticos que no aparecen en la documentación oficial.
Solo con esto, Feature::active('billing-v2') apunta automáticamente al equipo actual. Sea quien sea el miembro del equipo, mientras pertenezca al mismo equipo se devuelve el mismo resultado, con lo que se mantiene la coherencia de la UI.Ejemplo de rollout progresivo según la fecha de registro del equipo.
use App\Models\Team;use Illuminate\Support\Carbon;use Illuminate\Support\Lottery;use Laravel\Pennant\Feature;Feature::define('new-billing', function (Team $team) { // Equipos registrados desde 2024 en adelante: activo de inmediato if ($team->created_at->isAfter(new Carbon('2024-01-01'))) { return true; } // Registrados entre 2022 y 2023: rollout del 10 % if ($team->created_at->isAfter(new Carbon('2022-01-01'))) { return Lottery::odds(1 / 10); } // Equipos antiguos: prudentemente al 1 % return Lottery::odds(1 / 100);});
Si quieres activarlo solo para equipos concretos (entrega anticipada a clientes enterprise, por ejemplo):
// Tras el despliegue, activar manualmente solo para equipos específicosFeature::for($earlyAccessTeam)->activate('new-billing');
Kill switch de emergencia (aprovechando el método before)
Si se descubre un bug en producción, puedes desactivar la funcionalidad al instante sin hacer rollback de código. Si añades un método before a una feature basada en clase, se comprueba antes que el valor de almacenamiento.
<?phpnamespace App\Features;use App\Models\User;use Illuminate\Support\Facades\Config;class NewCheckout{ /** * Interruptor de emergencia que permite desactivar al instante desde config al ocurrir un bug en producción */ public function before(User $user): mixed { // Control desde el valor de config/features.php o env if (Config::boolean('features.new_checkout_disabled', false)) { return false; // Desactivación inmediata para todos } // Los administradores siempre lo tienen activo (para depuración) if ($user->isAdmin()) { return true; } return null; // Devolver null hace que pase a resolve() normal } public function resolve(User $user): mixed { return $user->isPremium() || Lottery::odds(1 / 5); }}
Con solo establecer la variable de entorno FEATURES_NEW_CHECKOUT_DISABLED=true, se puede parar la funcionalidad sin tocar la BD. Es un kill switch sin despliegue.
Si before devuelve null, se ejecuta resolve(). Si devuelve false, se trata inmediatamente como inactivo. Salvo en emergencias, devuelve null.
Un caso en el que quieres publicar automáticamente para todos los usuarios en una fecha/hora concreta. Puede implementarse con el método before.
public function before(User $user): mixed{ $rolloutDate = Config::get('features.new_api.rollout_date'); if ($rolloutDate && Carbon::parse($rolloutDate)->isPast()) { return true; // Al pasar la fecha de rollout, se publica para todos } return null; // Si aún no, pasa a resolve() normal}public function resolve(User $user): mixed{ // Antes del rollout, solo miembros internos return $user->isInternalTeamMember();}
# .envFEATURES_NEW_API_ROLLOUT_DATE=2025-04-01
Con esto, al pasar el 2025-04-01 se publica automáticamente para todos los usuarios. Se automatiza sin despliegue, sin BD y sin comando Artisan.
Es un patrón en el que ejecutas la lógica nueva con datos de producción sin mostrársela al usuario, y comparas los resultados con la lógica antigua. Si no hay problemas, basta con activar la flag para completar la release.
class RecommendationController{ public function index(Request $request) { $legacyResult = $this->getLegacyRecommendations($request->user()); // En modo shadow ejecuta el nuevo algoritmo y compara, pero muestra la lógica antigua if (Feature::for($request->user())->active('recommendation-v2-shadow')) { try { $newResult = $this->getNewRecommendations($request->user()); // Registra las diferencias en el log (sin mostrárselas al usuario) if ($legacyResult !== $newResult) { Log::channel('shadow_mode')->info('recommendation diff', [ 'user_id' => $request->user()->id, 'legacy' => $legacyResult, 'new' => $newResult, ]); } } catch (\Throwable $e) { Log::error('shadow mode error', ['error' => $e->getMessage()]); } } // La respuesta es siempre el resultado de la lógica antigua return response()->json($legacyResult); }}
Revisando los logs, cuando ya no queden diferencias, basta con conmutar la flag a recommendation-v2.
Recolección de resultados de A/B testing mediante eventos
En la documentación oficial hay una mención al evento FeatureRetrieved, pero no se muestran patrones reales de agregación de A/B testing.El evento FeatureResolved se dispara solo la primera vez que se resuelve el valor de la feature. Aprovéchalo para registrar la asignación de variante al usuario.
use Laravel\Pennant\Events\FeatureResolved;use Illuminate\Support\Facades\Event;// Registrar en AppServiceProvider o EventServiceProviderEvent::listen(function (FeatureResolved $event) { if ($event->feature !== 'purchase-button') { return; } // Registrar en el momento de la asignación de variante al usuario analytics()->identify($event->scope?->id, [ 'ab_purchase_button' => $event->value, ]);});
Si además registras por separado cuando ocurre la conversión, puedes agregar la tasa de conversión por variante.
// Al completar la compraEvent::dispatch(new PurchaseCompleted($user, $product));
Diferencia entre FeatureResolved y FeatureRetrieved: FeatureResolved se dispara solo en la primera evaluación; FeatureRetrieved se dispara en cada comprobación. Para registrar asignaciones es adecuado FeatureResolved, y para seguimiento de vistas de página, FeatureRetrieved.
En los jobs de la cola no hay usuario autenticado, por lo que las comprobaciones de feature pueden comportarse de forma inesperada. Da un scope explícito al job.
class SendWeeklyDigest implements ShouldQueue{ public function __construct( private readonly User $user ) {} public function handle(): void { // Mal: sin usuario autenticado, siempre da false // if (Feature::active('new-digest-layout')) { ... } // Bien: pasar el usuario explícitamente if (Feature::for($this->user)->active('new-digest-layout')) { $this->sendNewLayout($this->user); } else { $this->sendLegacyLayout($this->user); } }}
También es posible evaluar la flag en el momento del dispatch y pasarla al job.
// Evaluar en el controlador y pasar al job$useNewLayout = Feature::for($user)->active('new-digest-layout');SendWeeklyDigest::dispatch($user, $useNewLayout);
Refactorización segura del nombre de la feature (atributo Name)
Al renombrar una feature basada en clase, si cambia el nombre de la flag guardada en la BD, se resetean todas las flags de los usuarios. Fija el nombre de guardado con el atributo Name.
use Laravel\Pennant\Attributes\Name;// Nombre de clase antiguo: CheckoutV2 → nuevo nombre: NewCheckoutExperience// En la BD siempre se guarda como 'checkout-v2'#[Name('checkout-v2')]class NewCheckoutExperience{ public function resolve(User $user): mixed { return $user->isPremium(); }}
Con esto puedes refactorizar libremente el nombre de la clase manteniendo los datos de la BD.