~/blog ❯ enums-php-laravel-statuts
Les enums PHP dans Laravel : caster proprement vos statuts
Fini les constantes de classe et les 'pending' en dur partout. Comment caster vos statuts avec les enums PHP 8.1, les valider, et éviter les pièges côté base.
Un champ status dans une table, et très vite la chaîne 'pending' se retrouve copiée-collée dans un contrôleur, une condition Blade, un scope Eloquent et trois tests. Le jour où ce statut devient 'awaiting_payment', vous partez à la pêche aux occurrences — et vous en oubliez toujours une. Les enums de PHP 8.1 règlent ce problème à la racine : un type unique, vérifié par le compilateur, que Laravel sait caster directement depuis Eloquent.
Le réflexe d’avant : des constantes et des chaînes en dur
Pendant des années, on a modélisé un statut comme ça :
class Order
{
const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid';
const STATUS_CANCELLED = 'cancelled';
}
if ($order->status === Order::STATUS_PAID) {
// …
}
C’est déjà mieux que 'paid' en dur, mais ça reste fragile. Rien n’empêche d’affecter $order->status = 'payed' (avec une faute), aucune autocomplétion ne liste les valeurs possibles, et la logique métier attachée à chaque statut — un libellé, une couleur, une transition autorisée — finit éparpillée dans des match ou des tableaux associatifs sans cohésion.
Un enum backed, le bon outil
PHP 8.1 introduit les enums. Pour un statut stocké en base, on veut un backed enum : chaque cas porte une valeur scalaire, ici une chaîne, qui sera la valeur écrite en colonne.
namespace App\Enums;
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
L’intérêt immédiat : OrderStatus::Paid est un objet typé, pas une chaîne. Une méthode qui attend OrderStatus refusera n’importe quoi d’autre, et l’IDE complète les trois cas. On récupère la valeur stockée avec ->value, et on remonte d’une valeur vers le cas avec from() (qui lève une ValueError si la valeur est inconnue) ou tryFrom() (qui renvoie null).
OrderStatus::Paid->value; // 'paid'
OrderStatus::from('paid'); // OrderStatus::Paid
OrderStatus::tryFrom('payed'); // null, pas d'exception
OrderStatus::cases(); // [Pending, Paid, Cancelled]
On peut aussi rattacher le comportement métier directement à l’enum, là où il a du sens :
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'En attente',
self::Paid => 'Payée',
self::Cancelled => 'Annulée',
};
}
public function isFinal(): bool
{
return $this === self::Paid || $this === self::Cancelled;
}
}
Le match sur $this n’a pas de branche default : si vous ajoutez un cas sans traiter son libellé, PHP lève une \UnhandledMatchError. C’est exactement ce qu’on veut — une erreur bruyante plutôt qu’un statut silencieusement sans libellé.
Caster le statut dans Eloquent
Depuis Laravel 9, le cast d’enum est natif. On déclare le mapping dans le modèle :
namespace App\Models;
use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class,
];
}
À partir de là, Eloquent fait la traduction dans les deux sens. En lecture, $order->status renvoie une instance de OrderStatus. En écriture, on affecte l’enum et Laravel stocke ->value :
$order = Order::find(1);
$order->status; // OrderStatus::Paid (un objet)
$order->status->label(); // 'Payée'
$order->status = OrderStatus::Cancelled;
$order->save(); // écrit 'cancelled' en base
Les requêtes acceptent aussi bien l’enum que sa valeur — le query builder sérialise l’enum automatiquement dans le where :
Order::where('status', OrderStatus::Pending)->get();
Sur les versions récentes de Laravel, la déclaration des casts passe volontiers par une méthode plutôt que par la propriété, surtout dès qu’on référence des classes d’enums :
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
La méthode casts() (Laravel 11+) remplace avantageusement la propriété $casts et se prête mieux à la composition.
Valider une entrée utilisateur
Laisser un enum entrer dans le modèle suppose que la valeur soit valide. Pour les données venues d’une requête HTTP, la règle Enum fait le travail :
use Illuminate\Validation\Rules\Enum;
use App\Enums\OrderStatus;
$request->validate([
'status' => ['required', new Enum(OrderStatus::class)],
]);
La validation échoue si status ne correspond à aucune valeur de l’enum. Ensuite, dans le contrôleur, il faut convertir la chaîne validée en instance — la validation ne le fait pas pour vous :
$order->status = OrderStatus::from($request->validated('status'));
$order->save();
Comme la valeur a déjà passé la règle Enum, le from() ne lèvera pas d’exception ici. Si vous préférez restreindre l’entrée à un sous-ensemble de cas — par exemple interdire qu’une API publique passe une commande directement à Paid — utilisez Rule::enum(...)->only([...]) sur Laravel 11+ :
use Illuminate\Validation\Rule;
'status' => [Rule::enum(OrderStatus::class)->only([OrderStatus::Pending, OrderStatus::Cancelled])],
Les pièges côté base de données
C’est là que les ennuis arrivent, parce que la base ne connaît rien des enums PHP. Quelques points à surveiller.
La colonne reste une simple chaîne. Le plus robuste est une colonne string (ou varchar) classique. La migration ne change pas par rapport à du texte libre :
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status')->default('pending');
$table->timestamps();
});
Notez le default('pending') : c’est une chaîne, pas l’enum. Une migration ne sait pas instancier votre enum, et de toute façon le défaut est appliqué par le moteur SQL, pas par PHP. Si vous tenez à éviter le littéral, écrivez OrderStatus::Pending->value — le résultat est la même chaîne 'pending', mais l’intention est explicite et la faute de frappe impossible.
Méfiez-vous du type enum SQL. MySQL propose un type colonne ENUM('pending', 'paid', …), repris par $table->enum('status', [...]) dans les migrations Laravel. C’est tentant, mais ça duplique la liste des valeurs entre PHP et le schéma : ajouter un cas oblige à une migration ALTER TABLE, souvent verrouillante sur une grosse table, et les deux définitions finissent par diverger. Une colonne string laisse la source de vérité unique côté PHP. Si vous voulez une garantie au niveau base, une contrainte CHECK est plus souple à faire évoluer qu’un type ENUM.
Une valeur orpheline en base fait planter la lecture. C’est le piège le plus vicieux. Si une ligne contient 'archived' — issue d’un ancien déploiement, d’un script SQL manuel, d’un import — et que ce cas n’existe pas dans l’enum, Eloquent appelle from() en interne et lève une ValueError au moment de l’hydratation du modèle. Pas à l’usage du statut : dès le find(). Et comme c’est une ValueError et non une exception Laravel, elle ne passe pas par vos try/catch habituels sur les exceptions de validation.
Conséquences pratiques :
- Quand vous retirez un cas d’un enum, vérifiez d’abord qu’aucune ligne ne le porte encore, et prévoyez une migration de données pour réécrire les valeurs orphelines.
- Quand vous renommez une valeur, la migration de données est obligatoire — l’ancienne valeur en base ne se mappe plus sur rien.
// Migration de données avant de retirer le cas 'archived'
DB::table('orders')
->where('status', 'archived')
->update(['status' => OrderStatus::Cancelled->value]);
Les seeders et factories doivent passer par l’enum. Une factory qui écrit 'status' => 'pending' fonctionne, mais vous reperdez la sécurité de typage. Préférez fake()->randomElement(OrderStatus::cases()) : vous testez ainsi tous les cas réels, et un cas ajouté entre automatiquement dans le jeu de test.
public function definition(): array
{
return [
'status' => fake()->randomElement(OrderStatus::cases()),
];
}
Ce qu’il faut retenir
Un statut, c’est un type, pas une chaîne qui se balade. Les enums backed de PHP 8.1, castés dans Eloquent, vous donnent l’autocomplétion, la vérification au build, et un endroit unique où ranger le libellé et les règles de transition.
Trois réflexes pour que ça tienne en production :
- Colonne
string, source de vérité côté PHP — évitez le typeENUMSQL qui fige la liste dans le schéma. - Validez avant de caster avec la règle
Enum, puis convertissez explicitement viafrom(). - Aucune valeur orpheline en base — toute suppression ou renommage de cas s’accompagne d’une migration de données, sous peine de
ValueErrorà la lecture.
Le coût d’entrée est faible : un fichier d’enum, une ligne de cast. Le gain, lui, court sur toute la durée de vie du projet.
~/blog --related
À lire aussi
29 juin 2026
Le pattern Action en Laravel, vu depuis la prod
Ce que les tutos survolent sur les Actions Laravel : le couplage à la Request, les transactions multi-effets, la composition d'Actions et l'organisation des dossiers.
26 juin 2026
Le pattern Pipeline de Laravel pour orchestrer un processus métier
Refactorer une méthode de service truffée de if imbriqués en une chaîne de pipes à responsabilité unique avec Illuminate\Pipeline — et savoir quand s'en passer.
17 juin 2026
Mutation testing avec Pest 3 : quand 100 % de couverture ne prouve rien
Une suite verte à 100 % de couverture peut ne rien vérifier. Le mutation testing de Pest 3 débusque les tests trop mous, et voici comment lire les mutants survivants.