Module 3Approfondir la programmation Laravel
Chapitre 10 sur 17
Interagir avec la base de données
Migrations, clés étrangères, seeders, fabriques, SQL brut sécurisé et Query Builder.
- ≈ 160 min
- 6 leçons
- 5 vidéos
- 4 exercices
Module 3Approfondir la programmation Laravel
Chapitre 10 sur 17
Migrations, clés étrangères, seeders, fabriques, SQL brut sécurisé et Query Builder.
Objectifs
Avec ce chapitre, tu sauras…
:::savoir Contenu rédigé d'après le programme Les chapitres de la partie 3 ne figurent pas dans le résumé fourni. Ils ont été rédigés d'après les intitulés du programme officiel (« Interagir avec la base de données », « Manipuler l'ORM Eloquent », « Gérer la sécurité ») et la documentation de Laravel 13. Les connaissances SQL du module M106 sont supposées acquises. :::
Leçon 1
La configuration se trouve dans config/database.php, qui lit ses valeurs dans le .env (chapitre 2). Laravel prend en charge MySQL, MariaDB, PostgreSQL, SQLite et SQL Server.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=kitab
DB_USERNAME=root
DB_PASSWORD=On vérifie la connexion avec php artisan db:show (résumé de la base et de ses tables) ou php artisan db:table livres (structure d'une table). La façade DB donne accès à la base dans le code.
Leçon 2
Laravel garde la trace des migrations déjà exécutées dans une table migrations. Un projet neuf contient déjà trois migrations : users (avec password_reset_tokens et sessions), cache et jobs.
Créer une migration
php artisan make:migration create_categories_tableLe fichier est créé dans database/migrations, préfixé par la date et l'heure (qui fixent l'ordre d'exécution) : 2026_11_02_100000_create_categories_table.php. Le nom create_xxx_table indique à Artisan qu'il s'agit de créer la table xxx : il pré-remplit le code.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** Exécuter la migration. */
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id(); // BIGINT UNSIGNED AUTO_INCREMENT, clé primaire
$table->string('libelle', 80); // VARCHAR(80)
$table->string('slug', 90)->unique();
$table->foreignId('parent_id')->nullable() // sous-catégories
->constrained('categories')->nullOnDelete();
$table->timestamps(); // created_at et updated_at
});
}
/** Annuler la migration. */
public function down(): void
{
Schema::dropIfExists('categories');
}
};| Méthode | Rôle |
|---|---|
up() | appliquée par php artisan migrate : la modification |
down() | appliquée par php artisan migrate:rollback : l'inverse exact de up() |
Tableau: Les types de colonnes
| Méthode | Type SQL (MySQL) | Exemple Kitab |
|---|---|---|
id() | BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY | identifiant |
string('nom', 100) | VARCHAR(100) (255 par défaut) | titre, slug |
char('isbn', 13) | CHAR(13) | ISBN |
text('resume') | TEXT | résumé du livre |
integer('x'), unsignedInteger('stock') | INT, INT UNSIGNED |
| Modificateur | Effet |
|---|---|
->nullable() | la colonne accepte NULL |
->default(0) | valeur par défaut |
->unique() | index d'unicité |
->index() | index simple (accélère les recherches sur cette colonne) |
->unsigned() | entier positif |
->comment('...') | commentaire de la colonne |
->after('titre') | position de la colonne (MySQL, lors d'un ajout) |
| Opération | Code (dans Schema::table) |
|---|---|
| ajouter une colonne | $table->string('telephone', 15)->nullable(); |
| modifier une colonne | $table->string('titre', 300)->change(); (redonner tous les modificateurs à garder) |
| renommer une colonne | $table->renameColumn('resume', 'description'); |
| supprimer une colonne | $table->dropColumn('telephone'); |
| supprimer une clé étrangère | $table->dropForeign(['categorie_id']); |
| renommer une table | Schema::rename('auteurs', 'ecrivains'); (hors de Schema::table) |
| Commande | Effet |
|---|---|
php artisan migrate | exécute les migrations en attente (un nouveau « lot ») |
php artisan migrate:status | liste les migrations et indique lesquelles ont été exécutées |
php artisan migrate --pretend | affiche le SQL sans l'exécuter |
php artisan migrate:rollback | annule le dernier lot (appelle down()) |
php artisan migrate:rollback --step=2 | annule les 2 dernières migrations |
php artisan migrate:reset | annule toutes les migrations |
Leçon 3
Leçon 4
Leçon 5
| Méthode | SQL produit |
|---|---|
where('stock', 0) | WHERE stock = 0 |
where('prix', '>', 50) | WHERE prix > 50 |
where([['langue', 'fr'], ['stock', '>', 0]]) | WHERE langue = 'fr' AND stock > 0 |
orWhere('stock', '>', 20) | OR stock > 20 |
whereIn('langue', ['ar', 'fr']) / whereNotIn | WHERE langue IN ('ar', 'fr') |
Leçon 6
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
1La méthode d'une migration exécutée par migrate:rollback est…
2$table->foreignId('categorie_id')->constrained() référence la table…
3Le nom conventionnel de la table pivot entre Livre et Auteur est…
4La commande qui supprime toutes les tables puis relance les migrations est…
5Pour un prix, le bon type de colonne est…
6DB::table('livres')->pluck('titre') renvoie…
7Une valeur saisie dans une requête brute doit passer par…
8paginate(12) lit le numéro de page dans…
L'essentiel du chapitre
config/database.php + .env (DB_CONNECTION, DB_HOST, DB_DATABASE…) ; php artisan db:show.make:migration create_x_table, database/migrations, ordre = date) : up() / down() ; Schema::create, Schema::table, Schema::dropIfExists ; colonnes id, string, char, text, unsignedInteger, decimal(8,2), boolean, date, json, enum, foreignId, timestamps, softDeletes ; modificateurs nullable, default, unique, index, after, change().foreignId('categorie_id')->constrained()->cascadeOnDelete() (ou nullOnDelete, restrictOnDelete) ; pivot auteur_livre (singuliers, ordre alphabétique) avec primary([...]).migrate, migrate:status, --pretend, migrate:rollback (--step), reset, refresh, fresh --seed (jamais en production). Ne pas modifier une migration exécutée : en créer une nouvelle.make:seeder, DatabaseSeeder::call, db:seed) ; fabriques (make:factory, definition() avec fake(), états, factory()->count(30)->create(), for(), make()).DB::select/selectOne/scalar/insert/update/delete/statement avec liaisons ? ou :nom ; concaténer une saisie = injection SQL.decimal('prix', 8, 2) | DECIMAL(8,2) | prix (jamais float pour de l'argent) |
boolean('actif') | TINYINT(1) | livre visible ou non |
date('publie_le'), dateTime('x'), timestamp('x') | DATE, DATETIME, TIMESTAMP | date de parution |
json('options') | JSON | préférences |
enum('statut', ['a', 'b']) | ENUM | statut d'une commande |
foreignId('categorie_id') | BIGINT UNSIGNED (pour une clé étrangère) | catégorie du livre |
timestamps() | created_at et updated_at | dates de création et de modification |
softDeletes() | deleted_at | suppression « douce » (chapitre 11) |
php artisan migrate:refreshreset puis migrate |
php artisan migrate:fresh | supprime toutes les tables puis migrate (sans passer par down()) |
php artisan migrate:fresh --seed | la même chose, puis remplit la base avec les seeders |
whereBetween('prix', [50, 100]) | WHERE prix BETWEEN 50 AND 100 |
whereNull('couverture') / whereNotNull | WHERE couverture IS NULL |
whereLike('titre', '%prince%') | WHERE titre LIKE '%prince%' (insensible à la casse par défaut) |
whereDate('created_at', '2026-11-02'), whereYear('publie_le', 2020) | filtres sur une partie de date |
whereColumn('updated_at', '>', 'created_at') | comparaison de deux colonnes |
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
Vidéo · À regarder
ENHacking Websites with SQL Injection - Computerphile
Computerphile · YouTube
DB::table() + get, first, find, value, pluck ; select, where, orWhere, whereIn, whereBetween, whereNull, whereLike, groupes par closure ; orderBy, latest, take, skip, groupBy, having ; count, max, min, avg, sum, exists ; join, leftJoin ; insert, insertGetId, update, increment, decrement, delete (toujours avec where) ; when() ; paginate() + links() ; DB::transaction() ; toSql().Tableau: Les modificateurs
Les clés étrangères
$table->foreignId('categorie_id')->constrained()->cascadeOnDelete();foreignId('categorie_id') crée une colonne BIGINT UNSIGNED ;constrained() ajoute la contrainte de clé étrangère ; sans argument, Laravel devine la table à partir du nom de colonne : categorie_id → table categories, colonne id ;cascadeOnDelete() (supprimer les enfants), nullOnDelete() (mettre NULL ; la colonne doit être nullable), restrictOnDelete() (interdire la suppression du parent).Si le nom de la table ne se déduit pas de la colonne, on la donne : ->constrained('users') pour une colonne client_id.
Une table pivot (association plusieurs à plusieurs)
Un livre a plusieurs auteurs, un auteur a écrit plusieurs livres (M109, M106) : il faut une table d'association. Par convention Laravel, elle s'appelle avec les deux noms de modèles au singulier, dans l'ordre alphabétique, séparés par _ : auteur_livre.
Schema::create('auteur_livre', function (Blueprint $table) {
$table->foreignId('auteur_id')->constrained()->cascadeOnDelete();
$table->foreignId('livre_id')->constrained()->cascadeOnDelete();
$table->primary(['auteur_id', 'livre_id']); // clé primaire composée : pas de doublon
});Modifier une table existante
On ne modifie jamais une migration déjà exécutée (et partagée) : on en crée une nouvelle.
php artisan make:migration add_role_to_users_table<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role', 20)->default('client')->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};Tableau: Les commandes de migration
migrate:fresh et migrate:refresh effacent toutes les données. Elles sont très pratiques en développement, mais ne les lance jamais sur une base de production. En production, on n'exécute que php artisan migrate (avec l'option --force, que Laravel exige hors de l'environnement local).
Les seeders
Un seeder remplit la base avec des données initiales (catégories, compte administrateur) ou de test.
php artisan make:seeder CategorieSeeder<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CategorieSeeder extends Seeder
{
public function run(): void
{
foreach (['Romans', 'Jeunesse', 'Histoire du Maroc', 'Informatique', 'Poésie'] as $libelle) {
DB::table('categories')->insert([
'libelle' => $libelle,
'slug' => Str::slug($libelle), // "histoire-du-maroc"
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}Le seeder principal, DatabaseSeeder, appelle les autres dans l'ordre (les parents avant les enfants, à cause des clés étrangères) :
public function run(): void
{
$this->call([
CategorieSeeder::class,
AuteurSeeder::class,
LivreSeeder::class,
]);
}php artisan db:seed # exécute DatabaseSeeder
php artisan db:seed --class=CategorieSeeder # un seul seeder
php artisan migrate:fresh --seed # reconstruire et remplirLes fabriques (factories)
Une fabrique (factory) génère des données fictives réalistes pour un modèle, avec la bibliothèque Faker (assistant fake()). Elle sert aux seeders de développement et aux tests.
php artisan make:factory LivreFactory --model=Livre<?php
namespace Database\Factories;
use App\Models\Categorie;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Livre>
*/
class LivreFactory extends Factory
{
public function definition(): array
{
$titre = rtrim(fake()->sentence(3), '.');
return [
'isbn' => fake()->unique()->isbn13(),
'titre' => $titre,
'slug' => Str::slug($titre) . '-' . fake()->unique()->numberBetween(1, 99999),
'resume' => fake()->paragraph(4),
'prix' => fake()->randomFloat(2, 30, 400),
'stock' => fake()->numberBetween(0, 50),
'langue' => fake()->randomElement(['ar', 'fr', 'en']),
'publie_le' => fake()->dateTimeBetween('-30 years', 'now'),
'categorie_id' => Categorie::factory(), // crée une catégorie si on n'en fournit pas
];
}
/** Un « état » : un livre épuisé. */
public function epuise(): static
{
return $this->state(fn (array $attributes) => ['stock' => 0]);
}
}use App\Models\Categorie;
use App\Models\Livre;
Livre::factory()->create(); // 1 livre (et sa catégorie)
Livre::factory()->count(30)->create(); // 30 livres
Livre::factory()->count(5)->epuise()->create(); // 5 livres épuisés
Livre::factory()->count(10)->for(Categorie::first())->create(); // 10 livres d'une catégorie existante
Livre::factory()->make(); // un objet NON enregistré en basePour qu'un modèle ait une fabrique, il doit utiliser le trait HasFactory (chapitre 11). Faker est en anglais par défaut ; pour des noms et des adresses en français : APP_FAKER_LOCALE=fr_FR dans le .env.
La façade DB exécute du SQL écrit à la main. Les valeurs variables passent toujours par des liaisons (bindings) : des ? ou des noms précédés de :.
use Illuminate\Support\Facades\DB;
$livres = DB::select('SELECT titre, prix FROM livres WHERE prix < ? AND langue = ?', [100, 'fr']);
$livre = DB::selectOne('SELECT * FROM livres WHERE isbn = :isbn', ['isbn' => $isbn]);
$nb = DB::scalar('SELECT COUNT(*) FROM livres WHERE stock = 0');
DB::insert('INSERT INTO auteurs (nom, created_at, updated_at) VALUES (?, ?, ?)', ['Leïla Slimani', now(), now()]);
$modifies = DB::update('UPDATE livres SET stock = stock - 1 WHERE id = ?', [$id]); // nombre de lignes
$supprimes = DB::delete('DELETE FROM paniers WHERE updated_at < ?', [now()->subDays(30)]);
DB::statement('ALTER TABLE livres AUTO_INCREMENT = 1000');DB::select renvoie un tableau d'objets stdClass : $livres[0]->titre.
// DANGEREUX : la saisie est collée dans le SQL
$livres = DB::select("SELECT * FROM livres WHERE titre LIKE '%" . $request->input('q') . "%'");
Un utilisateur qui tape ' OR 1=1 -- lit toute la table, et pire encore avec d'autres requêtes (module M108). Avec une liaison, la valeur est envoyée séparément de la requête et ne peut jamais être interprétée comme du SQL :
$livres = DB::select('SELECT * FROM livres WHERE titre LIKE ?', ['%' . $request->input('q') . '%']);
Le Query Builder et Eloquent utilisent toujours des liaisons. Le danger revient avec les méthodes « raw » (whereRaw, selectRaw, orderByRaw, DB::raw) si on y concatène une saisie.
Le Query Builder (constructeur de requêtes) construit les requêtes SQL avec des méthodes PHP chaînées. Il fonctionne avec toutes les bases prises en charge et protège automatiquement contre l'injection SQL.
Récupérer des lignes
use Illuminate\Support\Facades\DB;
$livres = DB::table('livres')->get(); // toutes les lignes : une Collection d'objets
$livre = DB::table('livres')->where('isbn', $isbn)->first(); // la première ligne (ou null)
$livre = DB::table('livres')->find(3); // par id
$titre = DB::table('livres')->where('id', 3)->value('titre'); // une seule valeur
$titres = DB::table('livres')->pluck('titre'); // une colonne : ['Le Petit Prince', ...]
$titres = DB::table('livres')->pluck('titre', 'id'); // [3 => 'Le Petit Prince', ...]Sélectionner et filtrer
$livres = DB::table('livres')
->select('id', 'titre', 'prix')
->where('langue', 'fr') // WHERE langue = 'fr'
->where('prix', '<=', 100) // AND prix <= 100
->orWhere('stock', '>', 20) // OR stock > 20
->get();Les migrations : créer et modifier les tables.
(1) Passe Kitab sur MySQL (base kitab). (2) Crée les migrations : categories (avec parent_id), auteurs, livres, auteur_livre, l'ajout de role à users, commandes (numero unique, user_id, statut, total) et commande_livre (commande_id, livre_id, quantite, prix_unitaire). (3) Lance php artisan migrate puis migrate:status. (4) Crée CategorieSeeder et un seeder qui crée un compte administrateur. (5) Teste dans Tinker : le nombre de catégories, puis une jointure livres-catégories (après avoir inséré deux livres à la main). (6) Lance php artisan migrate:fresh --seed et vérifie dans phpMyAdmin.
Écris les migrations de Kitab : (1) auteurs (id, nom 120 caractères, nationalité facultative, date de naissance facultative, horodatage) ; (2) livres (id, isbn CHAR(13) unique, titre, slug unique, résumé facultatif, prix décimal, stock entier positif à 0 par défaut, langue sur 2 caractères à fr par défaut, couverture facultative, date de parution facultative, catégorie obligatoire supprimée en cascade, horodatage, suppression douce) ; (3) la table pivot auteur_livre.
Pour grouper des conditions entre parenthèses, on passe une closure :
// WHERE langue = 'fr' AND (stock > 0 OR precommande = 1)
DB::table('livres')
->where('langue', 'fr')
->where(function ($query) {
$query->where('stock', '>', 0)->orWhere('precommande', true);
})
->get();Trier, limiter, regrouper
DB::table('livres')->orderBy('prix', 'desc')->orderBy('titre')->get();
DB::table('livres')->latest()->take(5)->get(); // 5 plus récents (ORDER BY created_at DESC LIMIT 5)
DB::table('livres')->oldest('publie_le')->get();
DB::table('livres')->inRandomOrder()->limit(3)->get();
DB::table('livres')->skip(20)->take(10)->get(); // OFFSET 20 LIMIT 10
DB::table('livres')
->select('langue', DB::raw('COUNT(*) AS nombre'), DB::raw('AVG(prix) AS prix_moyen'))
->groupBy('langue')
->having('nombre', '>', 5)
->get();Les agrégats
$nb = DB::table('livres')->count();
$max = DB::table('livres')->max('prix');
$min = DB::table('livres')->where('langue', 'ar')->min('prix');
$moyenne = DB::table('livres')->avg('prix');
$valeur = DB::table('livres')->sum(DB::raw('prix * stock')); // valeur du stock
$existe = DB::table('livres')->where('isbn', $isbn)->exists();Les jointures
$livres = DB::table('livres')
->join('categories', 'livres.categorie_id', '=', 'categories.id')
->select('livres.titre', 'livres.prix', 'categories.libelle AS categorie')
->where('categories.slug', 'romans')
->get();
$categories = DB::table('categories')
->leftJoin('livres', 'livres.categorie_id', '=', 'categories.id')
->select('categories.libelle', DB::raw('COUNT(livres.id) AS nb_livres'))
->groupBy('categories.id', 'categories.libelle')
->get(); // les catégories, même sans livre (LEFT JOIN)Insérer, modifier, supprimer
DB::table('auteurs')->insert(['nom' => 'Tahar Ben Jelloun', 'created_at' => now(), 'updated_at' => now()]);
DB::table('auteurs')->insert([['nom' => 'A'], ['nom' => 'B']]); // plusieurs lignes
$id = DB::table('auteurs')->insertGetId(['nom' => 'Driss Chraïbi']); // renvoie l'id créé
DB::table('livres')->where('id', $id)->update(['prix' => 59.90]); // nombre de lignes modifiées
DB::table('livres')->where('id', $id)->increment('stock', 10); // stock = stock + 10
DB::table('livres')->where('id', $id)->decrement('stock'); // stock = stock - 1
DB::table('livres')->updateOrInsert(['isbn' => $isbn], ['stock' => 5]); // modifie ou crée
DB::table('livres')->where('stock', 0)->where('publie_le', '<', '2000-01-01')->delete();
DB::table('paniers')->truncate(); // vide la tableDB::table('livres')->update(['prix' => 0]) modifie toutes les lignes, et DB::table('livres')->delete() les supprime toutes. Écris toujours le where() avant d'écrire update() ou delete().
Les filtres conditionnels avec when()
Dans un moteur de recherche, les filtres sont facultatifs. when n'ajoute une condition que si la valeur est remplie :
$livres = DB::table('livres')
->when($request->input('q'), fn ($query, $texte) => $query->whereLike('titre', "%{$texte}%"))
->when($request->input('langue'), fn ($query, $langue) => $query->where('langue', $langue))
->when($request->boolean('en_stock'), fn ($query) => $query->where('stock', '>', 0))
->orderBy('titre')
->get();La pagination
$livres = DB::table('livres')->orderBy('titre')->paginate(12); // 12 par page, lit ?page=N
$livres = DB::table('livres')->orderBy('titre')->simplePaginate(12); // seulement « précédent / suivant »@foreach ($livres as $livre) ... @endforeach
{{ $livres->links() }} {{-- les liens de pagination --}}
{{ $livres->withQueryString()->links() }} {{-- en gardant les filtres de l'URL --}}Les liens utilisent par défaut des classes Tailwind. Pour Bootstrap, on appelle Paginator::useBootstrapFive(); dans AppServiceProvider::boot() (classe Illuminate\Pagination\Paginator). L'objet paginé donne aussi $livres->total(), currentPage(), lastPage().
Les transactions
Une transaction regroupe plusieurs requêtes : soit toutes réussissent, soit aucune n'est appliquée (module M106). Exemple : enregistrer une commande et diminuer les stocks.
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($panier, $userId) {
$commandeId = DB::table('commandes')->insertGetId([
'numero' => 'K-' . now()->format('Ymd-His'),
'user_id' => $userId,
'statut' => 'en_attente',
'total' => $panier->total(),
'created_at' => now(), 'updated_at' => now(),
]);
foreach ($panier->lignes() as $ligne) {
DB::table('commande_livre')->insert([
'commande_id' => $commandeId,
'livre_id' => $ligne['livre_id'],
'quantite' => $ligne['quantite'],
'prix_unitaire' => $ligne['prix'],
]);
DB::table('livres')->where('id', $ligne['livre_id'])->decrement('stock', $ligne['quantite']);
}
});Si une exception est levée dans la closure, Laravel fait un ROLLBACK automatiquement ; sinon, un COMMIT.
Voir le SQL produit
DB::table('livres')->where('prix', '<', 100)->toSql(); // "select * from `livres` where `prix` < ?"
DB::table('livres')->where('prix', '<', 100)->dumpRawSql(); // affiche le SQL avec les valeursLes fabriques de modèles (factories) et Faker.
Tout sur la pagination.
Les seeders.
Une injection SQL sur un vrai formulaire PHP, puis les requêtes préparées (Computerphile).
Traduis chaque requête SQL avec le Query Builder : (1) SELECT titre, prix FROM livres WHERE langue = 'ar' ORDER BY prix DESC LIMIT 5 ; (2) SELECT COUNT(*) FROM livres WHERE stock = 0 ; (3) SELECT * FROM livres WHERE prix BETWEEN 50 AND 150 AND categorie_id IN (1, 3) ; (4) UPDATE livres SET prix = prix * 0.9 WHERE categorie_id = 2 ; (5) SELECT c.libelle, AVG(l.prix) AS moyenne FROM categories c JOIN livres l ON l.categorie_id = c.id GROUP BY c.id, c.libelle HAVING AVG(l.prix) > 100.
Ce contrôleur de recherche fonctionne, mais il est dangereux. Explique pourquoi, puis réécris-le avec le Query Builder, avec pagination (10 par page) et les filtres facultatifs q (titre) et max (prix maximal).
public function recherche(Request $request)
{
$q = $request->input('q');
$livres = DB::select("SELECT * FROM livres WHERE titre LIKE '%$q%' ORDER BY " . $request->input('tri', 'titre'));
return view('recherche', compact('livres'));
}