Module 3Approfondir la programmation Laravel
Chapitre 11 sur 17
Manipuler l'ORM Eloquent
Modèles, CRUD, affectation de masse, casts, portées, collections, relations et chargement anticipé.
- ≈ 235 min
- 11 leçons
- 4 vidéos
- 4 exercices
Module 3Approfondir la programmation Laravel
Chapitre 11 sur 17
Modèles, CRUD, affectation de masse, casts, portées, collections, relations et chargement anticipé.
Objectifs
Avec ce chapitre, tu sauras…
Leçon 1
| Sans ORM (SQL) | Avec Eloquent |
|---|---|
SELECT * FROM livres WHERE id = 3 | Livre::find(3) |
INSERT INTO auteurs (nom, ...) VALUES (...) | Auteur::create(['nom' => 'Leïla Slimani']) |
UPDATE livres SET prix = 59 WHERE id = 3 | $livre->update(['prix' => 59]) |
| jointure livres ↔ categories | $livre->categorie->libelle |
Leçon 2
| Option | Génère aussi |
|---|---|
-m (--migration) | la migration create_livres_table |
-f (--factory) | la fabrique LivreFactory |
-s (--seed) | le seeder LivreSeeder |
-c (--controller) | le contrôleur LivreController (avec -r : de ressource) |
--policy | la policy LivrePolicy (chapitre 12) |
Leçon 3
Leçon 4
Leçon 5
| Cast | Résultat en PHP |
|---|---|
integer, float, boolean, string | le type correspondant |
decimal:2 | une chaîne avec 2 décimales (précis pour l'argent) |
date, datetime, immutable_date | un objet Carbon (->format(), ->diffForHumans()) |
array, collection, object | une colonne JSON lue comme tableau, collection ou objet |
Langue::class (énumération) |
Leçon 6
Leçon 7
Leçon 8
| Relation | Dans le modèle qui… | Méthode |
|---|---|---|
| un à un | n'a pas la clé étrangère | hasOne(Profil::class) |
| un à plusieurs | n'a pas la clé étrangère (le « un ») | hasMany(Livre::class) |
| inverse d'un à un / un à plusieurs | a la clé étrangère | belongsTo(Categorie::class) |
| plusieurs à plusieurs |
Leçon 9
Leçon 10
Leçon 11
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
1Le modèle Auteur correspond par défaut à la table…
2Livre::create($donnees) ignore une colonne si elle…
3findOrFail(99) sur un id inexistant provoque…
4Avec SoftDeletes, $livre->delete()…
5Dans Livre, la relation vers sa catégorie est…
6$livre->auteurs()->sync([1, 4])…
7$livre->auteurs (sans parenthèses) renvoie…
8Pour éviter le problème N+1, on utilise…
L'essentiel du chapitre
app/Models), une ligne = un objet. make:model Livre -mfsc (ou -a).Livre → livres ; attention aux mots comme Avis), clé id, created_at/updated_at ; sinon $table, $primaryKey, $timestamps (ou attributs Laravel 13 #[Table]).all, get, find, findOrFail, first, firstOrFail, + tout le Query Builder ; chunk, cursor.new + save(), create() (affectation de masse protégée par $fillable / $guarded), update(), firstOrCreate, updateOrCreate, increment ; delete, destroy ; SoftDeletes (withTrashed, onlyTrashed, restore, forceDelete).casts() : integer, decimal:2, date (Carbon), array, énumération, hashed, encrypted. Accesseurs/mutateurs : Attribute::make(get:, set:), $appends. Événements dans booted().scopeNom(Builder $query) → Livre::nom() (ou #[Scope] depuis Laravel 12). Collections : count, sum, avg, pluck, filter, map, sortBy, groupBy, keyBy… ; filtrer en base, pas en PHP.-a (--all) |
| tout cela, plus les Form Requests |
| Convention | Valeur par défaut | Pour changer |
|---|---|---|
| nom de la table | le nom de la classe en snake case et au pluriel anglais : Livre → livres, Categorie → categories, LigneCommande → ligne_commandes | protected $table = 'lignes'; |
| clé primaire | colonne id, entier auto-incrémenté | protected $primaryKey = 'isbn'; + public $incrementing = false; + protected $keyType = 'string'; |
| horodatage | colonnes created_at et updated_at remplies automatiquement | public $timestamps = false; |
| connexion | celle du .env | protected $connection = 'mysql2'; |
hashed | la valeur est hachée à l'enregistrement (utilisé pour password dans User) |
encrypted | la valeur est chiffrée en base et déchiffrée à la lecture |
belongsToMany(Auteur::class) |
| à travers une autre table | ex. les livres vendus d'un client | hasManyThrough(...) |
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
Écris les méthodes de relation : (1) dans User, ses commandes ; (2) dans Commande, son client (méthode client(), colonne user_id) et ses livres avec la quantité et le prix unitaire ; (3) dans Auteur, ses livres. Puis écris le code qui : (a) affiche pour la commande $commande chaque ligne « Titre × quantité = sous-total DH » et le total ; (b) liste les auteurs d'une catégorie donnée, sans doublon.
Cette page affiche 20 commandes avec le nom du client et les titres des livres. Combien de requêtes envoie-t-elle ? Corrige-la pour qu'elle en envoie 3.
$commandes = Commande::latest()->take(20)->get();
foreach ($commandes as $commande) {
echo $commande->client->name . ' : ' . $commande->livres->pluck('titre')->join(', ');
}hasOne / hasMany (côté sans clé étrangère), belongsTo (côté avec la clé), belongsToMany (pivot auteur_livre, withPivot, $x->pivot->quantite). Propriété $livre->categorie (résultat) vs méthode $livre->auteurs() (requête). associate, attach, detach, sync, toggle ; has, doesntHave, whereHas, withCount.with([...]) / load() ; Model::preventLazyLoading() en développement.toArray, toJson, $hidden, $appends.php artisan make:model Livre
php artisan make:model Livre -mfsc # + migration, fabrique, seeder, contrôleur
php artisan make:model Livre -a # + tout : migration, fabrique, seeder, policy, contrôleur de ressource, form requestsUn modèle est aussi un Query Builder : toutes les méthodes du chapitre 10 (where, orderBy, paginate…) s'utilisent directement sur le modèle.
use App\Models\Livre;
$livres = Livre::all(); // tous les livres (Collection de Livre)
$livres = Livre::where('langue', 'fr')->orderBy('titre')->get();
$livre = Livre::find(3); // par clé primaire, ou null
$livre = Livre::findOrFail(3); // ou erreur 404
$livre = Livre::where('slug', $slug)->first(); // le premier, ou null
$livre = Livre::where('slug', $slug)->firstOrFail(); // ou erreur 404
$livres = Livre::find([1, 2, 3]); // plusieurs par clé
$nb = Livre::where('stock', 0)->count();
$max = Livre::max('prix');
$titres = Livre::pluck('titre', 'id');
$page = Livre::latest()->paginate(12);findOrFail et firstOrFail lèvent une ModelNotFoundException, que Laravel transforme automatiquement en réponse 404. Dans un contrôleur, c'est presque toujours ce qu'on veut.
Pour parcourir beaucoup d'enregistrements sans saturer la mémoire :
Livre::chunk(200, function ($livres) { // par paquets de 200
foreach ($livres as $livre) { /* ... */ }
});
foreach (Livre::cursor() as $livre) { // un par un
// ...
}Créer
// 1. instancier, remplir, enregistrer
$auteur = new Auteur();
$auteur->nom = 'Leïla Slimani';
$auteur->nationalite = 'marocaine';
$auteur->save(); // INSERT ; $auteur->id est rempli
// 2. en une ligne, par affectation de masse
$auteur = Auteur::create(['nom' => 'Abdellatif Laâbi', 'nationalite' => 'marocaine']);
// 3. chercher ou créer
$categorie = Categorie::firstOrCreate(['slug' => 'poesie'], ['libelle' => 'Poésie']);
$livre = Livre::updateOrCreate(['isbn' => $isbn], ['stock' => 12]); // modifier ou créerfirstOrCreate cherche avec le premier tableau ; s'il ne trouve rien, il crée l'enregistrement avec les deux tableaux réunis.
L'affectation de masse et $fillable
create(), update() et fill() reçoivent un tableau de valeurs : c'est l'affectation de masse (mass assignment). Par sécurité, Eloquent n'accepte que les colonnes listées dans $fillable. Sans cette protection, un utilisateur pourrait ajouter un champ role=admin au formulaire d'inscription et devenir administrateur.
class Livre extends Model
{
protected $fillable = ['isbn', 'titre', 'slug', 'resume', 'prix', 'stock', 'langue', 'couverture', 'publie_le', 'categorie_id'];
}L'inverse est $guarded : les colonnes interdites (protected $guarded = ['id', 'role'];). Une colonne absente de $fillable est ignorée silencieusement par create() : si une valeur ne s'enregistre pas, vérifie d'abord $fillable.
Modifier
$livre = Livre::findOrFail(3);
$livre->prix = 59.90;
$livre->save(); // UPDATE (updated_at est mis à jour)
$livre->update(['prix' => 59.90, 'stock' => 8]); // affectation de masse
Livre::where('categorie_id', 2)->update(['prix' => 0]); // plusieurs lignes à la fois (sans événements)
$livre->increment('stock', 5);
$livre->decrement('stock');
$livre->titre = 'Nouveau titre';
$livre->isDirty('titre'); // true : modifié mais pas encore enregistréSupprimer
$livre->delete();
Livre::destroy(3);
Livre::destroy([3, 4, 5]);
Livre::where('stock', 0)->delete();La suppression douce (soft delete)
Avec le trait SoftDeletes (et la colonne deleted_at créée par $table->softDeletes()), delete() ne supprime pas la ligne : il remplit deleted_at. Les requêtes ignorent ensuite automatiquement ces lignes. On peut les retrouver et les restaurer, comme une corbeille.
use Illuminate\Database\Eloquent\SoftDeletes;
class Livre extends Model
{
use SoftDeletes;
}
$livre->delete(); // remplit deleted_at
Livre::all(); // n'inclut plus ce livre
Livre::withTrashed()->get(); // inclut les livres supprimés
Livre::onlyTrashed()->get(); // seulement la corbeille
$livre->trashed(); // true
$livre->restore(); // restaure
$livre->forceDelete(); // supprime vraimentLes casts
Les données lues en base arrivent souvent sous forme de chaînes. Les casts les convertissent automatiquement dans le bon type, à la lecture et à l'écriture. Ils se déclarent dans la méthode casts() du modèle :
use App\Enums\Langue;
protected function casts(): array
{
return [
'prix' => 'decimal:2', // chaîne avec 2 décimales : "65.00"
'stock' => 'integer',
'publie_le' => 'date', // objet Carbon : $livre->publie_le->format('d/m/Y')
'langue' => Langue::class, // énumération : $livre->langue->libelle()
'options' => 'array', // JSON ↔ tableau PHP
];
}Une portée locale (local scope) donne un nom à une condition réutilisable. La syntaxe classique : une méthode préfixée par scope, qu'on appelle sans le préfixe.
use Illuminate\Database\Eloquent\Builder;
public function scopeDisponibles(Builder $query): void
{
$query->where('stock', '>', 0);
}
public function scopeEnLangue(Builder $query, string $langue): void
{
$query->where('langue', $langue);
}$livres = Livre::disponibles()->enLangue('ar')->orderBy('titre')->get();Depuis Laravel 12, on peut aussi marquer une méthode protégée avec l'attribut #[Scope], sans préfixe :
use Illuminate\Database\Eloquent\Attributes\Scope;
#[Scope]
protected function disponibles(Builder $query): void
{
$query->where('stock', '>', 0);
}Une portée globale s'applique à toutes les requêtes du modèle (c'est ainsi que fonctionne SoftDeletes) ; on la désactive au besoin avec withoutGlobalScope().
Une requête qui renvoie plusieurs modèles (get(), all()) renvoie une collection (Illuminate\Database\Eloquent\Collection) : un tableau amélioré avec des dizaines de méthodes.
$livres = Livre::with('categorie')->get();
$livres->count(); // nombre
$livres->sum('stock'); // somme d'une colonne
$livres->avg('prix'); // moyenne
$livres->pluck('titre'); // les titres
$livres->first(); // le premier
$livres->filter(fn ($l) => $l->stock === 0); // les épuisés
$livres->map(fn ($l) => strtoupper($l->titre)); // transformer chaque élément
$livres->sortByDesc('prix'); // trier
$livres->groupBy('langue'); // ['fr' => [...], 'ar' => [...]]
$livres->keyBy('isbn'); // indexer par ISBN
$livres->contains('isbn', '9782070612758'); // présence
$livres->isEmpty(); // vide ?
$livres->each(fn ($l) => $l->increment('stock')); // action sur chacun
$livres->take(3); // les 3 premiersLivre::all()->where('stock', 0) charge tous les livres en mémoire avant de filtrer en PHP. Livre::where('stock', 0)->get() fait filtrer la base et ne charge que les livres utiles. Utilise les méthodes de collection seulement sur des données déjà chargées.
Les tables sont liées par des clés étrangères ; Eloquent représente ces liens par des méthodes de relation dans les modèles. Les relations du diagramme de classes de Kitab (module M109) deviennent :
@startuml
class User {
name
email
role
}
class Commande {
numero
statut
total
}
class Livre {
isbn
titre
slug
prix
stock
langue
}
class Categorie {
libelle
slug
}
class Auteur {
nom
nationalite
}
User "1" -- "0..*" Commande : hasMany / belongsTo
Commande "0..*" -- "1..*" Livre : belongsToMany\n(commande_livre : quantite, prix_unitaire)
Categorie "1" -- "0..*" Livre : hasMany / belongsTo
Livre "0..*" -- "1..*" Auteur : belongsToMany\n(auteur_livre)
Categorie "0..1" -- "0..*" Categorie : parent / enfants
@endumlUn à plusieurs : hasMany et belongsTo
Une catégorie a plusieurs livres ; un livre appartient à une catégorie (la clé étrangère categorie_id est dans livres).
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Categorie extends Model
{
use HasFactory;
protected $fillable = ['libelle', 'slug', 'parent_id'];
public function livres(): HasMany
{
return $this->hasMany(Livre::class); // clé étrangère devinée : categorie_id
}
public function parent(): BelongsTo
{
return $this->belongsTo(Categorie::class, 'parent_id');
}
public function enfants(): HasMany
{
return $this->hasMany(Categorie::class, 'parent_id');
}
}public function categorie(): BelongsTo
{
return $this->belongsTo(Categorie::class); // clé étrangère devinée : categorie_id
}Eloquent devine la clé étrangère : pour hasMany, le nom du modèle parent en snake case + _id (Categorie → categorie_id) ; pour belongsTo, le nom de la méthode + _id (categorie() → categorie_id). Si la colonne a un autre nom, on la donne en deuxième argument : belongsTo(User::class, 'client_id').
Un à un : hasOne et belongsTo
Un utilisateur a un profil (une table profils avec user_id) :
// dans User
public function profil(): HasOne
{
return $this->hasOne(Profil::class);
}
// dans Profil
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}Plusieurs à plusieurs : belongsToMany
Un livre a plusieurs auteurs et un auteur plusieurs livres, avec la table pivot auteur_livre :
// dans Livre
public function auteurs(): BelongsToMany
{
return $this->belongsToMany(Auteur::class); // table pivot devinée : auteur_livre
}
// dans Auteur
public function livres(): BelongsToMany
{
return $this->belongsToMany(Livre::class);
}Quand la table pivot contient des colonnes supplémentaires (la quantité et le prix d'une ligne de commande), on les déclare avec withPivot :
// dans Commande
public function livres(): BelongsToMany
{
return $this->belongsToMany(Livre::class)->withPivot('quantite', 'prix_unitaire');
}foreach ($commande->livres as $livre) {
echo "{$livre->titre} : {$livre->pivot->quantite} × {$livre->pivot->prix_unitaire} DH";
}Un modèle se convertit en tableau ou en JSON : $livre->toArray(), $livre->toJson(), ou automatiquement quand une route le renvoie. Les relations chargées sont incluses.
class User extends Authenticatable
{
protected $hidden = ['password', 'remember_token']; // jamais dans le JSON
}
class Livre extends Model
{
protected $appends = ['prix_formate']; // ajoute l'accesseur au JSON
}<?php
namespace App\Enums;
enum Langue: string
{
case Arabe = 'ar';
case Francais = 'fr';
case Anglais = 'en';
public function libelle(): string
{
return match ($this) {
self::Arabe => 'Arabe',
self::Francais => 'Français',
self::Anglais => 'Anglais',
};
}
}<?php
namespace App\Models;
use App\Enums\Langue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Livre extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'isbn', 'titre', 'slug', 'resume', 'prix', 'stock',
'langue', 'couverture', 'publie_le', 'categorie_id',
];
protected function casts(): array
{
return [
'prix' => 'decimal:2',
'stock' => 'integer',
'publie_le' => 'date',
'langue' => Langue::class,
];
}
protected static function booted(): void
{
static::creating(function (Livre $livre) {
$livre->slug ??= Str::slug($livre->titre);
});
}
public function getRouteKeyName(): string
{
return 'slug';
}
// Relations
public function categorie(): BelongsTo
{
return $this->belongsTo(Categorie::class);
}
public function auteurs(): BelongsToMany
{
return $this->belongsToMany(Auteur::class);
}
public function commandes(): BelongsToMany
{
return $this->belongsToMany(Commande::class)->withPivot('quantite', 'prix_unitaire');
}
// Portées
public function scopeDisponibles(Builder $query): void
{
$query->where('stock', '>', 0);
}
public function scopeRecherche(Builder $query, ?string $texte): void
{
$query->when($texte, fn ($q) => $q->where(function ($q) use ($texte) {
$q->whereLike('titre', "%{$texte}%")
->orWhereHas('auteurs', fn ($a) => $a->whereLike('nom', "%{$texte}%"));
}));
}
// Accesseurs
protected function prixFormate(): Attribute
{
return Attribute::make(get: fn () => number_format((float) $this->prix, 2, ',', ' ') . ' DH');
}
protected function couvertureUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->couverture ? asset('storage/' . $this->couverture) : asset('images/sans-couverture.png'),
);
}
}<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreLivreRequest;
use App\Http\Requests\UpdateLivreRequest;
use App\Models\Auteur;
use App\Models\Categorie;
use App\Models\Livre;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class LivreController extends Controller
{
public function index(Request $request): View
{
$livres = Livre::with(['categorie', 'auteurs'])
->recherche($request->input('q'))
->orderBy('titre')
->paginate(15)
->withQueryString();
return view('admin.livres.index', compact('livres'));
}
public function create(): View
{
return view('admin.livres.create', [
'categories' => Categorie::orderBy('libelle')->get(),
'auteurs' => Auteur::orderBy('nom')->get(),
]);
}
public function store(StoreLivreRequest $request): RedirectResponse
{
$donnees = $request->safe()->except(['auteurs', 'couverture']);
if ($request->hasFile('couverture')) {
$donnees['couverture'] = $request->file('couverture')->store('couvertures', 'public');
}
DB::transaction(function () use ($donnees, $request) {
$livre = Livre::create($donnees);
$livre->auteurs()->sync($request->validated('auteurs'));
});
return to_route('admin.livres.index')->with('status', 'Livre ajouté.');
}
public function show(Livre $livre): View
{
$livre->load(['categorie', 'auteurs']);
return view('admin.livres.show', compact('livre'));
}
public function edit(Livre $livre): View
{
return view('admin.livres.edit', [
'livre' => $livre->load('auteurs'),
'categories' => Categorie::orderBy('libelle')->get(),
'auteurs' => Auteur::orderBy('nom')->get(),
]);
}
public function update(UpdateLivreRequest $request, Livre $livre): RedirectResponse
{
$donnees = $request->safe()->except(['auteurs', 'couverture']);
if ($request->hasFile('couverture')) {
if ($livre->couverture) {
Storage::disk('public')->delete($livre->couverture);
}
$donnees['couverture'] = $request->file('couverture')->store('couvertures', 'public');
}
$livre->update($donnees);
$livre->auteurs()->sync($request->validated('auteurs'));
return to_route('admin.livres.index')->with('status', 'Livre modifié.');
}
public function destroy(Livre $livre): RedirectResponse
{
$livre->delete(); // suppression douce : la couverture est gardée pour une éventuelle restauration
return to_route('admin.livres.index')->with('status', "« {$livre->titre} » a été supprimé.");
}
}$request->safe() renvoie les données validées sous une forme qui accepte only() et except() ; $request->validated('auteurs') renvoie une seule clé validée. UpdateLivreRequest reprend les règles de StoreLivreRequest, avec l'unicité de l'ISBN qui ignore le livre modifié : Rule::unique('livres', 'isbn')->ignore($this->route('livre')).
<table>
<thead><tr><th>Titre</th><th>Auteurs</th><th>Catégorie</th><th>Prix</th><th>Stock</th><th></th></tr></thead>
<tbody>
@forelse ($livres as $livre)
<tr>
<td>{{ $livre->titre }}</td>
<td>{{ $livre->auteurs->pluck('nom')->join(', ') }}</td>
<td>{{ $livre->categorie->libelle }}</td>
<td>{{ $livre->prix_formate }}</td>
<td>{{ $livre->stock }}</td>
<td>
<a href="{{ route('admin.livres.edit', $livre) }}">Modifier</a>
<form method="POST" action="{{ route('admin.livres.destroy', $livre) }}" onsubmit="return confirm('Supprimer ce livre ?')">
@csrf
@method('DELETE')
<button type="submit">Supprimer</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="6">Aucun livre.</td></tr>
@endforelse
</tbody>
</table>
{{ $livres->links() }}<form method="POST" action="{{ route('admin.livres.update', $livre) }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<label for="categorie_id">Catégorie</label>
<select id="categorie_id" name="categorie_id">
@foreach ($categories as $categorie)
<option value="{{ $categorie->id }}" @selected(old('categorie_id', $livre->categorie_id) == $categorie->id)>
{{ $categorie->libelle }}
</option>
@endforeach
</select>
<fieldset>
<legend>Auteurs</legend>
@foreach ($auteurs as $auteur)
<label>
<input type="checkbox" name="auteurs[]" value="{{ $auteur->id }}"
@checked(in_array($auteur->id, old('auteurs', $livre->auteurs->pluck('id')->all())))>
{{ $auteur->nom }}
</label>
@endforeach
</fieldset>
@error('auteurs') <p class="message-erreur">{{ $message }}</p> @enderror
<button type="submit">Enregistrer</button>
</form>Le nom auteurs[] envoie un tableau d'identifiants : c'est exactement ce qu'attend sync().
Premiers pas avec Eloquent : modèles, lecture et création.
(1) Crée les modèles Categorie, Auteur, Livre, Commande (avec relations, $fillable, casts) et l'énumération Langue. (2) Crée les fabriques CategorieFactory, AuteurFactory, LivreFactory et un seeder qui crée 6 catégories, 20 auteurs et 60 livres ayant chacun 1 à 3 auteurs. (3) Réécris CatalogueController avec Eloquent : liste paginée (12) avec recherche et filtre par catégorie, fiche par slug avec liaison implicite. (4) Termine le CRUD Admin\LivreController de la section 10. (5) Active preventLazyLoading et corrige les erreurs N+1 éventuelles.
Écris chaque requête avec Eloquent : (1) les 10 livres les plus chers en arabe ; (2) le livre d'ISBN 9782070612758 ou une 404 ; (3) créer l'auteur « Mohamed Choukri » (marocain) ; (4) augmenter de 10 % le prix des livres de la catégorie romans (slug) ; (5) le nombre de livres de chaque catégorie, triées du plus grand au plus petit ; (6) les livres sans aucun auteur ; (7) restaurer tous les livres supprimés aujourd'hui.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Auteur extends Model
{
use HasFactory;
protected $fillable = ['nom', 'nationalite', 'date_naissance'];
}Tableau: Les conventions d'Eloquent
Laravel 13 permet aussi d'écrire cette configuration avec des attributs PHP sur la classe : #[Table('lignes')], #[Table(key: 'isbn', keyType: 'string', incrementing: false)], #[Fillable(['nom', 'nationalite'])]. Les propriétés classiques ($table, $fillable) restent valables dans toutes les versions : ce cours les utilise.
Les accesseurs et mutateurs
Un accesseur transforme une valeur à la lecture ; un mutateur la transforme à l'écriture. On les déclare avec une méthode protégée qui renvoie un objet Attribute. Le nom de la méthode en camelCase correspond à l'attribut en snake case.
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Str;
/** Accesseur calculé : $livre->prix_formate */
protected function prixFormate(): Attribute
{
return Attribute::make(
get: fn () => number_format((float) $this->prix, 2, ',', ' ') . ' DH',
);
}
/** Accesseur et mutateur : le titre est nettoyé à l'écriture */
protected function titre(): Attribute
{
return Attribute::make(
get: fn (string $valeur) => $valeur,
set: fn (string $valeur) => Str::squish($valeur), // supprime les espaces en trop
);
}echo $livre->prix_formate; // "65,00 DH"Un accesseur calculé n'apparaît pas dans le JSON du modèle, sauf si on l'ajoute à protected $appends = ['prix_formate'];.
Les événements du modèle
Eloquent déclenche des événements à chaque étape (creating, created, updating, saving, deleting…). On s'y abonne dans la méthode statique booted :
protected static function booted(): void
{
static::creating(function (Livre $livre) {
$livre->slug ??= Str::slug($livre->titre); // crée le slug s'il n'est pas fourni
});
}Utiliser une relation
$livre = Livre::find(3);
$livre->categorie; // PROPRIÉTÉ : le modèle Categorie (requête exécutée)
$livre->categorie->libelle; // "Romans"
$livre->auteurs; // Collection d'Auteur
$livre->auteurs->pluck('nom')->join(', '); // "Antoine de Saint-Exupéry"
$livre->auteurs(); // MÉTHODE : un Query Builder, qu'on peut compléter
$livre->auteurs()->where('nationalite', 'marocaine')->get();
$categorie->livres()->count(); // compte en SQL, sans charger les livresCréer et lier par une relation
// créer un livre directement dans une catégorie (categorie_id est rempli automatiquement)
$categorie->livres()->create(['isbn' => '9789954000001', 'titre' => 'Le Pain nu', 'prix' => 70]);
// belongsTo : associer / dissocier
$livre->categorie()->associate($autreCategorie);
$livre->save();
// belongsToMany : gérer la table pivot
$livre->auteurs()->attach($auteurId); // ajouter un lien
$livre->auteurs()->attach([1, 4]);
$livre->auteurs()->detach($auteurId); // retirer un lien
$livre->auteurs()->sync([1, 4]); // garder EXACTEMENT ces liens (formulaire de modification)
$livre->auteurs()->toggle([2]); // ajouter si absent, retirer si présent
$commande->livres()->attach($livreId, ['quantite' => 2, 'prix_unitaire' => 65]); // avec données pivotInterroger selon une relation
Categorie::has('livres')->get(); // catégories qui ont au moins un livre
Categorie::doesntHave('livres')->get(); // catégories vides
Livre::whereHas('auteurs', fn ($q) => $q->where('nationalite', 'marocaine'))->get(); // livres d'auteurs marocains
Categorie::withCount('livres')->get(); // ajoute $categorie->livres_count
Auteur::withCount('livres')->orderByDesc('livres_count')->take(5)->get(); // auteurs les plus publiésLe chargement anticipé et le problème N+1
$livres = Livre::all(); // 1 requête
foreach ($livres as $livre) {
echo $livre->categorie->libelle; // 1 requête PAR livre !
}
// 100 livres = 101 requêtesAccéder à une relation dans une boucle déclenche une requête à chaque tour : c'est le problème N+1, la première cause de lenteur des applications Laravel. La solution : le chargement anticipé (eager loading) avec with().
$livres = Livre::with('categorie')->get(); // 2 requêtes, quel que soit le nombre de livres
$livres = Livre::with(['categorie', 'auteurs'])->paginate(12); // 3 requêtes
$livres = Livre::with('auteurs:id,nom')->get(); // seulement certaines colonnes de la relation
$livre->load('auteurs'); // charger après coup, sur un modèle déjà luselect * from `livres`
select * from `categories` where `categories`.`id` in (1, 2, 4)Les deux relations clés : hasMany et belongsTo.
Tables pivots et relations belongsToMany.
Le chargement anticipé et le problème N+1.