Cours Laravel 5.3 – les données – la relation 1:n
Dimanche 27 novembre 2016 19:13
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 verrons également dans ce chapitre comment créer un middleware.
Comme exemple pour ce chapitre, je vais prendre le cas d'un petit blog personnel avec :
- un affichage des articles,
- des visiteurs qui pourront consulter les articles,
- des utilisateurs enregistrés qui pourront aussi rédiger des articles (donc possibilité de se connecter et se déconnecter),
- des administrateurs qui pourront aussi supprimer des articles.
php artisan make:auth
Nous allons utiliser cette infrastructure de base pour créer notre exemple.
Les migrations
Table users
Pour distinguer les rôles nous allons ajouter dans la table users une colonne admin. Dans la migration par défaut ajoutez cette ligne :public function up() { Schema::create('users', function (Blueprint $table) { ... $table->boolean('admin')->default(false); ... }); }On va ainsi créer un champ booléen admin avec comme valeur par défaut false. Ne changez rien au reste du code.
Table posts
On va créer une migration pour la table des articles posts : php artisan make:migration create_posts_table Complétez le code ainsi :<?php use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; class CreatePostsTable extends Migration { public function up() { Schema::create('posts', function(Blueprint $table) { $table->increments('id'); $table->timestamps(); $table->string('titre'); $table->text('contenu'); $table->integer('user_id')->unsigned(); $table->foreign('user_id') ->references('id') ->on('users') ->onDelete('restrict') ->onUpdate('restrict'); }); } public function down() { Schema::table('posts', function(Blueprint $table) { $table->dropForeign('posts_user_id_foreign'); }); Schema::drop('posts'); } }Normalement vous devez avoir ces 3 migrations : Lancez les migrations : Vous devez vous retrouver avec les trois tables dans votre base ainsi que la table migrations :
La relation
On a la situation suivante :- un utilisateur peut écrire plusieurs articles,
- un article est écrit par un seul utilisateur.
$table->foreign('user_id') ->references('id') ->on('users') ->onDelete('restrict') ->onUpdate('restrict');Dans la table on déclare une clé étrangère (foreign) nommée user_id qui référence (references) la ligne id dans la table (on) users. 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 un utilisateur avec l'id 5 qui a deux articles, donc dans la table posts on a deux enregistrements avec user_id qui a la valeur 5. Si on supprime l'utilisateur que va-t-il se passer ? On risque de se retrouver avec nos deux enregistrements dans la table posts avec une clé étrangère qui ne correspond à aucun enregistrement dans la table users. En mettant restrict on empêche la suppression d'un utilisateur qui a des articles. On doit donc commencer par supprimer ses articles 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 user_id une valeur qui n'existe pas dans la table users. Une autre possibilité est cascade à la place de restrict. Dans ce cas si vous supprimez un utilisateur ça supprimera en cascade les articles de cet utilisateur. C'est une option qui est rarement 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.
Les modèles
Nous avons déjà un modèle User (app/User.php). Il va juste falloir ajouter une méthode pour pouvoir facilement aller trouver les articles d'un utilisateur. Ajoutez ce code dans le modèle User :public function posts() { return $this->hasMany(\App\Post::class); }On déclare ici qu'un utilisateur a plusieurs (hasMany) articles (posts). On aura ainsi une méthode pratique pour récupérer les articles d'un utilisateur. Soyez vigilant avec les espaces de noms ! Il nous faut aussi le modèle Post :
php artisan make:model PostComplétez ainsi le code :
<?php namespace App; use Illuminate\Database\Eloquent\Model; class Post extends Model { protected $fillable = [ 'titre','contenu','user_id' ]; public function user() { return $this->belongsTo(\App\User::class); } }Ici on a la méthode user (au singulier) qui permet de trouver l'utilisateur auquel appartient (belongsTo) l'article. C'est donc la réciproque de la méthode précédente. Voici une schématisation de cette 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 Post il en conclut que la table s'appelle posts. 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 articles de l'utilisateur qui a l'id 1 :
$articles = App\User::find(1)->posts;De la même manière on peut trouver l'utilisateur qui a écrit l'article d'id 1 :
$user = App\Post::find(1)->user;Vous voyez que le codage devient limpide avec ces méthodes .
Le contrôleur et les routes
Le contrôleur
Maintenant que tout est en place au niveau des données voyons un peu la gestion de tout ça. On va créer un contrôleur de ressource pour les articles qu'on va appeler PostController :php artisan make:controller PostController --resourceCe contrôleur devra gérer plusieurs chose :
- la réception de la requête pour afficher les articles du blog et la réponse adaptée,
- la réception de la requête pour le formulaire pour créer un nouvel article et son envoi,
- la réception de la soumission du formulaire de création d'un nouvel article (réservé à un utilisateur connecté) et son enregistrement,
- la réception de la demande de suppression d'un article (réservé à un administrateur) et sa suppression.
<?php namespace App\Http\Controllers; use App\Repositories\PostRepository; use App\Http\Requests\PostRequest; use App\Post; class PostController extends Controller { protected $postRepository; protected $nbrPerPage = 4; public function __construct(PostRepository $postRepository) { $this->middleware('auth')->except('index'); $this->middleware('admin')->only('destroy'); $this->postRepository = $postRepository; } public function index() { $posts = $this->postRepository->getPaginate($this->nbrPerPage); return view('posts.liste', compact('posts')); } public function create() { return view('posts.create'); } public function store(PostRequest $request) { $inputs = array_merge($request->all(), ['user_id' => $request->user()->id]); $this->postRepository->store($inputs); return redirect()->route('post.index'); } public function destroy(Post $post) { $this->postRepository->destroy($post); return back(); } }Comme à l’accoutumée j'injecte la requête de formulaire et le repository. Notez l'utilisation des middleware pour filtrer les utilisateurs, nous allons voir cela un peu plus loin.
Les routes
On a vu dans le chapitre sur les ressources comment créer les routes de ce genre de contrôleur. Il va juste falloir indiquer qu'on ne veut pas utiliser les 7 méthodes disponibles mais juste certaines. On va aussi établir une redirection de la route de base "/" vers la ressource :Route::get('/', function () { return redirect()->route('post.index'); }); Auth::routes(); Route::resource('post', 'PostController', ['except' => ['show', 'edit', 'update']]);Pour mémoire Auth::routes(); a été généré par Artisan pour créer les routes de l'authentification. Vous avez ainsi toutes ces routes :
Le repository
Pour la gestion on va placer les fichiers dans le dossier app/Repositories comme nous l'avons déjà fait pour la gestion des utilisateurs : Avec ce code :<?php namespace App\Repositories; use App\Post; class PostRepository { protected $post; public function __construct(Post $post) { $this->post = $post; } public function getPaginate($n) { return $this->post->with('user') ->orderBy('posts.created_at', 'desc') ->paginate($n); } public function store($inputs) { $this->post->create($inputs); } public function destroy(Post $post) { $post->delete(); } }Nous allons voir plus loin l'utilité de toutes ces méthodes.
Les middlewares
On a vu que dans le contrôleur on applique deux middlewares :- auth : accès réservé aux utilisateurs authentifiés à part pour la méthode index pour afficher le blog,
- admin : accès réservé aux administrateurs pour la méthode destroy.
php artisan make:middleware AdminOn trouve le fichier bien rangé : Modifiez ainsi le code :
<?php namespace App\Http\Middleware; use Closure; class Admin { /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle($request, Closure $next) { if ($request->user()->admin) { return $next($request); } return redirect('post'); } }Si l'utilisateur n'est pas un administrateur on redirige sur l'affichage du blog. Remarquez qu'on ne vérifie pas à ce niveau qu'on a un utilisateur authentifié parce que dans le constructeur du contrôleur le filtre auth est placé avant le filtre admin. Si c'était l'inverse on tomberait évidemment sur une erreur en cas de tentative d'accès à l'url pour la suppression d'un article. On a créé le middleware mais ça ne suffit pas, il faut maintenant un lien entre son nom et la classe qu'on vient de créer. Regardez dans le fichier app/Http/Kernel.php ces lignes de code :
protected $routeMiddleware = [ 'auth' => \Illuminate\Auth\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, ];Vous trouvez ici tous les middlewares déclarés, il suffit d'ajouter le nouveau :
protected $routeMiddleware = [ ... 'admin' => \App\Http\Middleware\Admin::class, ];
La validation
Voyons maintenant la validation. On va encore créer une requête de formulaire :php artisan make:request PostRequestElle se place dans le dossier qui se crée pour l'occasion : Et on complète ainsi le code :
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class PostRequest extends FormRequest { /** * Determine if the user is authorized to make this request. * * @return bool */ public function authorize() { return true; } /** * Get the validation rules that apply to the request. * * @return array */ public function rules() { return [ 'titre' => 'bail|required|max:255', 'contenu' => 'required' ]; } }
La population
Les fabriques (model factories)
Nous allons voir maintenant comment remplir nos tables avec des enregistrements pour faire nos essais. Laravel nous permet de définir un tableau d'attributs pour les modèles avec les fabriques (model factories). Regardez le fichier database/factories/ModelFactory.php :<?php /* |-------------------------------------------------------------------------- | Model Factories |-------------------------------------------------------------------------- | | Here you may define all of your model factories. Model factories give | you a convenient way to create models for testing and seeding your | database. Just tell the factory how a default model should look. | */ $factory->define(App\User::class, function (Faker\Generator $faker) { static $password; return [ 'name' => $faker->name, 'email' => $faker->safeEmail, 'password' => $password ?: $password = bcrypt('secret'), 'remember_token' => str_random(10), ]; });Faker. C'est un générateur de données virtuelles aléatoires qui sait pratiquement tout faire. Comme on a ajouté la colonne admin on va la prévoir dans la fabrique. D'autre part on va utiliser un mot de passe systématique pour nous simplifier la vie :
$factory->define(App\User::class, function (Faker\Generator $faker) { return [ 'name' => $faker->name, 'email' => $faker->safeEmail, 'password' => bcrypt('secret'), 'admin' => $faker->boolean, 'remember_token' => str_random(10), ]; });On va aussi prévoir une fabrique pour les articles dans le même fichier :
$factory->define(App\Post::class, function (Faker\Generator $faker) { return [ 'titre' => $faker->sentence(2), 'contenu' => $faker->paragraph(rand(8, 15)), 'created_at' => $faker->dateTimeThisYear(), ]; });la documentation détaillée.
La population
Regardez le fichier database/seeds/DatabaseSeeder : Par défaut il comporte ce code :<?php use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { /** * Run the database seeds. * * @return void */ public function run() { // $this->call(UsersTableSeeder::class); } }La méthode run est destinée à exécuter les fichiers pour la population. Vous avez déjà la ligne commentée de lancement pour la table users. Comme on n'a que deux tables à remplir on va mettre tout le code dans ce fichier :
public function run() { factory(App\User::class, 5) ->create() ->each(function ($user) { $user->posts()->saveMany(factory(App\Post::class, rand(2, 5))->make()); }); }On appelle la fabrique (factory) pour le modèle des utilisateurs (App\User::class), on en veut 5, on les crée (create) pour chacun des utilisateurs créés (each $user) on utilise la relation (posts()) pour créer plusieurs (saveMany) articles (App\Post::class) en utilisant la fabrique (factory), on en veut entre 2 et 5 (rand(2, 5)). Il ne vous reste plus qu'à lancer la population :
php artisan db:seedVous aurez dans la base 5 utilisateurs : Et des articles (table posts) affectés aux utilisateurs : On a des dates aléatoires pour la colonne created_at, ce qui va nous permettre de les trier.
Fonctionnement
La liste des articles
La liste des articles est obtenue avec l'url (méthode get) : .../post Elle arrive sur la méthode index du contrôleur :public function index() { $posts = $this->postRepository->getPaginate($this->nbrPerPage); return view('posts.liste', compact('posts')); }Ici on envoie le nombre d'articles par page (placé dans la propriété $nbrPerPage) à la méthode getPaginate du repository :
public function getPaginate($n) { return $this->post->with('user') ->orderBy('created_at', 'desc') ->paginate($n); }On veut les articles avec (with) l'utilisateur (user), dans l'ordre des dates de création (created_at) descendant (desc) avec une pagination de n articles ($n). Il existe la méthode latest (et oldest pour l'inverse) qui permet de simplifier la syntaxe :
return $this->post->with('user') ->latest() ->paginate($n);
L'ajout d'un article
La demande du formulaire de création d'un article se fait avec l'url (méthode get) : .../post/create Le contrôleur renvoie directement la vue :public function create() { return view('posts.create'); }Le retour du formulaire se fait avec l'url (méthode post) : .../post Remarquez que cette méthode est protégée par le middleware auth :
$this->middleware('auth')->except('index');On arrive sur la méthode store du contrôleur :
public function store(PostRequest $request) { $inputs = array_merge($request->all(), ['user_id' => $request->user()->id]); $this->postRepository->store($inputs); return redirect()->route('post.index'); }On injecte la requête de formulaire pour la validation, je n'insiste pas parce qu'il n'y a rien de nouveau à ce niveau. On récupère les entrées du formulaire pour le titre et le contenu. Pour l'identifiant de l'utilisateur on sait qu'il est forcément connecté alors on récupère cet identifiant avec la requête. Si la validation se passe bien on envoie à la méthode store du repository :
public function store($inputs) { $this->post->create($inputs); }L'assignement de masse fonctionne parce qu'on a prévu la propriété $fillable dans le modèle Post :
protected $fillable = [ 'titre','contenu','user_id' ];
Suppression d'un article
Enfin on supprime un article avec l'url (méthode delete) : .../post/id Où id représente l'identifiant de l'article à supprimer. On tombe sur la méthode destroy du contrôleur :public function destroy(Post $post) { $this->postRepository->destroy($post); return back(); }Remarquez que cette méthode est protégée par le middleware admin :
$this->middleware('admin')->only('destroy');Avec la liaison implicite on a directement le modèle de l'article correspondant dans la variable $post. On l'envoie à la méthode destroy du repository :
public function destroy(Post $post) { $post->delete(); }Là on supprime l'article avec la méthode delete du modèle.
Le template
Voyons à présent les vues. On dispose déjà d'un template (resources/views/layouts/app.blade.php) avec l'installation de l'authentification : Alors on va l'utiliser pour avoir une cohérence visuelle de l'application. On va juste lui apporter des petites modifications (changement du titre, francisation, ajout d'un item dans la barre de navigation) , voici le code résultant :<!DOCTYPE html> <html lang="fr"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <!-- CSRF Token --> <meta name="csrf-token" content="{{ csrf_token() }}"> <title>Mon joli blog</title> <!-- Styles --> <link href="/css/app.css" rel="stylesheet"> <!-- Scripts --> <script> window.Laravel = <?php echo json_encode([ 'csrfToken' => csrf_token(), ]); ?> </script> </head> <body> <nav class="navbar navbar-default navbar-static-top"> <div class="container"> <div class="navbar-header"> <!-- Collapsed Hamburger --> <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#app-navbar-collapse"> <span class="sr-only">Toggle Navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <!-- Branding Image --> <a class="navbar-brand" href="{{ url('/') }}"> Mon joli Blog </a> </div> <div class="collapse navbar-collapse" id="app-navbar-collapse"> <!-- Left Side Of Navbar --> <ul class="nav navbar-nav"> </ul> <!-- Right Side Of Navbar --> <ul class="nav navbar-nav navbar-right"> <!-- Authentication Links --> @if (Auth::guest()) <li><a href="{{ url('/login') }}">Se connecter</a></li> <li><a href="{{ url('/register') }}">S'enregistrer</a></li> @else <li><a href="{{ url('/post/create') }}">Créer un article</a></li> <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false"> {{ Auth::user()->name }} <span class="caret"></span> </a> <ul class="dropdown-menu" role="menu"> <li> <a href="{{ url('/logout') }}" onclick="event.preventDefault(); document.getElementById('logout-form').submit();"> Logout </a> <form id="logout-form" action="{{ url('/logout') }}" method="POST" style="display: none;"> {{ csrf_field() }} </form> </li> </ul> </li> @endif </ul> </div> </div> </nav> @yield('content') <!-- Scripts --> <script src="/js/app.js"></script> </body> </html>
L'affichage des articles
Nous avons besoin d'une vue pour afficher les articles du blog (resources/views/posts/liste.blade.php) : Avec ce code :@extends('layouts.app') @section('content') <div class="container"> @if(isset($info)) <div class="row alert alert-info">{{ $info }}</div> @endif {!! $posts->links() !!} @foreach($posts as $post) <article class="row bg-primary"> <div class="col-md-12"> <header> <h1>{{ $post->titre }}</h1> </header> <hr> <section> <p>{{ $post->contenu }}</p> @if(auth()->check() and auth()->user()->admin) <form method="POST" action="{{ route('post.destroy', ['id' => $post->id]) }}"> {{ method_field('DELETE') }} {{ csrf_field() }} <input class="btn btn-danger btn-xs" onclick="return confirm('Vraiment supprimer cet article ?')" type="submit" value="Supprimer cet article"> </form> @endif <em class="pull-right"> {{ $post->user->name }} le {!! $post->created_at->format('d-m-Y') !!} </em> </section> </div> </article> <br> @endforeach {!! $posts->links() !!} </div> @endsectionAvec cet aspect pour un utilisateur non connecté : Le lien "Se connecter" ouvre le formulaire de connexion comme on l'a vu dans le chapitre sur l'authentification. Pour avoir une redirection correcte après la connexion il faut bien renseigner la propriété $redirectTo dans le contrôleur LoginController :
protected $redirectTo = 'post';La génération des articles dans la vue se fait avec un foreach :
@foreach($posts as $post) ... @endforeachSi vous vous connectez vous retournez au blog avec deux liens supplémentaires : On utilise une condition pour adapter les liens :
@if (Auth::guest()) <li><a href="{{ url('/login') }}">Se connecter</a></li> <li><a href="{{ url('/register') }}">S'enregistrer</a></li> @else <li><a href="{{ url('/post/create') }}">Créer un article</a></li> <li class="dropdown"> ... Logout ... </li> @endifLa méthode guest permet de savoir si un utilisateur est un simple visiteur non authentifié. C'est le contraire de la méthode check qu'on pourrait utiliser en inversant la logique. Si un administrateur est connecté il dispose en plus de boutons pour supprimer les articles : On utilise encore une condition pour détecter un administrateur :
@if(auth()->check() and auth()->user()->admin) ... @endifIl faut que l'utilisateur soit connecté (check) et que ce soit un administrateur (auth()->user()->admin). Comme on n'utilise pas laravelcollective/html dans cet exemple (c'est bien aussi de voir comment faire sans ce composant) il faut construire les formulaires avec tout le code nécessaire. Pour la destruction des articles on doit aboutir sur cette route : Il nous faut une méthode DELETE, une méthode qui n’est pas supportée dans les formulaires (tout comme PUT et PATCH). Alors on déclare une méthode POST comme action mais on ajoute un contrôle caché _method avec la valeur DELETE. A l'arrivée la requête est interprétée comme une requête DELETE. Regardez le code de ce formulaire :
<form method="POST" action="{{ route('post.destroy', ['id' => $post->id]) }}"> {{ method_field('DELETE') }} {{ csrf_field() }} <input class="btn btn-danger btn-xs" onclick="return confirm('Vraiment supprimer cet article ?')" type="submit" value="Supprimer cet article"> </form>On utilise l'helper method_field pour générer ce code :
<input type="hidden" name="_method" value="DELETE">J'ai aussi prévu une simple confirmation en JavaScript avant de supprimer effectivement l'article.
La création d'un article
Voici maintenant la vue pour le formulaire de création d'un article (resources/views/posts/create.blade.php) : Avec ce code :@extends('layouts.app') @section('content') <div class="col-sm-offset-3 col-sm-6"> <div class="panel panel-default"> <div class="panel-heading">Ajout d'un article</div> <div class="panel-body"> <form method="POST" action="{{ url('/post') }}"> {{ csrf_field() }} <div class="form-group{{ $errors->has('titre') ? ' has-error' : '' }}"> <input class="form-control" placeholder="Titre" name="titre" type="text" value="{{ old('titre') }}" autofocus> @if ($errors->has('titre')) <span class="help-block"> <strong>{{ $errors->first('titre') }}</strong> </span> @endif </div> <div class="form-group{{ $errors->has('contenu') ? ' has-error' : '' }}"> <textarea class="form-control" placeholder="Contenu" name="contenu" cols="50" rows="10">{{ old('contenu') }}</textarea> @if ($errors->has('contenu')) <span class="help-block"> <strong>{{ $errors->first('contenu') }}</strong> </span> @endif </div> <button type="submit" class="btn btn-primary pull-right">Envoyer !</button> </form> </div> </div> </div> @endsectionVoici l'apparence du formulaire : Là encore le fait de ne pas utiliser le composant laravellollective/html oblige à :
- écrire tout le code,
- prévoir de remplir les contrôles avec les anciennes valeurs saisies en cas d'erreur dans la validation.
<input class="form-control" placeholder="Titre" name="titre" type="text" value="{{ old('titre') }}" autofocus>Imaginez que vous ayez saisi le titre mais pas le contenu, au retour de la validation vous allez retrouver votre titre : C'est exactement pareil pour le contenu.
En résumé
- Une relation de type 1:n nécessite la création d'une clé étrangère côté n.
- On peut remplir les tables d'enregistrements avec la population.
- Une relation dans la base nécessite la mise en place de méthodes spéciales dans les modèles.
- Avec les middlewares il est facile de gérer l'accès aux méthodes des contrôleurs.
Par bestmomo
Nombre de commentaires : 4