Depuis sa version 10.20, Laravel intègre une méthode dénommée createOrFirst
pouvant sembler redondante avec l'historique méthode firstOrCreate
.
La raison d'être de cette nouvelle méthode provient d'une problématique que peut parfois rencontrer la méthode firstOrCreate
dans des situations de concurrence.
Voyons ensemble quelles sont les particularités de cette méthode et en quoi elle se distingue de la méthode firstOrCreate
!
Le problème du firstOrCreate
Comme son nom l'indique, un firstOrCreate
fonctionne en deux étapes.
Tout d'abord, la méthode vérifie si une ligne spécifique existe déjà dans votre base de données, ensuite, si cette ligne n'est pas trouvée, la méthode procède à son insertion avant de vous la renvoyer.
Cependant, une problématique peut surgir pendant l'intervalle de temps entre l'exécution de ces deux étapes.
Si la méthode contenant l'appel au firstOrCreate
est fortement sollicitée et que des lignes similaires sont envoyées dans un court laps de temps, une situation de concurrence peut se produire lors de l'insertion.
Dans notre exemple, la requête 1 n'ayant pas eu le temps de finaliser son insertion avant la réception de la requête 2, Laravel considérera que la ligne est absente et procédera à une nouvelle insertion.
Cette situation de concurrence pourrait entraîner l'insertion d'un doublon ou provoquer une erreur d'unicité au niveau de votre base de données.
Testons le createOrFirst
Afin de tester cette nouvelle méthode createOrFirst
, commençons par créer la migration suivante :
1Schema::create('posts', function (Blueprint $table) {2 $table->id();3 $table->string('title')->unique();4});
php artisan make:migration posts
Ainsi que le model associé :
1class Post extends Model2{3 public $timestamps = false;4 protected $fillable = ['title'];5}
php artisan make:model Post
Nous pouvons des lors commencer à tester la méthode createOrFirst
à l'aide du code suivant, nous utiliserons les méthodes de debug SQL afin de visualiser les requêtes exécutées par le framework :
1DB::connection()->enableQueryLog();2 3Post::createOrFirst([4 'title' => 'abc',5]);6 7$queries = DB::getQueryLog();8 9dd($queries);
En exécutant ce code, aucun poste intitulé "abc" ne se trouvant en base de données, la variable $queries
contiendra les informations suivantes :
1array:1 [2 0 => array:3 [3 "query" => "insert into `posts` (`title`) values (?)"4 "bindings" => array:1 [5 0 => "abc"6 ]7 "time" => 0.348 ]9]
On voit ici que le framework effectue directement une insertion sans vérifier au préalable l'existence d'un post
équivalent, ce qui est l'inverse du comportement d'une méthode firstOrCreate
.
Exécutons à nouveau le code précédent, la contenu de la variable $queries
sera quelque peu différent :
1array:1 [2 0 => array:3 [3 "query" => "select * from `posts` where (`title` = ?) limit 1"4 "bindings" => array:1 [5 0 => "abc"6 ]7 "time" => 0.368 ]9]
Lors de cette seconde exécution, nous remarquons que seule une requête "select" fut exécutée sans la moindre trace de l'insertion, ce comportement était attendu car la post
"abc" existait déjà dans notre base de données.
Voyons désormais comment le framework opère pour passer automatiquement d'un "create" vers un "first".
Anatomie du createOrFirst
Le code source de la méthode createOrFirst
se trouve dans la class Builder de votre vendor et se décompose de la manière suivante :
1public function createOrFirst(array $attributes = [], array $values = [])2{3 try {4 return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values)));5 } catch (UniqueConstraintViolationException $e) {6 return $this->useWritePdo()->where($attributes)->first() ?? throw $e;7 }8}
les relations BelongsToMany & HasOneOrMany possèdent leur propre version de la méthode.
En examinant ces lignes, on remarque que Laravel est désormais capable de gérer une UniqueConstraintViolationException
à la création d'une nouvelle ligne depuis sa méthode createOrFirst
.
Au fin fond de votre vendor, vous trouverez une classe appelée Connection contenant une méthode nommée runQueryCallback, cette dernière est chargée d'exécuter les requêtes SQL de votre application.
Depuis Laravel 10, lorsque runQueryCallback
détectera une erreur de contrainte d'unicité, la méthode déclenchera une UniqueConstraintViolationException
entrainant la transition de la méthode createOrFirst
que nous avons vu un peu plus haut d'un "create" vers un "first".
1protected function runQueryCallback($query, $bindings, Closure $callback) 2{ 3 try { 4 return $callback($query, $bindings); 5 } 6 7 catch (Exception $e) { 8 if ($this->isUniqueConstraintError($e)) { 9 throw new UniqueConstraintViolationException( 10 $this->getName(), $query, $this->prepareBindings($bindings), $e 11 ); 12 } 13 14 throw new QueryException(15 $this->getName(), $query, $this->prepareBindings($bindings), $e16 );17 }18}
Point extrêmement important si vous souhaitez utiliser la méthode createOrFirst
, il est essentiel d'avoir configuré au préalable une contrainte "unique" dans la migration de la table en question :
1Schema::create('posts', function (Blueprint $table) {2 $table->id();3 $table->string('title')->unique(); 4});
Dans le cas contraire, Laravel sera incapable de lever l'exception au moment opportun !
Cas concret
Lors d'une expérience professionnelle passée j'ai eu l'occasion de travailler sur un APM (Application performance monitoring), cette application avait pour responsabilité de récupérer les erreurs d'autres applications à travers une API d'une manière similaire à Sentry.
Cette application utilisait le fameux firstOrCreate
pour sauvegarder les erreurs que nous recevions depuis l'API, ce point d'entrée était fortement sollicité et il n'était pas rare de recevoir plusieurs dizaines d'erreurs par seconde ... provoquant régulièrement des plantages dus aux contraintes d'unicités que nous avons abordées plus haut.
Le code en question était similaire à l'exemple suivant :
1try { 2 3 /** 4 * This method is widely used. 5 * At times, firstOrCreate is not fast enough 6 * and causes duplicity errors. 7 */ 8 return Issue::firstOrCreate([ 9 'class' => $attributes['class'],10 'message' => $attributes['message'],11 ]);12 13} catch (QueryException $e) {14 15 /**16 * QueryException probably caused by a duplicate issue.17 */18 return Issue::where('class', $attributes['class'])19 ->where('message', $attributes['message'])20 ->firstOrFail();21}
Pour contrer cette problématique, nous avions mis en place la détection d'une QueryException
qui était, dans la plupart des cas, vraisemblablement provoquée par une contrainte d'unicité.
L'utilisation d'un createOrFirst
nous aurait permis de réduire le code précédant ... en ces quelques lignes :
1return Issue::createOrFirst([2 'class' => $attributes['class'],3 'message' => $attributes['message'],4]);
A lire
Autres articles de la même catégorie
Améliorer ses requêtes en base de données
L'ORM de Laravel est un outil remarquable. Mais tout n'est pas forcément dans la doc : connaissiez-vous ces astuces ?
Laravel Jutsu
PHPStan valide nos règles de conception
Comment s’assurer du respect de nos règles de conception ? Avec PHPStan bien sûr !
William Suppo
Les bases 1/6 : Création du modèle
Découverte de l'ORM Eloquent à travers la création de modèle
William Suppo