Un double-clic peut suffire à corrompre votre base de données
Un utilisateur clique deux fois sur "Ajouter au panier" avant que le bouton ne se désactive. Deux requêtes partent vers votre serveur à quelques millisecondes d'intervalle. Chacune appelle firstOrCreate(). Et vous vous retrouvez avec deux paniers pour le même client, sans qu'aucune erreur ne soit remontée.
Ce scénario n'est pas hypothétique : il survient dans toute application Laravel exposée à du trafic concurrent, même modéré. La méthode firstOrCreate() est souvent présentée comme une solution élégante pour éviter les doublons, mais elle contient une faille structurelle qu'aucune transaction classique ne corrige.
Ce que fait réellement firstOrCreate() sous le capot
Lorsque vous écrivez Cart::firstOrCreate(['user_id' => $userId]), Laravel exécute deux requêtes SQL distinctes :
SELECT * FROM carts WHERE user_id = ? LIMIT 1;
-- Si rien n'est trouvé :
INSERT INTO carts (user_id) VALUES (?);
Entre ces deux opérations, il existe un intervalle de temps, aussi court soit-il. C'est dans cet intervalle que la concurrence frappe.
Voici la séquence problématique :
- La requête A exécute le
SELECTet ne trouve rien. - Avant qu'elle n'exécute son
INSERT, la requête B exécute aussi sonSELECTet ne trouve rien non plus. - Les deux requêtes lancent leur
INSERT. Deux enregistrements identiques sont créés.
C'est ce qu'on appelle une race condition (ou condition de course) : deux processus parallèles obtiennent des résultats cohérents chacun de leur côté, mais produisent un état incohérent ensemble.
Pourquoi DB::transaction() ne résout pas le problème
La réaction instinctive consiste à envelopper l'appel dans une transaction :
DB::transaction(function () use ($userId) {
return Cart::firstOrCreate(['user_id' => $userId]);
});
Cette approche ne change rien. Par défaut, PostgreSQL et MySQL utilisent le niveau d'isolation READ COMMITTED. Cela signifie que chaque requête voit les données validées au moment où elle s'exécute, mais deux transactions concurrentes peuvent lire le même état "vide" avant que l'une d'elles n'ait inséré son enregistrement.
Même en montant au niveau SERIALIZABLE, vous n'éliminez pas le doublon : vous provoquez simplement une erreur de sérialisation que vous devrez gérer explicitement, avec une logique de retry. Ce n'est pas une solution propre.
La bonne approche : pousser la contrainte au niveau de la base
Le vrai garde-fou se place en dehors du code applicatif. Une contrainte UNIQUE au niveau SQL garantit que la base de données elle-même rejette tout doublon, indépendamment de ce que fait votre ORM.
Étape 1 : ajouter la contrainte dans votre migration
Schema::table('carts', function (Blueprint $table) {
$table->unique('user_id');
});
Étape 2 : gérer l'exception dans votre code
Quand deux requêtes tentent d'insérer simultanément, la seconde lève une QueryException. Vous pouvez l'attraper et retourner l'enregistrement existant :
use Illuminate\Database\QueryException;
try {
$cart = Cart::create(['user_id' => $userId]);
} catch (QueryException $e) {
$cart = Cart::where('user_id', $userId)->first();
}
Alternative avec upsert()
Depuis Laravel 8, la méthode upsert() permet d'insérer ou de mettre à jour en une seule opération SQL, exploitant les mécanismes natifs de la base pour gérer les conflits :
Cart::upsert(
[['user_id' => $userId]],
['user_id'], // colonnes de détection du conflit
[] // colonnes à mettre à jour si conflit (vide = ne rien changer)
);
Cette approche est atomique par construction : c'est la base de données qui arbitre, sans fenêtre d'exposition entre lecture et écriture.
Quels contextes sont concernés ?
Toute situation où plusieurs requêtes peuvent cibler le même enregistrement de manière quasi simultanée est exposée :
- Création de panier ou de commande après un double-clic
- Inscription d'un utilisateur avec plusieurs onglets ouverts
- Traitement de webhooks envoyés plusieurs fois par un service tiers
- Files de travail (queues) avec plusieurs workers en parallèle
Le point commun : firstOrCreate() est utilisé comme filet de sécurité contre les doublons, mais ce filet a des mailles trop larges dès que la concurrence entre en jeu.
Conclusion
La méthode firstOrCreate() de Laravel est utile dans de nombreux contextes, mais elle ne constitue pas une protection suffisante contre les doublons sous charge concurrente. La bonne pratique consiste à associer systématiquement une contrainte UNIQUE au niveau de la base de données, puis à gérer proprement les conflits dans le code, que ce soit via QueryException ou via upsert().
Cette règle vaut pour tout ORM et tout framework : la couche applicative vérifie, la base de données garantit. L'article original d'Elias Alrgeai sur dev.to détaille ce comportement avec des exemples complémentaires.
Ce que ça change pour vous
Si votre site permet à vos clients de passer des commandes, de s'inscrire ou d'ajouter des produits à un panier, une faille de ce type peut générer des doublons dans vos données sans qu'aucun message d'erreur n'apparaisse. Résultat concret : des clients facturés deux fois, des stocks mal décomptés, ou des doublons dans votre base de clients qui faussent vos rapports. Ce n'est pas une erreur d'utilisation : c'est un défaut de conception que seul un développeur peut corriger en ajoutant une règle de sécurité directement dans la base de données. Si vous observez des anomalies inexplicables dans vos commandes ou inscriptions, c'est une piste sérieuse à soumettre à votre prestataire technique.
Et votre site, il vaut quoi ?
Vitesse, accessibilité, référencement technique. Le rapport est écrit pour un dirigeant, pas pour un développeur. Gratuit, sans inscription.