
Cours Laravel 8 – les données – la relation 1:n
Pour le moment nous n’avons manipulé qu’une table avec Eloquent. Dans le présent chapitre nous allons utiliser deux tables et les mettre en relation.
La relation la plus répandue et la plus simple entre deux tables est celle qui fait correspondre un enregistrement d’une table à plusieurs enregistrements de l’autre table, on parle de relation de un à plusieurs ou encore de relation de type 1:n.
Nous allons poursuivre notre gestion de films. Comme nous en avons beaucoup nous éprouvons la nécessité de les classer en catégories : comédie, fantastique, drame, thriller…
On va partir du projet dans l’état où on l’a laissé dans le précédent article. Il y a un lien de téléchargement à la fin.
Les migrations
La table categories
Nous allons créer une table pour les catégories. On crée sa migration en même temps que le modèle :
php artisan make:model Category --migration
Voici le code complété pour la migration :
public function up() { Schema::create('categories', function (Blueprint $table) { $table->id(); $table->string('name')->unique(); $table->string('slug')->unique(); $table->timestamps(); }); }
On va donc définir les colonnes ;
- name : nom de la catégorie
- slug : adaptation du nom pour le rendre compatible avec les urls
La table films
On a déjà une migration pour la table films mais il va falloir la compléter pour pouvoir la mettre en relation avec la table categories :
public function up() { Schema::disableForeignKeyConstraints(); Schema::create('films', function (Blueprint $table) { ... $table->unsignedBigInteger('category_id'); $table->foreign('category_id') ->references('id') ->on('categories') ->onDelete('restrict') ->onUpdate('restrict'); }); }
Dans la table films on déclare une clé étrangère (foreign) nommée category_id qui référence (references) la colonne id dans la table (on) categories. En cas de suppression (onDelete) ou de modification (onUpdate) on a une restriction (restrict).
Que signifient ces deux dernières conditions ?
Imaginez que vous avez une catégorie avec l’id 5 qui a deux films, donc dans la table films on a deux enregistrements avec category_id qui a la valeur 5. Si on supprime la catégorie que va-t-il se passer ? On risque de se retrouver avec nos deux enregistrements dans la table films avec une clé étrangère qui ne correspond à aucun enregistrement dans la table categories. En mettant restrict on empêche la suppression d’une catégorie qui a des films. On doit donc commencer par supprimer ses films avant de le supprimer lui-même. On dit que la base assure l’intégrité référentielle. Elle n’acceptera pas non plus qu’on utilise pour category_id une valeur qui n’existe pas dans la table categories.
Une autre possibilité est cascade à la place de restrict. Dans ce cas si vous supprimez une catégorie ça supprimera en cascade les films de cette catégorie.
C’est une option qui est moins utilisée parce qu’elle peut s’avérer dangereuse, surtout dans une base comportant de multiples tables en relation. Mais c’est aussi une stratégie très efficace parce que c’est le moteur de la base de données qui se charge de gérer les enregistrements en relation, vous n’avez ainsi pas à vous en soucier au niveau du code.
On pourrait aussi ne pas signaler à la base qu’il existe une relation et la gérer seulement dans notre code. Mais c’est encore plus dangereux parce que la moindre erreur de gestion des enregistrements dans votre code risque d’avoir des conséquences importantes dans votre base avec de multiples incohérences.
On va lancer les migrations en rafraichissant la base :
php artisan migrate:fresh
Les migrations sont effectuées dans l’ordre alphabétique, ce qui peut générer un problème avec les clés étrangères. Si la table référencée est créée après on va tomber sur une erreur du genre :
Illuminate\Database\QueryException : SQLSTATE[HY000]: General error: 1215 Cannot add foreign key constraint (SQL: alter table `films` add constraint `films_category_id_foreign` foreign key (`category_id`) references `categories` (`id`) on delete restrict on update restrict)
C’est pour cette raison que j’ai ajouté cette ligne dans la migration :
Schema::disableForeignKeyConstraints();
On désactive temporairement le contrôle référentiel le temps de créer les tables.
La population
On va remplir les tables avec des enregistrements pour nos essais.
Les catégories
On va définir un certain nombre de catégories. Alors on crée un factory :
php artisan make:factory CategoryFactory --model=Category
use Illuminate\Support\Str; ... public function definition() { $name = $this->faker->word(); return [ 'name' => $name, 'slug' => Str::slug($name), ]; }
La relation
On a la situation suivante :
- une catégorie peut avoir plusieurs films,
- un film n’appartient qu’à une catégorie.
Il faut trouver un moyen pour référencer cette relation dans les tables. Le principe est simple et on l’a vu dans les migrations : on prévoit dans la table films une colonne destinée à recevoir l’identifiant de la catégorie. On appelle cette ligne une clé étrangère parce qu’on enregistre ici la clé d’une autre table. Voici une représentation visuelle de cette relation :
Vous voyez la relation films_category_id_foreign dessinée entre la clé id dans la table categories et la clé étrangère category_id dans la table films.
Les modèles et la relation
Le modèle Category
Le modèle Category se trouve ici (on l’a créé en même temps que la migration) :
public function films() { return $this->hasMany(Film::class); }
On déclare ici avec la méthode films (au pluriel) qu’une catégorie a plusieurs (hasMany) films (Film). On aura ainsi une méthode pratique pour récupérer les films d’une catégorie.
Le modèle Film
Dans le modèle Film on va coder la réciproque :
public function category() { return $this->belongsTo(Category::class); }
Ici on a la méthode category (au singulier) qui permet de trouver la catégorie à laquelle appartient (belongsTo) le film.
La relation 1:n
Voici une schématisation de la relation avec les deux méthodes :
Si vous ne spécifiez pas de manière explicite le nom de la table dans un modèle, Laravel le déduit à partir du nom du modèle en le mettant au pluriel (à la mode anglaise) et en mettant la première lettre en minuscule. Donc avec le modèle Film il en conclut que la table s’appelle films. Si ce n’était pas satisfaisant il faudrait créer une propriété $table.
Les deux méthodes mises en place permettent de récupérer facilement un enregistrement lié. Par exemple pour avoir tous les films de la catégorie qui a l’id 1 :
$films = App\Models\Category::find(1)->films;
De la même manière on peut trouver la catégorie du film d’id 1 :
$category = App\Models\Film::find(1)->category;
Vous voyez que le codage devient limpide avec ces méthodes !
La population (seeding)
Pour la population on va justement utiliser la relation qu’on vient de mettre en place. On modifie ainsi DatabaseSeeder :
use App\Models\{ Film, Category }; ... public function run() { Category::factory() ->has(Film::factory()->count(4)) ->count(10) ->create(); }
On lance la population :
php artisan db:seed
On crée ainsi 10 catégories :
Avec cette population pour les catégories le nom et le slug sont indentiques mais dans une situation plus réaliste il y aurait évidemment souvent des différences, avec les majuscules, les espaces, les accents…
Et pour chacune 4 films associés.
On a maintenant tout en place pour commencer à nous amuser…
Route et contrôleur
On va définir une nouvelle route pour aller chercher les films par catégorie :
Route::get('category/{slug}/films', [FilmController::class, 'index'])->name('films.category');
On voit qu’on pointe la méthode index du contrôleur. Cette méthode existe déjà mais ne servait jusque là qu’à fournir la liste paginée des films sans tenir compte de catégories. On met à jour le code :
use App\Models\{Film, Category}; ... public function index($slug = null) { $query = $slug ? Category::whereSlug($slug)->firstOrFail()->films() : Film::query(); $films = $query->withTrashed()->oldest('title')->paginate(5); $categories = Category::all(); return view('index', compact('films', 'categories', 'slug')); }
On voit qu’on distingue le cas où on fournit un slug de catégorie et le cas où on n’en fournit pas. On envoie toutes les informations nécessaires dans la vue index.
La vue index
On va donc un peu modifier la vue index pour ajouter cette fonctionnalité. Essentiellement on ajoute une liste de choix :
<div class="card"> <header class="card-header"> <p class="card-header-title">Films</p> <div class="select"> <select onchange="window.location.href = this.value"> <option value="{{ route('films.index') }}" @unless($slug) selected @endunless>Toutes catégories</option> @foreach($categories as $category) <option value="{{ route('films.category', $category->slug) }}" {{ $slug == $category->slug ? 'selected' : '' }}>{{ $category->name }}</option> @endforeach </select> </div> <a class="button is-info" href="{{ route('films.create') }}">Créer un film</a> </header>
On modifie aussi un peu le style pour tenir compte de la liste :
select, .is-info { margin: 0.3em; }
Maintenant quand on arrive avec l’url …/films on a :
Quand on clique sur une option de la liste, donc une des catégories, ça envoie la requête qui va bien et ça affiche les films de cette catégorie :
Ici on a l’url …/category/voluptatum/films.
La vue show
On va aussi ajouter dans la vue show le nom de la catégorie du film. On complète le contrôleur :
public function show(Film $film) { $category = $film->category->name; return view('show', compact('film', 'category')); }
Et la vue :
<div class="content"> <p>Année de sortie : {{ $film->year }}</p> <p>Catégorie : {{ $category }}</p> ... </div>
Et ça roule :
La création d’un film
Maintenant aussi quand on crée un film il faut l’attribuer à une catégorie. Il faut déjà modifier la méthode create du contrôleur pour envoyer les catégories :
public function create() { $categories = Category::all(); return view('create', compact('categories')); }
Et modifier la vue create en ajoutant une liste des catégories :
<form action="{{ route('films.store') }}" method="POST"> @csrf <div class="field"> <label class="label">Catégorie</label> <div class="select"> <select name="category_id"> @foreach($categories as $category) <option value="{{ $category->id }}">{{ $category->name }}</option> @endforeach </select> </div> </div>
On ajoute le champ category_id dans la propriété $fillable du modèle Film :
protected $fillable = ['title', 'year', 'description', 'category_id'];
Et on n’a même pas besoin de toucher à la méthode store du contrôleur ! D’autre part on ne va pas se soucier de validation pour une liste imposée.
Les composeurs de vue
Vous avez peut-être remarqué une répétition de code dans ces deux méthodes du contrôleur :
public function index($slug = null) { $query = $slug ? Category::whereSlug($slug)->firstOrFail()->films() : Film::query(); $films = $query->withTrashed()->oldest('title')->paginate(5); $categories = Category::all(); return view('index', compact('films', 'categories', 'slug')); } public function create() { $categories = Category::all(); return view('create', compact('categories')); }
Dans les deux cas on va chercher toutes les catégories pour les envoyer à la vue. Laravel propose le concept de composeur de vue (view composer) pour traiter cette situation de façon plus élégante. Dans un premier temps on nettoie le contrôleur :
public function index($slug = null) { $query = $slug ? Category::whereSlug($slug)->firstOrFail()->films() : Film::query(); $films = $query->withTrashed()->oldest('title')->paginate(5); return view('index', compact('films', 'slug')); } public function create() { return view('create'); }
Donc là on n’envoie plus les catégories et ça ne fonctionne plus !
Changez ainsi le code du fichier App\Providers\AppServiceProvider :
<?php namespace App\Providers; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\View; use App\Models\Category; class AppServiceProvider extends ServiceProvider { public function register() { // } public function boot() { View::composer(['index', 'create'], function ($view) { $view->with('categories', Category::all()); }); } }
On utilise la façade View avec la méthode composer pour mettre en place le fait que chaque fois qu’une des deux vues index ou create et appelée alors on associe la variable categories qui contient toutes les catégories. Et ça fonctionne ! Classiquement on créerait plutôt un provider dédié à cette tâche.
Pour simplifier l’exemple je ne prévois pas la possibilité de modifier la catégorie d’un film.
J’ai prévu un ZIP récupérable ici qui contient le code de cet article.
En résumé
- Une relation de type 1:n nécessite la création d’une clé étrangère côté n.
- Une relation dans la base nécessite la mise en place de méthodes spéciales dans les modèles.
- On dispose de plusieurs méthodes pour manipuler une relation.
- Un composeur de vue permet de mutualiser du code pour envoyer des informations dans les vues.


20 commentaires
Miguel
j’ai resolu en enlevant unique sur le name de la table categories parck les slug et name ont les memes noms.
Miguel
moi j’ai cette erreurr ci.
E:\laragon\www\blog\vendor\laravel\framework\src\Illuminate\Database\Connection.php:501
PDOException::(« SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry ‘deleniti’ for key ‘categories_name_unique' »)
elProfessor
C’est ici que j’apprend laravel. Génial tuto. Et votre pédagogie l’est aussi.
fabBlab
Je continue à pinailler :), mais peut-être cela pourra aider d’autres personnes.
Concernant le code du factory des catégories :
public function definition()
{
$name = $this->faker->word();
return [
‘name’ => $name,
‘slug’ => Str::slug($name),
];
}
Cela n’est pas indiqué, mais il est nécessaire d’appeler le helper « str » en début de script pour la fonction slug() fonctionne :
use Illuminate\Support\Str;
Et sinon concernant l’astuce du composeurs de vue, je suis d’accord qu’il faut éviter de se répéter, mais d’un autre côté si je me mets dans la peau de quelqu’un qui découvre une application, il peut se demander de quel chapeau sort « categories ».
Peut-être ajouter un commentaire dans les vues ?
À moins qu’un déveleppeur habitué à Laravel aura le réflexe d’aller chercher à cet endroit, en plus des contrôleurs ?
bestmomo
Salut,
J’ai ajouté la référence de la classe parce que ça peut effectivement égarer certains…
Pour le composeur de vue, c’est assez largement utilisé donc il faut s’y habituer 🙂
gil
Sur le onDelete et onUpdate, n’étant pas trop rentré dans le fonctionnement, j’avais choisi avant de ne pas utiliser, parce que ne maîtrisant pas le truc, j’avais peur de « destructions en chaîne non controllées ».
Mais comme il faut bien que ça ne plante pas, dans tous les modèles à clef externe, dans les index; show et edit, j’ai du tester si les id existent, et alors afficher « PB! » avec un complément d’info en show et edit » lien sur un ID interne de xyz #12216 qui n’est pas un ID existant => Une correction est nécessaire ! »
Bref bcp de test mais au moins ça fonctionne sans planter.
Ce n’est pas satisfaisant, mais la doc Laravel n’est pas super explicite sur tous ces sujets. Et la doc API non plus. Peut-être qu’il y a quelque chose sur le sujet chez Laracard, je vais chercher.
– restrictOnDelete() et OnDelete (‘restrict’) c’est bien la même chose ?
– ‘Cascade’ sur on OnUpdate, ça cascade quoi ?
– ‘Restrict’ c’est bien joli, mais on ne peut pas envisager d’interdire la destruction d’un user. ‘cascade’ c’est bien joli, mais on n’a pas forcément envie de perdre tous les articles créés. alors onDelete(‘null’) ça existe ? (il y a un nullOnDelete(). Après faut gérer les « null », mais c’est mieux et plus simple que ce que je faisais, gérer des id inexistants.
– Une « cascade » des timestamps si on ajoute ou retire un lien, ça existe (évidemment c’est un clef interne du modèle, ça marche, mais si c’est une clef de la table liée ? Ou si c’est une table pivot ?)
bestmomo
Salut,
Tu ne trouveras aucune explication dans la documentation de Laravel parce que c’est quelque chose qui concerne MySQL et c’est dans sa documentation que tout est expliqué. En gros par défaut c’est RESTRICT, c’est-à-dire qu’on ne peut pas supprimer un enregistrement si un enregistrement lié possède cette clé comme clé étrangère. C’est une sécurité. L’autre option classique est CASCADE, dans ce cas les enregistrements liés sont aussi supprimés. Mais il existe aussi l’option NULL pour conserver les enregistrements liés mais ensuite comme tu dis il faut les gérer.
Pour l’update c’est un peu spécial, c’est si une clé primaire change alors forcément l’enregistrement lié se désynchronise. En mode RESTRICT ça coince. En mode CASCADE la clé étrangère est changée pour correspondre à la nouvelle valeur. Mais je ne vois pas trop pourquoi une clé primaire changerait dans un processus normal.
gil
Cool, merci !
En fait selon le contexte, les trois (Restrict, Cascade ou Set Null) peuvent être utiles, il faut choisir en fonction des besoins. Il en existe un 4ième (no action), mais en mysql c’est comme Restrict.
Pas la même occasion, j’ai compris que si dans mon dev précédent je n’avais aucun de ces trois comportements et que j’avais du tester l’existence partout… c’est parce que toutes mes clefs, de tables ou de tables pivots étaient juste de simples Integer que j’utilisais comme clef sans les avoir défini comme clef… C’est mal ! 😀
cfor
Bonjour, j’ai une table « services » avec une clé étrangère (type_service_id). Lorsque j’exécute le seed, j’ai ce type d’erreur :
Argument 1 passed to Illuminate\Database\Grammar::parameterize() must be of the type array, string given, called in C:\Sites\SaaS-finances\vendor\laravel\framework\src\Illuminate\Database\Query\Grammars\Grammar.php on line 886
at C:\Sites\SaaS-finances\vendor\laravel\framework\src\Illuminate\Database\Grammar.php:136
132▕ *
133▕ * @param array $values
134▕ * @return string
135▕ */
➜ 136▕ public function parameterize(array $values)
137▕ {
138▕ return implode(‘, ‘, array_map([$this, ‘parameter’], $values));
139▕ }
140▕
1 C:\Sites\SaaS-finances\vendor\laravel\framework\src\Illuminate\Database\Query\Grammars\Grammar.php:886
Illuminate\Database\Grammar::parameterize(« 2021-02-04 19:46:19 »)
2 [internal]:0
Illuminate\Database\Query\Grammars\Grammar::Illuminate\Database\Query\Grammars\{closure}(« 2021-02-04 19:46:19 », « updated_at »)
J’ai fait des recherches sur le net mais sans succès. D’où cela peut-il venir à votre avis?
bestmomo
Bonjour,
Quelque par dans le seeder tu envoies une chaîne de caractères au lieu d’un tableau. C’est pas toujours facile à traquer, quand ça m’arrive je supprime par bloc le code et je lance, du coup je repère peu à peu là où ça se passe.
Aboubakar
J’ai trouvé le problème. J’avais commit une erreur grave au niveau de la classe DatabaseSeeder. Maintenant c’est reglé, tout marche bien et j’ai reussi à finir le projet. Merci à vous.
Une autre question : Le tuto sur l’appli e-comerce que vous avez fait avec Laravel 7, est-ce que je peux le reprendre avec Laravel 8 ? Je veux dire il n’y aura pas trop d’incompatibilités? Ou je dois installer la version 7.
Bonne journée.
bestmomo
Super si ça marche !
Pour l’application e-commerce ça peut aller avec Laravel 8 sans trop de souci je pense, il faut faire attention à l’authentification est bien utiliser le package laravel/ui. D’ailleurs quelqu’un a déposé une version actualisée complète pour Laravel 8 ici.
Aboubakar
Bonjour,
Je tiens à vous remercier pour ce cours très claire (Je me regale depuis le début de la semaine)
Mais j’ai eu un petit problème à l’étape de la population (Seeding).
Voici l’erreur que j’ai eu (j’ai suvi toutes les étapes, en fin je croid) :
php artisan db:seed
Illuminate\Database\QueryException
SQLSTATE[HY000]: General error: 1364 Field 'category_id' doesn't have a default value (SQL: insert into `films` (`title`, `year`, `description`, `updated_at`, `created_at`) values (Ad earum
dignissimos., 2005, Ut qui dolorem eos totam. Non vitae sed et enim quos corrupti delectus. Aut suscipit iusto nihil modi exercitationem illo quibusdam inventore. Molestiae cum dolor corporis
nisi., 2020-10-21 00:37:08, 2020-10-21 00:37:08))
at D:\PHP_Laravel\crud_film2\vendor\laravel\framework\src\Illuminate\Database\Connection.php:671
667▕ // If an exception occurs when attempting to run a query, we'll format the error
668▕ // message to include the bindings with SQL, which will make this exception a
669▕ // lot more helpful to the developer instead of just the database's errors.
670▕ catch (Exception $e) {
➜ 671▕ throw new QueryException(
672▕ $query, $this->prepareBindings($bindings), $e
673▕ );
674▕ }
675▕
1 D:\PHP_Laravel\crud_film2\vendor\laravel\framework\src\Illuminate\Database\Connection.php:464
PDOException::("SQLSTATE[HY000]: General error: 1364 Field 'category_id' doesn't have a default value")
2 D:\PHP_Laravel\crud_film2\vendor\laravel\framework\src\Illuminate\Database\Connection.php:464
PDOStatement::execute()
Qu’est ce que j’ai oublié ? Ou qu’est ce je dois faire à ce stade ?
Merci.
bestmomo
Bonjour,
Est-ce que la relation est bien définie dans le modèle Film ?
Aboubakar
Oui, je n’ai fait que reprendre la méthode category :
public function category(){
return $this->belongsTo(Category::class);
}
Aboubakar
Est-ce que cette fonction dans la class FilmFactory doit rester telle qu’elle est ou on doit rajouter quelque chose en lien avec ‘category_id’ ?
public function definition()
{
return [
'title'=>$this->faker->sentence(2,true),
'year'=>$this->faker->year,
'description'=>$this->faker->paragraph()
];
}
bestmomo
Non rien à changer ici par contre est-ce que category_id est bien dans la propriété $fillable ?
Aboubakar
C’est le model Film :
class Film extends Model
{
use HasFactory;
protected $fillable = ['title', 'year', 'description','category_id'];
public function category(){
return $this->belongsTo(Category::class);
}
}
bestmomo
Là aussi le code est bon… Je vois pas d’où viens le souci du coup…
Nyleor
Salut,
J’ai eu pratiquement la même erreur (enfin sur un autre projet mais je suis venu rechercher mes infos ici). Personnellement d’après ce que j’ai compris, oui il faut désormais rajouter un faker pour category_id puisque cette nouvelle colonne ne peut être vide. Perso j’ai simplement rajouté ainsi :
‘category_id’ => $this->faker()->numberBetween(1, 5),
Sachant que j’ai 5 entrées dans les catégories. Et cela fonctionne.