Retour au cours

backend / php-laravel

Eloquent ORM : modèles et requêtes

Leçon 101 exercice

Explication

Ce que vous allez apprendre

  • Comprendre ce qu'est un ORM et pourquoi Eloquent évite d'écrire du SQL à la main
  • Protéger un modèle contre le mass assignment avec $fillable
  • Convertir automatiquement des types entre la base de données et PHP avec $casts
  • Créer des scopes locaux réutilisables pour composer des requêtes lisibles
  • Traiter de très gros volumes de données sans épuiser la mémoire avec chunkById

Dans quel contexte ?

Une équipe backend gère un catalogue de produits stocké en base MySQL. Plutôt que d'écrire des requêtes SQL à la main pour chaque opération (créer, filtrer, agréger), Eloquent permet de manipuler ces données comme de simples objets PHP, tout en gardant un contrôle fin sur la sécurité et les performances. Comprendre Eloquent en profondeur, c'est éviter à la fois les failles de sécurité (mass assignment) et les ralentissements silencieux (chargement de tables entières en mémoire).

D'abord, qu'est-ce qu'un ORM apporte concrètement ?

Un ORM (Object-Relational Mapper) fait correspondre une table de base de données à une classe PHP, et chaque ligne à une instance de cette classe. Écrire Produit::find(42) évite d'écrire SELECT * FROM produits WHERE id = 42, tout en restant portable si la base de données change de moteur (MySQL, PostgreSQL...).

Eloquent va plus loin qu'un simple mapping : il propose un query builder fluide, des relations entre modèles (vues en prochaine leçon), et des mécanismes de protection intégrés.

Prérequis

Cette leçon suppose une bonne compréhension des contrôleurs et requêtes HTTP (leçon 9), puisque c'est typiquement depuis un contrôleur que ces requêtes Eloquent sont déclenchées.

Ensuite, une faille de sécurité à connaître avant d'aller plus loin

Sans protection, Produit::create($request->all()) accepterait n'importe quel champ envoyé par le client — y compris, potentiellement, un champ sensible qui ne devrait jamais être modifiable directement. $fillable définit une liste blanche explicite des champs autorisés en création/mise à jour de masse.

Piège dangereux

Un modèle sans $fillable ni $guarded explicite accepte tous les champs par défaut sur les anciennes conventions Laravel. Toujours déclarer $fillable explicitement, même une liste courte, pour ne jamais dépendre d'un comportement implicite sur des données sensibles.

Il reste un problème pratique : les types ne correspondent pas toujours

Une base de données stocke souvent des booléens comme des 0/1, du JSON comme du texte brut. Les $casts d'Eloquent convertissent automatiquement ces valeurs dans les deux sens : un champ metadonnees casté en array se lit et s'écrit comme un vrai tableau PHP, sans json_encode/json_decode manuel à chaque endroit du code.

CastType en baseType en PHP
boolean0 / 1true / false
arrayTexte JSONTableau PHP
datetimeChaîne de dateInstance Carbon
decimal:2NombreChaîne formatée à 2 décimales

Maintenant, composer des requêtes sans les répéter partout

Un scope local (scopeDisponibles($query)) encapsule une condition de requête fréquemment utilisée, appelable ensuite simplement via Produit::disponibles(). Ça évite de dupliquer ->where('est_disponible', true)->where('stock', '>', 0) dans dix contrôleurs différents, et centralise la définition métier de "disponible" à un seul endroit.

Astuce

firstOrCreate() et updateOrCreate() évitent une race condition classique : vérifier si une ligne existe puis la créer en deux requêtes séparées laisse une fenêtre où deux requêtes concurrentes pourraient créer un doublon. Ces méthodes gèrent ce cas en une seule opération atomique côté base.

Enfin, ne jamais charger une table entière en mémoire

Produit::all() sur une table de plusieurs millions de lignes ferait exploser la mémoire du processus PHP. chunkById(500, function ($lot) { ... }) traite les données par lots de 500, en gardant une empreinte mémoire constante quelle que soit la taille de la table.

Maintenant que tu sais interroger un modèle isolé, la prochaine leçon aborde ce qui rend Eloquent vraiment puissant : les relations entre plusieurs modèles, et comment éviter le fameux problème de performance N+1.

Commandes & code

Eloquent ORM : modèles et requêtes

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;

class Produit extends Model {
    // Mass assignment : seuls ces champs sont remplissables via create()/update()
    protected $fillable = ['nom', 'prix', 'categorie_id', 'description'];

    // Casts automatiques : conversion type base <-> type PHP
    protected $casts = [
        'prix' => 'decimal:2',
        'est_disponible' => 'boolean',
        'metadonnees' => 'array',      // JSON <-> tableau PHP automatique
        'publie_le' => 'datetime',
    ];

    // Accessor moderne (Laravel 9+) : calcule une valeur virtuelle
    protected function nomAffiche(): Attribute {
        return Attribute::make(
            get: fn () => strtoupper($this->nom),
        );
    }

    // Scope local réutilisable dans les requêtes : Produit::disponibles()->get()
    public function scopeDisponibles($query) {
        return $query->where('est_disponible', true)->where('stock', '>', 0);
    }

    public function scopePrixInferieurA($query, float $montant) {
        return $query->where('prix', '<', $montant);
    }
}

// Utilisation du query builder Eloquent
$produits = Produit::disponibles()
    ->prixInferieurA(100)
    ->orderByDesc('cree_le')
    ->limit(10)
    ->get();

// find / findOrFail / firstOrCreate / updateOrCreate : les raccourcis courants
$produit = Produit::findOrFail(42);                     // 404 automatique si absent (dans un contrôleur)
$produit = Produit::firstOrCreate(['sku' => 'ABC123'], ['nom' => 'Nouveau produit', 'prix' => 0]);
$produit = Produit::updateOrCreate(['sku' => 'ABC123'], ['prix' => 29.90]);

// Agrégations
$total = Produit::disponibles()->sum('prix');
$moyenne = Produit::avg('prix');
$parCategorie = Produit::selectRaw('categorie_id, COUNT(*) as total')
    ->groupBy('categorie_id')
    ->having('total', '>', 5)
    ->get();

// Requêtes brutes quand le query builder ne suffit pas
$resultats = \Illuminate\Support\Facades\DB::select(
    'SELECT categorie_id, AVG(prix) as prix_moyen FROM produits WHERE stock > ? GROUP BY categorie_id',
    [0]
);

// Chunking : traiter de grandes tables sans tout charger en mémoire
Produit::where('est_disponible', true)->chunkById(500, function ($lot) {
    foreach ($lot as $produit) {
        // traitement par lots de 500
    }
});

Résumé

  • $fillable (whitelist) protège contre le mass assignment non désiré — toujours l'expliciter.
  • Les scopes locaux (scopeXxx) rendent les requêtes composables et lisibles.
  • firstOrCreate / updateOrCreate évitent les races de type "vérifier puis créer".
  • chunkById traite de grands volumes sans exploser la mémoire du process.

Exercices pratiques

1 disponible
1

Mission : colmater une faille de mass assignment et un plantage memoire

Objectif : Proteger un modele Eloquent contre le mass assignment non controle et corriger un traitement qui charge une table entiere en memoire.

Contexte

Le modele Produit ne declare ni $fillable ni $guarded, et le controleur appelle directement Produit::create($request->all()). Un test de securite montre qu'envoyer un champ cout_achat (normalement reserve aux calculs internes de marge) dans le payload de creation suffit a le faire enregistrer tel quel en base. Par ailleurs, une commande de maintenance nocturne fait foreach (Produit::all() as $produit) { ... } sur une table qui vient de depasser 2 millions de lignes, et le processus PHP se fait tuer pour depassement de memoire.

Résoudre l’exercice →