Nous avons trouvé celui-là dans notre propre code, lundi, en ajoutant une image à une fiche de notre catalogue de sites. Rien n’était cassé. Rien ne se serait vu avant le prochain déploiement.
Le seeder du catalogue déclare quatre sites, chacun sous une clé qui sert de slug. Un de ces sites avait été renommé quelques jours plus tôt depuis l’administration : ecommerce-01 était devenu boutique-01, parce que c’est le mot que les clients emploient. Le code, lui, disait toujours ecommerce-01.
Ce qui allait se passer
Le seeder est écrit comme on les écrit tous, pour être rejouable sans dégât :
foreach ($definitions as $slug => $definition) {
$modele = ModeleDeSite::firstOrCreate(
['slug' => $slug],
[ /* nom, prix, description, statut… */ ],
);
}
firstOrCreate cherche une ligne dont le slug vaut ecommerce-01. Elle n’existe plus : elle s’appelle boutique-01 depuis le renommage. La méthode fait donc ce pour quoi elle est écrite — elle crée. Au premier db:seed suivant, un cinquième site serait apparu au catalogue, publié, au même prix, avec la même description, à côté de celui que le client voit déjà.
Le mot « idempotent » est celui qui trompe. firstOrCreate est idempotent par rapport à sa clé de recherche, pas par rapport à l’enregistrement. Tant que la clé est immuable, les deux reviennent au même. Dès que l’application permet de la modifier, ce sont deux choses différentes — et c’est justement une administration qui permet de la modifier.
Pourquoi aucun test ne l’attrape
C’est la partie intéressante, et elle rappelle un autre piège que nous avions décrit ici : celui des N+1 que les tests ne voient pas. Le mécanisme est le même.
Les tests tournent sur une base neuve. On la migre, on la sème, on vérifie, on la jette. Dans cette base, personne n’a jamais rien renommé : les slugs en base sont exactement ceux du seeder, donc firstOrCreate retrouve tout, donc rien ne double. Le test est vert, et il a raison de l’être : sur les données qu’il possède, le code est correct.
Le bug n’existe que dans une base qui a une histoire. C’est la définition même de la production, et c’est la seule base que les tests ne rejouent jamais.
Trois façons de s’en sortir
Chercher sur une clé que personne ne peut modifier. La plus solide. On ajoute une colonne technique — reference, code, appelez-la comme vous voulez — qui n’apparaît nulle part dans l’interface et que rien ne permet de changer. Le seeder cherche sur elle, le slug devient un simple attribut, libre de bouger autant que le marketing le souhaite.
ModeleDeSite::firstOrCreate(
['reference' => $definition['reference']], // jamais éditable
[...$definition, 'slug' => $definition['slug']],
);
Réconcilier, et échouer bruyamment. Un test compare l’ensemble des références connues du seeder à celles présentes en base. Une fiche en base que le seeder ignore, ou l’inverse, et la suite échoue avec un message qui nomme le coupable. Cela ne corrige rien, mais cela transforme un doublon découvert par un client en une ligne rouge découverte avant le déploiement.
Cesser de semer. La plus radicale, et souvent la bonne. Un seeder a un rôle légitime : poser les données de référence, celles que personne ne modifie — rôles, permissions, taux de taxe, statuts de commande. Le jour où le client édite lui-même les fiches, elles ne sont plus des données de référence : elles sont son contenu. Continuer à les semer, c’est maintenir deux propriétaires pour la même ligne.
updateOrCreate ne sauve personne
C’est le premier réflexe, et il aggrave la situation.
Il cherche sur la même clé : un enregistrement renommé lui échappe exactement de la même façon, et le doublon apparaît quand même. En revanche, sur tous les enregistrements qu’il retrouve, il écrase — à chaque déploiement, il repose les valeurs du code par-dessus celles de l’administration. Le client corrige une faute dans un texte le mardi, elle disparaît au déploiement du jeudi, et personne ne comprend pourquoi.
Il existe un cas où updateOrCreate est le bon outil : quand le code est délibérément la source de vérité et que l’administration ne fait que consulter. Ce cas est plus rare qu’on ne le croit, et il mérite d’être écrit en commentaire au-dessus de l’appel, sans quoi quelqu’un ouvrira un jour l’édition de ces champs sans se douter de ce qu’il déclenche.
La question à se poser, une fois
Qui possède cette ligne : le dépôt, ou l’administration ?
La réponse tient en une phrase par table, et elle vaut d’être écrite quelque part. C’est le même genre de décision que celles qui font qu’une architecture tient encore après cinq ans : elle ne coûte rien à prendre au départ, et elle coûte un après-midi de rattrapage quand on ne l’a jamais prise.
Une ligne possédée par les deux finit toujours par diverger. La seule question est de savoir si vous l’apprendrez par un test, ou par un client qui vous demande pourquoi il y a deux fois la même chose sur son site.
Notre correction a tenu en deux lignes : le slug du seeder aligné sur celui de la production, et cinq lignes de commentaire au-dessus de la boucle pour expliquer le piège au prochain qui renommera quelque chose. La colonne immuable viendra ensuite, quand le catalogue comptera assez de fiches pour que le renommage devienne une habitude.
Ce n’est pas la correction la plus élégante. C’est celle qui empêche le doublon d’arriver ce soir.