Laravel 4

Laravel 4 : chapitre 34 : Les relations avec Eloquent 2/2

Modification le 5/5/2014 : quelques changements dans l’organisation du code et dans la syntaxe (en particulier si vous aviez des soucis avec le PSR-0 ça devrait maintenant être réglé).

Dans le dernier article j’ai détaillé les possibilités relationnelles d’Eloquent. Maintenant il nous reste à voir comment gérer tout ça. Le voyage va être parfois un peu mouvementé alors accrochez-vous…

La base

La base de référence qui va nous servir est la même que nous avons vue précédemment :

img92

Le code

Installation

Comme le code est volumineux je ne vais pas le mettre complètement ici mais juste m’y référer. Vous pouvez le télécharger ici. Il vous suffit de caser tout ça dans un dossier et de faire :

  • composer install
  • créer une base et renseigner son nom dans app/config/database.php
  • php artisan migrate:install
  • php artisan migrate
  • php artisan db:seed

Vous devriez alors avoir une application fonctionnelle et arriver sur cette page :

img93

Organisation du code

Voici l’architecture des dossiers dans app :

img14

Vous pouvez remarquer la présence du dossier Lib qui contient pratiquement tout le code de l’application et le fichier macro.php qui contient des macros HTML et Form. Vous pouvez aussi voir qu’il n’y a pas les dossiers models, views et controllers. Je suis parti sur une organisation du code fondée sur des entités, en l’occurrence les tables. Si vous regardez le contenu du dossier Lib :

img15

Vous voyez un dossier pour presque chacune des tables de la base. Le code correspondant à la gestion de chaque table se trouve dans ce dossier. Par exemple pour les villes :

img17

Vous trouvez ici les vues, le modèle, le contrôleur, la validation et la gestion. c’est la même chose pour chaque table.

Il y a aussi un dossier Commun qui contient tout le code qui concerne toutes les gestions :

img16

Ici on a les templates, la validation (la classe utilisée est celle du bouquin de Fidao Implementing Laravel) , les vues communes, la classe abstraite des contrôleurs…

Cette organisation me paraît plus cohérente quand on commence à avoir pas mal de code. Il m’a fallu évidemment expliquer à Laravel où trouver tout ce code. Je l’ai fait au niveau de composer :

    "psr-0": {
        "Lib": "app"
    }

Pour les vues il m’a fallu changer le path dans app/config/view.php :

'paths' => array(__DIR__.'/../Lib'),

Les macros

Pour simplifier les vues avec l’utilisation de bootstrap j’ai créé quelques macros situées dans le fichier app/macros.php. J’ai renseigné le fichier app/start/global.php :

require app_path().'/macros.php';

Par exemple pour faire apparaître une zone de texte avec son étiquette version bootstrap :

Form::macro('boottext', function($name, $label, $input = '')
{
	return sprintf('
		<div class="row">
			<div class="form-group">
				%s
				<div class="col-md-10">
					%s
				</div>
			</div>
		</div>',
		Form::label($name, $label, array("class" => "col-md-2")),
		Form::text($name, $input, array('class' => 'form-control'))
	);
});

Ce qui génère en une seule ligne ce genre de code :

<div class="row">
	<div class="form-group">
		<label for="nom" class="col-md-2">Nom :</label>
		<div class="col-md-10">
			<input class="form-control" name="nom" type="text" value="Pays 5" id="nom">
		</div>
	</div>
</div>

Les routes

J’ai traité tous les contrôleurs comme des ressources, du coup les routes sont très épurées :

Route::get('/', function() { return View::make('Commun.vues.accueil'); });

Route::resource('villes', 'Lib\Villes\VilleController');
Route::resource('pays', 'Lib\Pays\PaysController');
Route::resource('auteurs', 'Lib\Auteurs\AuteurController');
Route::resource('livres', 'Lib\Livres\LivreController');
Route::resource('editeurs', 'Lib\Editeurs\EditeurController');
Route::resource('autoedites', 'Lib\Autoedites\AutoediteController');
Route::resource('themes', 'Lib\Themes\ThemeController');
Route::resource('categories', 'Lib\Categories\CategorieController');
Route::resource('periodes', 'Lib\Periodes\PeriodeController');

Les contrôleurs

Comme le traitement dans le cas de ressources est similaire j’ai créé une classe abstraite qui contient l’essentiel du code :

<?php namespace Lib\Commun;

use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Redirect;

abstract class BaseResourceController extends \Illuminate\Routing\Controller {

	protected $gestion;
	protected $base;
	protected $message_store;
	protected $message_update;

	public function __construct()
	{
		$this->beforeFilter('csrf', array('on' => array('post', 'delete', 'put')));
		$this->beforeFilter('ajax', array('on' => array('delete', 'put')));
	}

	public function index()
	{
		$lignes = $this->gestion->listePages(10);
		return View::make($this->base.'.vues.liste', compact('lignes'));
	}

	public function create()
	{
		return View::make($this->base.'.vues.create',  $this->gestion->create());
	}

	public function store()
	{
    $return = $this->gestion->store();
    if($return === true) {
    	return Redirect::route($this->base.'.index')->with('message_success', $this->message_store);
    } 
		return Redirect::route($this->base.'.create')->withInput()->withErrors($return);
	}

	public function show($id)
	{
		return View::make($this->base.'.vues.show', $this->gestion->show($id));
	}

	public function edit($id)
	{
		return View::make($this->base.'.vues.edit', $this->gestion->edit($id));
	}

	public function update($id)
	{
		$return = $this->gestion->update($id);
		if($return === true) {
			return Redirect::route($this->base.'.index')->with('message_success', $this->message_update);
		}
		return Redirect::route($this->base.'.edit', $id)->withInput()->withErrors($return);
	}

	public function destroy($id)
	{
		$this->gestion->destroy($id);
		return Redirect::back();
	}

}

On se retrouve ainsi avec des contrôleurs très allégés.

Considérations générales

J’ai simplifié au maximum la situation pour me concentrer sur les procédures. Chaque table a un seul champ, je ne fais pas de tri pour l’affichage, etc…

J’ai prévu une barre de débogage pour voir les requêtes générées par Eloquent.

Gestion des pays

On va commencer avec un cas simple, celui des pays. Au niveau des relations la table des pays est reliée à la table des villes par un lien 1:n :

img76

On a vu également qu’on peut atteindre les auteurs avec la méthode hasManyThrough.

Le modèle

Comme on a des espaces de noms il faut évidemment bien renseigner le code en conséquence pour qu’Eloquent s’y retrouve :

<?php namespace Lib\Pays;

use Eloquent;

class Pays extends Eloquent {

	protected $table = 'pays';
	public $timestamps = true;
	protected $softDelete = false;
	protected $guarded = array('id');

	public function villes()
	{
		return $this->hasMany('\Lib\Villes\Ville');
	}

	public function auteurs()
	{
		return $this->hasManyThrough('\Lib\Auteurs\Auteur', '\Lib\Villes\Ville');
	}

}

Ce sera la même chose pour tous les modèles de l’application.

Le contrôleur

J’ai parlé plus haut de la classe abstraite pour les contrôleurs, ça se traduit pour celui des pays à très peu de code :

<?php namespace Lib\Pays;

class PaysController extends \Lib\Commun\BaseResourceController {

    public function __construct(PaysGestion $gestion)
    {
        parent::__construct();
        $this->gestion = $gestion;
        $this->base = class_basename(__NAMESPACE__);
        $this->message_store = 'Le pays a été ajouté';
        $this->message_update = 'Le pays a été modifié';
    }

}

La gestion est déléguée à la classe PaysGestion qui est injectée dans le modèle. On a ainsi une bonne séparation des tâches.

La liste

Pour obtenir la liste des pays on passe par la méthode ListePages de la classe abstraite Basegestion commune à toutes les gestions :

	public function listePages($pages)
	{
		return $this->model->paginate($pages);
	}

Cette méthode est appelée par la méthode index du contrôleur de base :

	public function index()
	{
		$lignes = $this->gestion->listePages(10);
		return View::make($this->base.'.vues.liste', compact('lignes'));
	}

On voit ici qu’on appelle la vue liste pour la table concernée, ici celle des pays. Le code des vues pour les listes est similaire pour toutes les tables, voici ce que ça donne pour les pays :

@extends('Commun.templates.liste')

@section('entete')
	Liste des pays
	{{ link_to_route('pays.create', 'Ajouter un pays', null, array('class' => 'btn btn-info pull-right')) }}
@stop

@section('titre')
	Pays
@stop

@section('tableau')
	@foreach ($lignes as $ligne)
		<tr>
		<td>{{ $ligne->id }}</td>
		<td class="text-primary"><strong>{{ $ligne->nom }}</strong></td>
		<td>{{ link_to_route('pays.show', 'Voir', array($ligne->id), array('class' => 'btn btn-success btn-block')) }}</td>
		<td>{{ link_to_route('pays.edit', 'Modifier', array($ligne->id), array('class' => 'btn btn-warning btn-block')) }}</td>
		<td>
			{{ Form::open(array('method' => 'DELETE', 'route' => array('pays.destroy', $ligne->id))) }}
				{{ Form::submit('Supprimer', array('class' => 'btn btn-danger btn-block', 'onclick' => 'return confirm(\'Vraiment supprimer cet enregoistrement ?\')')) }}
			{{ Form::close() }}
		</td>
		</tr>
	@endforeach
@stop

Je ne rentre pas dans le détail de ce code, ni dans le template correspondant parce que ce n’est pas le sujet. Voici l’aspect de la page obtenue :

img98

La pagination est réglée à 10 enregistrements. Pour chaque ligne on peut voir le pays, le modifier ou le supprimer.

La fiche du pays

Pour voir un pays c’est la méthode show de la gestion qui est appelée :

	public function show($id)
	{
		$pays = $this->model->find($id);
		return array(
			'pays' => $pays,
			'villes' => $pays->villes,
			'auteurs' => $pays->auteurs
		);
	}

La méthode find permet de récupérer l’enregistrement à partir de son id qui a été renseigné dans la vue. Ensuite on prévoit les informations nécessaires pour la fiche du pays :

  • évidemment les champs du pays $pays
  • les villes du pays récupérées avec $pays->villes (méthode hasMany du modèle)
  • les auteurs du pays récupérés avec $pays->auteurs(méthode hasManyThrough du modèle)

La vue récupère ces informations :

@extends ('Commun.templates.fiche')

@section('titre')
	<h1>Fiche de pays</h1>
@stop

@section('contenu')
	{{ HTML::bootpanel('Nom du  pays', $pays->nom) }}
	{{ HTML::bootpanelmulti('Villes', $villes, 'nom') }}
	{{ HTML::bootpanelmulti('Auteurs', $auteurs, 'nom') }}
@stop

L’utilisation de macros (situées dans le fichier app/macros.php) permet de rendre le code simple et lisible. Résultat obtenu :

img99

Modifier un pays

La modification d’un pays est simple parce que ça n’impacte pas la relation avec les villes. Au niveau de la gestion on se contente de récupérer l’enregistrement :

	public function edit($id)
	{
		return array(
			'pays' => $this->model->find($id),
		);
	}

Et dans la vue on affiche le seul champ disponible (toujours avec les macros) :

@extends ('Commun.templates.form')

@section('formulaire')
	{{ Form::open(array('url' => 'pays/'.$pays->id, 'method' => 'put', 'class' => 'form-horizontal panel')) }}	
   	@include ('commun.templates.messages')
   	{{ Form::bootlegend('Modification du pays') }}
	  {{ Form::boottext('nom', 'Nom :', $pays->nom) }}
	  {{ Form::bootbuttons(url('pays')) }}
	{{ Form::close() }}
@stop

Ce qui donne cet aspect :

img01

Au retour on teste la validité, on sauvegarde avec la méthode save et on retourne :

	public function update($id)
	{
		if($this->validation->with(Input::all())->passes())	{
			$pays = $this->model->find($id);
			$pays->nom = Input::get('nom');
			return $pays->save();
		}
		return $this->validation->errors();
	}

La redirection a lieu dans le contrôleur de base :

	public function update($id)
	{
		$return = $this->gestion->update($id);
		if($return === true) {
			return Redirect::route($this->base.'.index')->with('message_success', $this->message_update);
		}
		return Redirect::route($this->base.'.edit', $id)->withInput()->withErrors($return);
	}

La création est calquée sur ce modèle, je ne la présente donc pas.

Détruire un pays

N’ayez pas peur, rien de belliqueux là dedans. Étant donné qu’il y a dans la table des villes une clé étrangère avec l’identifiant du pays (en fait autant de clés que de pays en relation), on ne va pas supprimer un pays sans précaution, au risque d’avoir une clé étrangère qui ne se réfère à plus rien du tout. Un petit schéma pour bien voir ça :

img02

Si je ne veux pas que le champ pays_id dans la table villes devienne orpheline j’ai le choix :

  • je peux demander à MySQL (ou autre) de faire une suppression en cascade. Dans ce cas si je supprime le pays, les villes sont aussi automatiquement supprimées dans la base (et ainsi de suite s’il y a une autre cascade). C’est la méthode radicale qu’en général on évite.
  • je peux demander à MySQL de m’interdire la suppression du pays. Du coup si je le fais je reçois une erreur de sa part. C’est l’attitude par défaut, et c’est celle que j’ai laissée.

Du coup dans le traitement de la suppression je vais vérifier s’il existe encore au moins une ville en relation :

	public function destroy($id)
	{
		$pays = $this->model->find($id);
		if($pays->villes->count() == 0)
		{
			$pays->delete();
		} else {
			Session::flash('message_danger', 'Ce pays ne peut pas être supprimé parce qu\'il possède des villes !');
		}
	}

S’il y a au moins une ville j’envoie un message, sinon je supprime le pays.

Gestion des villes

Voyons à présent la gestion des villes. Il y a pas mal de similitude avec celle des pays, je vais donc m’attacher à montrer les particularités.

Au niveau des relations on a une double liaison :

img03

Un ville appartient à un pays (relation 1:n vue du côté n) et une ville possède plusieurs auteurs (relation 1:n vue du côté 1). Qu’est-ce que cela implique ?

Pour l’affichage de la liste il n’y a aucune différence avec les pays. Pour la suppression c’est pareil, on va faire attention parce que la table auteurs possède la clé étrangère ville_id. Je ne reviens donc pas sur ces deux points, il suffit de vous reporter à la gestion des pays vue ci-dessus.

La fiche de la ville

Dans la fiche de la ville on va afficher le pays auquel elle appartient et tous les auteurs qui l’habitent. Ce qui donne ce code de gestion :

	public function show($id)
	{
		$ville = $this->model->find($id);
		return array(
			'ville' => $ville,
			'pays' => $ville->pays->nom,
			'auteurs' => $ville->auteurs
		);
	}

Et cette vue :

@extends ('Commun.templates.fiche')

@section('titre')
	<h1>Fiche de ville</h1>
@stop

@section('contenu')
	{{ HTML::bootpanel('Nom de la ville', $ville->nom) }}
	{{ HTML::bootpanel('Nom du pays', $pays) }}
	{{ HTML::bootpanelmulti('Auteurs', $auteurs, 'nom') }}
@stop

Et cet aspect :

img04

Modifier une ville

La table ville comporte deux champs :

  • nom : c’est le nom de la ville
  • pays_id : c’est la clé étrangère qui se réfère au pays

Il faut donc permettre de modifier ces deux valeurs. pour le nom c’est simple, un simple contrôle de texte fait l’affaire. Pour le pays c’est plus délicat, il faut proposer un choix parmi tous les pays, donc prévoir une liste de choix avec tous les noms des pays. Voici la gestion :

	public function edit($id)
	{
		$ville = $this->model->find($id);
		return array(
			'ville' => $ville,
			'select' => $this->pays->all()->lists('nom', 'id')
		);
	}

La variable $ville contiendra les informations de la table villes et la variables $select contiendra tous les pays avec leur nom et leur id. Avec les macros la vue est épurée :

@extends ('Commun.templates.form')

@section('formulaire')
	{{ Form::open(array('url' => 'villes/'.$ville->id, 'method' => 'put', 'class' => 'form-horizontal panel')) }}	
   	@include ('commun.templates.messages')
   	{{ Form::bootlegend('Modification de la ville') }}
	  {{ Form::boottext('nom', 'Nom :', $ville->nom) }}
	  {{ Form::bootselect('pays_id', 'Pays :', $select, $ville->pays_id) }}
	  {{ Form::bootbuttons(url('villes')) }}
	{{ Form::close() }}
@stop

On obtient ce formulaire avec le nom de la ville et la liste de choix avec le bon pays sélectionné :

img05

Au retour on teste la validité et on enregistre :

	public function update($id)
	{
		if($this->validation->with(Input::all())->passes())
		{
			$ville = $this->model->find($id);
			$ville->nom = Input::get('nom');
			$ville->pays_id = Input::get('pays_id');
			return $ville->save();
		}
		return $this->validation->errors();
	}

Créer une ville

Pour créer une ville c’est pratiquement la même chose pour le formulaire à part que le nom est vierge et qu’au retour il faut créer une nouvelle entitée :

	public function store()
	{
		if($this->validation->with(Input::all())->passes())	{
			$ville = new $this->model(Input::all());
			return $ville->save();
		}
		return $this->validation->errors();
	}

Gestion des livres

Avec la gestion des livres on attaque un gros morceau. Voici les 3 relations :

img06

  • avec les éditeurs et les auto-éditeurs on a une relation polymorphique de type 1:n vue du côté n
  • avec les thèmes on a une relation de type 1:n vue du côté n
  • avec les auteurs on a une relation de type n:n

Nous avons déjà vu le deuxième cas avec les villes, le traitement sera donc identique. par contre nous avons deux nouveaux cas à analyser attentivement.

Pour la relation avec les auteurs de type n:n on sait qu’il nous faut une table pivot, ici c’est auteur_livre. On part du principe qu’un livre à au moins un auteur et une quantité maximale indéterminée d’auteurs.

Pour la relation polymorphique on sait qu’un livre appartient soit à un éditeur, soit à un auto-éditeur.

La fiche du livre

La fiche du livre doit comporter :

  • le titre du livre
  • le thème
  • le nom de l’éditeur ou l’auto-éditeur
  • les noms des auteurs

Voici la méthode show :

	public function show($id)
	{
		$livre = $this->model->find($id);
		return array(
			'livre' => $livre,
			'auteurs' => $livre->auteurs,
			'theme' => $livre->theme->nom,
			'editeur' => $livre->livrable->nom
		);
	}

On a 4 variables transmises pour les 4 informations nécessaires. Voici la vue :

@extends ('Commun.templates.fiche')

@section('titre')
	<h1>Fiche de livre</h1>
@stop

@section('contenu')
	{{ HTML::bootpanel('Titre du livre', $livre->titre) }}
	{{ HTML::bootpanel('Thème', $theme) }}
	{{ HTML::bootpanel('Editeur', $editeur) }}
	{{ HTML::bootpanelmulti('Auteurs', $auteurs, 'nom') }}
@stop

Et le résultat :

img07

Vous remarquez comment Eloquent permet de faire ça avec simplicité.

Modifier un livre

Maintenant envisageons la modification d’un livre. Cette fois je vais partir à l’envers. On veut le formulaire suivant :

img08

Avec ces possibilités :

  • pour le titre un simple contrôle de texte
  • pour le thème une liste de choix avec tous les thèmes et au départ le bon thème sélectionné
  • pour les auteurs au départ autant de listes de choix que d’auteurs avec le bon auteur chaque fois sélectionné, un bouton de suppression pour chaque auteur et un bouton pour ajouter un auteur, on aura donc là un traitement dynamique du formulaire
  • pour les éditeurs deux boutons « radio » pour choisir entre les deux possibilités avec le bon bouton sélectionné au départ, et une liste de choix avec selon le bouton radio sélectionné les éditeurs ou les auto-éditeurs, avec évidemment le bon sélectionné au départ

Voici la gestion correspondante :

	public function edit($id)
	{
		$old = Input::old();
		$livre = $this->model->find($id);
		if(empty($old))
		{
			$livrable = $livre->livrable_type == 'Lib\Editeurs\Editeur';
		} else {
			$livrable = $old['livrable'] == 'Lib\Editeurs\Editeur';
		}
		$retour = array(
			'livre' => $livre,
			'select_theme' => $this->theme->all()->lists('nom', 'id'),
			'select_auteurs' => $this->auteur->all()->lists('nom', 'id'),
			'select_editeurs' => $this->editeur->all()->lists('nom', 'id'),
			'select_autoedites' => $this->autoedite->all()->lists('nom', 'id'),
			'livrable' => $livrable
		);		
		$retour['auteurs'] = empty($old) ? $livre->auteurs->toArray(): $old['auteur'];
		return $retour;
	}

On va regarder tout ça de près. Déjà au niveau des informations transmises :

  • pour tous les champs du livre c’est tout simple, on les a directement dans le modèle
  • pour remplir les listes des thèmes, des auteurs, des éditeurs et des auto-éditeurs on envoie les 4 paquets avec :
    			'select_theme' => $this->theme->all()->lists('nom', 'id'),
    			'select_auteurs' => $this->auteur->all()->lists('nom', 'id'),
    			'select_editeurs' => $this->editeur->all()->lists('nom', 'id'),
    			'select_autoedites' => $this->autoedite->all()->lists('nom', 'id'),
  • pour savoir si c’est éditeur ou auto-éditeur il y a plusieurs façons de faire. J’ai choisi de transmettre une valeur booléenne qui indique qu’il s’agit des éditeurs si elle est vraie et évidemment l’inverse si elle est fausse
  • pour les auteurs on les trouve facilement avec la relation $livre->auteurs

Le traitement spécifique des auteurs et des éditeurs est nécessaire en cas d’échec de validation. Il faut dans ce cas renvoyer à l’utilisateur ses choix en générant les bons contrôles. C’est la raison du test de présence d’anciennes entrées qui servent de référence dans ce cas.

La vue est évidemment un peu chargée pour traiter tout ça :

@extends ('Commun.templates.form')

@section('formulaire')
	{{ Form::open(array('url' => 'livres/'.$livre->id, 'method' => 'put', 'class' => 'form-horizontal panel')) }}	
   	@include ('commun.templates.messages')
   	{{ Form::bootlegend('Modification du livre') }}<br>
	  {{ Form::boottext('titre', 'Titre :', $livre->titre) }}<br>
	  {{ Form::bootselect('theme_id', 'Theme :', $select_theme, $livre->theme_id) }}<br>
	  @for ($i = 0; $i < count($auteurs); $i++)
	  	{{ Form::bootselectbutton('auteur', $i, 'Auteur :', $select_auteurs, $auteurs[$i]) }}				  	
	  @endfor
	  <div class="row">
	  	<button id="add" type="button" class="btn btn-primary pull-right">Ajouter un auteur</button>
	  </div>
	  <br>
	  <div class="row form-group">
		  <label class="radio-inline">
		  	{{ Form::radio('livrable', 'Lib\Editeurs\Editeur', $livrable) }} Editeur
			</label>
			<label class="radio-inline">
				{{ Form::radio('livrable', 'Lib\Autoedites\Autoedite', !$livrable) }} Auto Editeur
			</label>
		</div>
		<div class="toggle {{ $livrable? 'show' : 'hidden' }}">
	  	{{ Form::bootselect('editeur_id', 'Editeur :', $select_editeurs, $livre->livrable_id) }}
	  </div>
	  <div class="toggle {{ $livrable? 'hidden' : 'show' }}">
	  	{{ Form::bootselect('autoedite_id', 'Auto Editeur :', $select_autoedites, $livre->livrable_id) }}
	  </div>
	  <br><hr>
	  {{ Form::bootbuttons(url('livres')) }}
	{{ Form::close() }}
@stop

@section('scripts')
	<script>
		$(function(){
			// Suppression d'une ligne d'auteurs
			$(".btn-danger").click(function() {
				// On supprime la ligne s'il en reste au moins 2
				if($('.ligne').length > 1) $(this).parents('.row .ligne').remove();	
			});

			// Ajout d'une ligne d'auteurs
			$("#add").click(function() {
				// Recherche dernier id
				var max = id = 0;
				$('.ligne').each(function(){
					id = parseInt(($(this).attr('id')).substring(11));
					if(id > max) max = id;
				});
				// Première ligne
				var clone = $('#ligneauteur' + max).clone(true);
				// Change l'id
				clone.attr('id', 'ligneauteur' + ++max);
				// Change le for du label 
				clone.find('label').attr('for', 'auteur' + max);
				// Change l'id du select
				clone.find('select').attr('id', 'auteur' + max);
				// Ajoute la ligne à la fin
				$('#ligneauteur' + id).after(clone);			
			});

			// Changement editeur/auto editeur
			$('input[type="radio"]').change(function () {
				$('.toggle').toggleClass('show hidden');
			});

		})
	</script>
@stop

Je ne détaille pas tout ce code et il est sans doute améliorable, je n’ai pas chercher à l’optimiser. Je précise juste que je gère l’aspect dynamique avec jQuery en clonant le contrôle des auteurs puisqu’il doit en rester au moins un et en jouant avec des classes pour les éditeurs.

Le traitement au retour est assez facile avec Eloquent :

	public function update($id)
	{
		if($this->validation->with(Input::all())->passes())
		{
			$livre = $this->model->find($id);	
			DB::transaction(function() use($livre)
			{		
				$livre->auteurs()->sync(Input::get('auteur'));
				$livre->titre = Input::get('titre');
				$livre->theme_id = Input::get('theme_id');
				$type = Input::get('livrable');
				$livre->livrable_type = $type;
				$livre->livrable_id = ($type == 'Lib\Editeurs\Editeur') ? Input::get('editeur_id') : Input::get('autoedite_id');
				$livre->save();
			});
			return true;
		} 
		return $this->validation->errors();
	}

Notez la synchronisation élégante de la table pivot auteur_livre avec cette simple ligne de code :

$livre->auteurs()->sync(Input::get('auteur'));

Transaction ?

Vous avez sans doute remarqué que j’ai placé le code de la mise à jour du livre dans une fonction anonyme pour la transaction. Mais c’est quoi une transaction ? Dans la mise à jour du livre on fait deux choses :

  • on met à jour la table pivot pour référencer les auteurs
  • on met à jour la table des livres

Imaginez que la première action se fasse et pas la seconde. On aurait la moitié de ce que l’on veut qui sera exécuté. Dans le cas présent ça ne pose aucun problème au niveau intégrité de la base mais ce n’est quand même pas une situation souhaitable parce qu’on ne sait plus où on en est. L’attitude dans ce cas est de dire : on fait tout ou on fait rien. C’est l’objet d’une transaction. Le fait d’inclure les deux actions dans la transaction nous assure qu’elles seront soit exécutées toutes les deux, soit aucune des deux, mais pas à moitié.

Création d’un livre

Pour créer un livre le formulaire est identique, il est juste non renseigné au départ. Je ne détaille donc pas cette partie du code. La seule chose intéressant à noter est la manière de créer les lignes dans la table pivot. Voici la méthode store :

	public function store()
	{
		if($this->validation->with(Input::all())->passes())
		{				
			$livre = new $this->model;
			$livre->theme_id = Input::get('theme_id');
			$livre->titre = Input::get('titre');
			$type = Input::get('livrable');
			$livre->livrable_type = $type;
			$livre->livrable_id = ($type == 'Lib\Editeurs\Editeur') ? Input::get('editeur_id') : Input::get('autoedite_id');
			DB::transaction(function() use($livre)
			{
				$livre->save();
				$auteurs = array_unique(Input::get('auteur'));
				foreach($auteurs as $auteur_id)
				{
					$livre->auteurs()->attach($auteur_id);
				}
			});				
			return true;
		}
		return $this->validation->errors();
	}

Remarquez l’utilisation de la méthode attach pour ajouter les lignes dans la table pivot. Il faut encore utiliser une transaction parce qu’on pourrait se retrouver avec un livre sans auteur.

Destruction d’un livre

Pour détruire un livre il n’y a pas de précaution particulière à prendre si ce n’est de détruire également les lignes de la table pivot :

	public function destroy($id)
	{
		$livre = $this->model->find($id);
		DB::transaction(function() use($livre)
		{
			$livre->auteurs()->detach();
			$livre->delete();
		});
	}

Cela se fait avec la méthode detach sans paramètre pour les effacer tous. il faut le faire avant la suppression du livre pour respecter l’intégrité référentielle de la base et ne pas tomber sur une erreur. On aurait pu aussi dire à MySQL de faire une suppression en cascade, mais c’est toujours un peu risqué. On utilise encore une transaction puisque nous avons deux actions.

Gestion des éditeurs

On va souffler un peu avec les éditeurs qui ne présentent pas de difficulté si ce n’est une relation de type 1:1 avec les contacts. et une relation polymorphique avec les livres :

img09

Pour renseigner la fiche des éditeurs il faut aller récupérer le téléphone dans la table de contacts. On va chercher aussi les livres de l’éditeur sélectionné pour les afficher :

	public function show($id)
	{
		$editeur = $this->model->find($id);
		return array(
			'editeur' => $editeur,
			'livres' => $editeur->livres,
			'contact' => $editeur->contact->telephone
		);
	}

De la même façon pour la modification d’un éditeur il faut aller chercher le téléphone :

	public function edit($id)
	{
		$editeur = $this->model->find($id);
		return array(
			'editeur' => $editeur,
			'telephone' => $editeur->contact->telephone
		);
	}

Au retour on doit mettre à jour les deux tables :

	public function update($id)
	{
		if($this->validation->with(Input::all())->passes())
		{
			$editeur = $this->model->find($id);
			$editeur->nom = Input::get('nom');
			$editeur->contact->telephone = Input::get('telephone');
			DB::transaction(function() use($editeur)
			{	
				$editeur->contact->save();
				$editeur->save(); 
			});
			return true;
		}
		return $this->validation->errors();
	}

Encore une fois une transaction est nécessaire.

Pour créer un nouvel éditeur on a ce code :

	public function store()
	{
		if($this->validation->with(Input::all())->passes())
		{
			$editeur = new $this->model;
			$editeur->nom = Input::get('nom');
			$contact = new $this->contact;
			$contact->telephone = Input::get('telephone');
			DB::transaction(function() use($editeur, $contact)
			{	
				$editeur->save();
				$editeur->contact()->save($contact);
			});
			return true; 
		}
		return $this->validation->errors();
	}

Remarquez qu’on a pas besoin de renseigner la clé étrangère editeur_id, Eloquent s’en charge pour nous.

Gestion des thèmes

Voyons à présent la gestion des thèmes. Voyons la situation :

img10

La table des thèmes possède une triple relation (une de type 1:n et deux polymorphiques de type n:n) :

  • hasMany avec la table des livres
  • morphedByMany avec la table des catégories
  • morphedByMany avec la table des périodes

Comme on l’a vu dans le précédent article la polymorphie de type n:n remplace plusieurs relation de type n:n. Au lieu d’avoir autant de tables pivots que de relation on en a une seule qui les regroupe. L’identification se fait avec le nom du modèle en relation en plus des deux id. Un thème peut donc être en relation avec 0 ou plusieurs catégories et avec 0 ou plusieurs périodes.

La fiche du thème

Que nous faut-il comme information pour la fiche d’un thème ? Voici la gestion correspondante :

	public function show($id)
	{
		$theme = $this->model->find($id);
		return array(
			'theme' => $theme,
			'livres' => $theme->livres,
			'categories' => $theme->categories,
			'periodes' => $theme->periodes
		);
	}

On récupère : le thème sélectionné, les livres, les catégories et les périodes. Au niveau de la vue on procède toujours avec les macros :

@extends ('Commun.templates.fiche')

@section('titre')
	<h1>Fiche de thème</h1>
@stop

@section('contenu')
	{{ HTML::bootpanel('Nom du thème', $theme->nom) }}
	{{ HTML::bootpanelmulti('Catégories', $categories, 'nom') }}
	{{ HTML::bootpanelmulti('Périodes', $periodes, 'nom') }}
	{{ HTML::bootpanelmulti('Livres', $livres, 'titre') }}
@stop

Dans ce cas on a 3 panneaux multiples :

img11

La modification d’un thème

Voici le formulaire qu’on le veut :

img12

Trois parties :

  • pour le nom un simple contrôle de texte
  • pour les catégories des listes de choix avec traitement dynamique (de 0 à n listes)
  • pour les périodes des listes de choix avec traitement dynamique (de 0 à n listes)

Voici le code qui renseigne ce formulaire :

	public function edit($id)
	{
		$theme = $this->model->find($id);
		$retour = array(
			'theme' => $theme,
			'select_categories' => $this->categorie->all()->lists('nom', 'id'),
			'select_periodes' => $this->periode->all()->lists('nom', 'id')
		);		
		$old = Input::old();
		$retour['categories'] = empty($old) ? $theme->categories->toArray(): $old['categorie'];
		$retour['periodes'] = empty($old) ? $theme->periodes->toArray(): $old['periode'];
		return $retour;
	}

On prévoit d’envoyer les champs du thème ($theme) et les éléments de remplissage des listes ($select_categories et $select_periodes). Pour le nombre de listes de choix à afficher ça dépend évidemment de la situation : envoi initial du formulaire ou nouveau remplissage après erreur de validation. D’où le test de présence d’anciennes données.

La vue est un peu chargée parce qu’elle comporte le traitement dynamique des listes de choix en jQuery :

@extends ('Commun.templates.form')

@section('formulaire')
	{{ Form::open(array('url' => 'themes/'.$theme->id, 'method' => 'put', 'class' => 'form-horizontal panel')) }}	
   	@include ('commun.templates.messages')
   	{{ Form::bootlegend('Modification du thème') }}
	  {{ Form::boottext('nom', 'Nom :', $theme->nom) }}<br><hr>
	  <div id="cats">
		  @for ($i = 0; $i < count($categories); $i++)
		  	{{ Form::bootselectbutton('categorie', $i, 'Catégorie :', $select_categories, $categories[$i]) }}				  	
		  @endfor
	  </div>
	  <div class="row">
	  	<button id="add_cat" type="button" class="btn btn-primary pull-right">Ajouter une catégorie</button>
	  </div>
	  <br><hr>
	  <div id="pers">
		  @for ($i = 0; $i < count($periodes); $i++)
		  	{{ Form::bootselectbutton('periode', $i, 'Période :', $select_periodes, $periodes[$i]) }}				  	
		  @endfor
	  </div>
	  <div class="row">
	  	<button id="add_per" type="button" class="btn btn-primary pull-right">Ajouter une période</button>
	  </div>
	  <br><hr>
	  {{ Form::bootbuttons(url('themes')) }}
	{{ Form::close() }}
@stop

@section('scripts')
	<script>

		$(function(){		

			// Nombre de catégories et périodes au départ
			var cat_number = $('#cats .ligne').length;
			var per_number = $('#pers .ligne').length; 

			// Suppression d'une ligne
			$(document).on('click', '.btn-danger', function(){ 
				$(this).parents('.row .ligne').remove();	
			});

			// Ajout d'une ligne de catégorie
			$("#add_cat").click(function() {
				var id = 'categorie' + cat_number;
				var html = '<div class="row ligne" id="ligne' + id + '">\n<div class="form-group">\n'
				+ '<label for="categorie' + cat_number + '" class="col-md-3">Catégorie :</label>\n'
				+ '<div class="col-md-7">\n'
				+ '{{ Form::select('categorie[]', $select_categories, null, array('class' => 'form-control', 'id' => 'id_temp')) }}\n'
				+ '</div>\n<div class="col-md-2">\n<button type="button" class="btn btn-danger">Supprimer</button>\n</div>\n</div>\n';
				++cat_number;
				$('#cats').append(html);	
				$('#id_temp').attr('id', id);	
			});

			// Ajout d'une ligne de période
			$("#add_per").click(function() {
				var id = 'periode' + per_number;
				var html = '<div class="row ligne" id="ligne' + id + '">\n<div class="form-group">\n'
				+ '<label for="periode' + per_number + '" class="col-md-3">Période :</label>\n'
				+ '<div class="col-md-7">\n'
				+ '{{ Form::select('periode[]', $select_periodes, null, array('class' => 'form-control', 'id' => 'id_temp')) }}\n'
				+ '</div>\n<div class="col-md-2">\n<button type="button" class="btn btn-danger">Supprimer</button>\n</div>\n</div>\n';
				++per_number;
				$('#pers').append(html);	
				$('#id_temp').attr('id', id);	
			});

		})

	</script>
@stop

Là encore on peut sans doute améliorer ce code mais il accomplit dignement sa tâche. J’ai utilisé la méthode select de Form pour générer une partie du javascript. Au retour il faut mettre à jour l’enregistrement :

	public function update($id)
	{
		if($this->validation->with(Input::all())->passes())
		{
			$theme = $this->model->find($id);
			DB::transaction(function() use($theme)
			{	
				$theme->categories()->sync(Input::get('categorie'));
				$theme->periodes()->sync(Input::get('periode'));
				$theme->nom = Input::get('nom');
				$theme->save();
			});
			return true;
		}
		return $this->validation->errors();
	}

J’utilise à nouveau une transaction parce qu’il y a plusieurs actions. La méthode sync permet une mise à jour simplifié de la table pivot.

Créer un thème

Pour créer un thème le formulaire est le même que pour la mise à jour sans le renseignement initial. On a juste besoin de remplir les listes de choix :

	public function create(){
		return array(
			'select_categories' => $this->categorie->all()->lists('nom', 'id'),
			'select_periodes' => $this->periode->all()->lists('nom', 'id')
		);  	
	}

Au retour il faut créer le thème :

	public function store()
	{
		if($this->validation->with(Input::all())->passes())
		{
			$theme = new $this->model;
			$theme->nom = Input::get('nom');
			DB::transaction(function() use($theme)
			{	
				$theme->save();
				if(!is_null(Input::get('categorie'))) $theme->categories()->attach(array_unique(Input::get('categorie')));
				if(!is_null(Input::get('periode'))) $theme->periodes()->attach(array_unique(Input::get('periode')));
			});
			return true; 
		}
		return $this->validation->errors();
	}

Évidemment encore une transaction. La création des lignes dans la table pivot se fait simplement avec la méthode attach.

Supprimer un thème

Pour supprimer un thème il faut évidemment supprimer les lignes correspondantes dans la table pivot :

	public function destroy($id)
	{
		$theme = $this->model->find($id);
		if($theme->livres->count() == 0) {
			DB::transaction(function() use($theme)
			{	
				$theme->categories()->detach();
				$theme->periodes()->detach();
				$theme->delete();
			});
		} else {
			Session::flash('message_danger', 'Ce thème ne peut pas être supprimé parce qu\'il possède des livres !');
		}
	}

On voit que ça se fait facilement avec la méthode detach qui est l’opposé de la méthode attach vue pour la création. Le fait de ne pas mentionner de paramètre signifie qu’on veut tout supprimer (oui il faut le savoir, c’est pas précisé dans la documentation).

Conclusion

Je n’ai pas évoqué tout le code mais je me suis attaché à présenter l’essentiel. Vous pouvez analyser le reste. Il doit forcément traîner des bugs, merci de me les signaler. D’autre part j’ai fait un certain nombre de choix de codage qui ne sont pas forcément les meilleurs et il y a sans doute de nombreuse voies d’amélioration. Je me suis posé également pas mal de questions et certaines restent un peu en suspend. Mon seul objectif était de présenter la gestion des relations avec Eloquent avec un panorama assez complet. J’espère y être parvenu.

Print Friendly, PDF & Email

15 commentaires

  • GrCOTE7

    Après avoir approfondi la question : strtolower() !
    (Car bien que sous Win, mais utilisant xampp, donc simulant un système linux, il semblerait que la notion de casse des caractères influe le script…)

    Soit, dans commun/BaseRessourceController.php :
    et + précisément dans:

    public function store()
    if ($return === TRUE) {
    return Redirect::route(strtolower($this->base) . '.index')->with('message_success', $this->message_store);
    }
    return Redirect::route(strtolower($this->base) $this->base . '.create')->withInput()->withErrors($return);

    Et là, le 'completExemple' de BestMomo fonctionne à 100% ! (y)
    }

    Encore merci, et juste, comme Laravel 5 pointe son nez, j’espère seulement que BestMomo sera encore prolifique… 🙂

  • GrCOTE7

    (Zut, g mer… avec les touches, du coup forcé de finir dans ce nouveau thread)

    Je disais donc…:
    En tout cas, comme MisTinuX, Macoune ou encore Catalyst, j’ai suivi l’ensemble des Tutos de ce Blog… Et chapeau bas: D’une excellente qualité, et de quoi déclencher des vocations chez plusieurs 🙂 BRAVO !

  • GrCOTE7

    Bravo, BestMomo,

    Vraiment un excellent tuto une fois de plus 🙂

    Juste, par contre, bon newbee avec un FrameWork, mais pensant tout de même avoir bien ‘user’ de composer pour le présent tuto, apportant la gestion des models du précédent, l’install de ce jour (16/12/14) presente un dysfonctionnement à la sortie de chaque pression de chaque cas sur le bouton ‘Valider’
    (Ex. Ajouter un Auteur)
    On a alors un problème de route…
    (Dans notre exemple: InvalidArgumentException: Route [Auteurs.index] not defined.)

    Comme il est commun à toutes les parties (y compris les update de Livre par exemple), je présume que l’erreur vient de la méthode update($id) de commun/BaseResourceController.php
    et plus particulièrement ici:

    return Redirect::route($this->base.’.index’)->with(…

    (Et qu’elle est dûe aussi que le composer update est susceptible d’importer un module dont des récents changements entraîne maintenant ce soucis dans ton Appli (Par exemple peut-être au niveau du système de route par rapport à la méthode ‘ressource’…?) )

    5 minutes pour ‘replanter’ une app laravel et ses magiques migrations + seed + config/app pour looker…? Auycquel cas, BestMomo sufffira plus ! Lol, Faudra penser aà un truc style MaxiTopMomo ! 🙂

    En tout ca

  • Ktalyst

    Alors, j’ai trouvé la solution mais pour l’expliquer ce serait un peu compliqué. Mais en résumé il suffit de rajouter un peu de Javascript pour pouvoir ajouter plusieurs adresses, et de les mettre dans un tableau. Après tout se joue du côté du contrôleur. Dès que j’aurai fini mon stage, je créerai un blog pour expliquer tout cela en détail 🙂

  • Ktalyst

    De supers tutos qui m’ont beaucoup aidée ! Bravo !
    Mais malheureusement, je reste encore bloqué sur un détail, et malgré de nombreuses nuits à ne pas dormir, je n’ai pas encore trouvé la solution.
    Je souhaite dans un même formulaire, créer deux entités différentes reliées par une relation one-to-many. Par exemple, un contact ayant plusieurs adresses.
    Je me retrouve bloquée car pour ce type de relations, il n’y a pas de « super » formule comme « sync ».
    Avez vous une idée ?
    Et encore merci 🙂

    • bestmomo

      Eloquent a des méthode « magiques » pour les tables pivots et pour quelques actions sur des données liées, par exemple pour renseigner automatiquement la clé étrangère. Mais pour le reste il faut se retrousser les manches et utiliser les modèles séparément 🙂

        • bestmomo

          Il est difficile de répondre sur une question aussi générale. Si tu veux dans le formulaire pouvoir sélectionner la ligne côté « un » (un contact) de la relation dans une liste et obtenir les adresses correspondantes ça implique une actualisation qu’il faut gérer en Ajax. Quant à la mise à jour il faut la traiter séparément pour les deux tables.

  • bestmomo

    Finalement j’ai l’explication de tes déboires avec le PSR-0, c’est parce que je ne me suis pas soucié des majuscules dans le code parce que je travaille sous Windows, mais Linux est sensible à la casse. J’ai mis le code à jour dans l’article et dans le fichier à télécharger, maintenant il ne devrait plus y avoir de problème 😉 .

  • macoune

    Un grand merci pour ces tutos, cela m’a donné envie d’utiliser Laravel pour mes différents projets. Ceci est aussi une excellente explication du modèle MVC.

    J’ai suivi ce dernier tuto, en téléchargeant le fichier et en installant le site sur une fresh install Ubuntu 12.04/PHP5.3.10/Laravel 4.1, et j’ai un problème avec les routes ( Class Lib\Villes\VilleController does not exist lorsque je tente d’accéder à site.com/villes).
    Le paramètre psr-0 est bien présent dans le fichier composer.json :
    "psr-0": {
    "Lib\\": "app"
    }

    Et j’ai bien sûr fait un « composer dump-autoload ».
    Je n’ai pas touché au code, et je sèche sur les raisons de ce problème.
    Avez-vous une idée de où cela peut venir ?

    Merci encore !!

  • MysTinuX

    J’ai terminé de lire vos différents tuto.
    Je tenais tout spécialement à vous féliciter pour le travail accompli, et pour le partage.
    Votre site est une véritable mine d’or pour qui souhaite s’initier à Laravel.
    Je suivrais les prochains tuto avec autant d’attention.
    Merci !!!!

Laisser un commentaire