Patrons pratiques allant au-delà des bases de Laravel Pennant : scope par équipe, kill switch d’urgence, dark launch, commandes de management, etc. — savoir-faire absent de la documentation officielle.
Pour les bases, reportez-vous à la page de guide. Cette page présente des patrons pratiques que la documentation officielle ne couvre pas.
Si vous souhaitez gérer les flags à l’échelle de l’équipe (locataire) plutôt qu’à l’échelle de l’utilisateur, changez le scope par défaut vers l’équipe.
Cela suffit pour que Feature::active('billing-v2') cible automatiquement l’équipe courante. Comme tous les membres d’une même équipe obtiennent le même résultat, la cohérence de l’UI est préservée.Exemple de déploiement progressif selon la date d’inscription de l’équipe.
use App\Models\Team;use Illuminate\Support\Carbon;use Illuminate\Support\Lottery;use Laravel\Pennant\Feature;Feature::define('new-billing', function (Team $team) { // Les équipes inscrites depuis 2024 sont immédiatement actives if ($team->created_at->isAfter(new Carbon('2024-01-01'))) { return true; } // Inscriptions 2022-2023 : rollout progressif à 10 % if ($team->created_at->isAfter(new Carbon('2022-01-01'))) { return Lottery::odds(1 / 10); } // Anciennes équipes : rollout prudent à 1 % return Lottery::odds(1 / 100);});
Pour n’activer que certaines équipes (mise à disposition anticipée aux comptes entreprise, par exemple) :
// Activer manuellement pour une équipe donnée après déploiementFeature::for($earlyAccessTeam)->activate('new-billing');
Lorsqu’un bug se manifeste en production, vous pouvez désactiver immédiatement une fonctionnalité sans rollback du code. En ajoutant la méthode before à une feature basée sur classe, la vérification est effectuée avant les valeurs stockées.
<?phpnamespace App\Features;use App\Models\User;use Illuminate\Support\Facades\Config;class NewCheckout{ /** * Kill switch d'urgence, activable en config lors d'un bug en production */ public function before(User $user): mixed { // Contrôlé par la valeur dans config/features.php ou une variable d'env if (Config::boolean('features.new_checkout_disabled', false)) { return false; // Désactivation immédiate pour tous } // Toujours actif pour les administrateurs (débogage) if ($user->isAdmin()) { return true; } return null; // Renvoyer null poursuit vers resolve() } public function resolve(User $user): mixed { return $user->isPremium() || Lottery::odds(1 / 5); }}
Il suffit de définir FEATURES_NEW_CHECKOUT_DISABLED=true en variable d’environnement pour arrêter la fonctionnalité sans toucher à la base. C’est un kill switch qui ne demande aucun déploiement.
Si before renvoie null, resolve() est exécuté. Si before renvoie false, la fonctionnalité est considérée immédiatement inactive. En dehors des urgences, renvoyez null.
Vous voulez ouvrir automatiquement une fonctionnalité à tous les utilisateurs à une date donnée. Utilisez la méthode before.
public function before(User $user): mixed{ $rolloutDate = Config::get('features.new_api.rollout_date'); if ($rolloutDate && Carbon::parse($rolloutDate)->isPast()) { return true; // Une fois la date passée, ouvert à tous } return null; // Sinon, on continue vers resolve()}public function resolve(User $user): mixed{ // Avant le rollout : uniquement les membres internes return $user->isInternalTeamMember();}
# .envFEATURES_NEW_API_ROLLOUT_DATE=2025-04-01
Passé le 2025-04-01, la fonctionnalité s’ouvre automatiquement à tous. Pas de déploiement, pas de base, pas de commande Artisan.
Un patron où la nouvelle logique tourne sur les données de production sans être visible pour l’utilisateur, avec comparaison des résultats à l’ancienne. Si tout va bien, il suffit d’activer le flag pour finaliser la mise en production.
class RecommendationController{ public function index(Request $request) { $legacyResult = $this->getLegacyRecommendations($request->user()); // En mode shadow, on exécute le nouvel algo pour comparer, mais on renvoie l'ancien if (Feature::for($request->user())->active('recommendation-v2-shadow')) { try { $newResult = $this->getNewRecommendations($request->user()); // On journalise la différence (invisible pour l'utilisateur) 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 réponse renvoyée reste toujours celle de l'ancienne logique return response()->json($legacyResult); }}
Quand les différences disparaissent des logs, il suffit de basculer le flag vers recommendation-v2.
La documentation officielle mentionne l’événement FeatureRetrieved mais ne montre pas de patron concret d’agrégation A/B.L’événement FeatureResolved est déclenché uniquement à la première résolution d’une feature. Utilisez-le pour enregistrer l’attribution du variant à un utilisateur.
use Laravel\Pennant\Events\FeatureResolved;use Illuminate\Support\Facades\Event;// À enregistrer dans AppServiceProvider ou EventServiceProviderEvent::listen(function (FeatureResolved $event) { if ($event->feature !== 'purchase-button') { return; } // Enregistrement au moment de l'attribution du variant analytics()->identify($event->scope?->id, [ 'ab_purchase_button' => $event->value, ]);});
Enregistrez la conversion séparément pour calculer le taux de conversion par variant.
// À la fin d'un achatEvent::dispatch(new PurchaseCompleted($user, $product));
Différence entre FeatureResolved et FeatureRetrieved : FeatureResolved se déclenche uniquement à la première évaluation ; FeatureRetrieved à chaque vérification. Utilisez FeatureResolved pour la trace d’attribution, FeatureRetrieved pour le suivi des pages vues.
Dans un job de queue, il n’y a pas d’utilisateur authentifié : les vérifications de feature peuvent produire des comportements inattendus. Passez explicitement un scope au job.
class SendWeeklyDigest implements ShouldQueue{ public function __construct( private readonly User $user ) {} public function handle(): void { // À éviter : sans utilisateur authentifié, ce sera toujours false // if (Feature::active('new-digest-layout')) { ... } // Correct : on passe explicitement l'utilisateur if (Feature::for($this->user)->active('new-digest-layout')) { $this->sendNewLayout($this->user); } else { $this->sendLegacyLayout($this->user); } }}
Autre approche : évaluer le flag au dispatch et le passer au job.
// Évaluation en contrôleur puis passage au job$useNewLayout = Feature::for($user)->active('new-digest-layout');SendWeeklyDigest::dispatch($user, $useNewLayout);
Lorsque vous renommez une feature basée sur classe, le nom stocké en base change et remet à zéro les flags de tous les utilisateurs. Fixez le nom stocké avec l’attribut Name.
use Laravel\Pennant\Attributes\Name;// Ancienne classe CheckoutV2 → nouvelle classe NewCheckoutExperience// La base stocke toujours 'checkout-v2'#[Name('checkout-v2')]class NewCheckoutExperience{ public function resolve(User $user): mixed { return $user->isPremium(); }}
Vous pouvez ainsi refactorer librement le nom de la classe tout en préservant les données en base.