Aller au contenu

MVC

Bloc Laravel · séance 1 · ~60 min

Slides du chapitre (PDF)PDF

On peut afficher les articles d'un blog avec un seul fichier PHP de 26 lignes. Ce fichier contient alors la requête SQL, le HTML et le choix de l'ordre des articles. Une modification du tri, de la base ou de la présentation conduit donc à ouvrir le même fichier. Laravel sépare ces tâches dans plusieurs fichiers. Pour répondre à /posts, il exécute les instructions de chaque fichier dans un ordre précis.

À la fin de ce chapitre, vous serez capables de :

  1. nommer le rôle des quatre parties qui traitent une requête.
  2. ranger un extrait de code dans la partie qui remplit sa tâche.
  3. tracer le traitement d'une requête, de l'adresse saisie jusqu'à la page affichée.
  4. dire quels mélanges cette organisation évite, avec un exemple pour chacun.

1. Le code qui sent mauvais

Vous connaissez la requête et la réponse depuis le chapitre Framework et librairie. Le navigateur envoie une méthode et un chemin, par exemple GET /posts.php. Le serveur renvoie alors un code tel que 200 et une page HTML.

Le fichier posts.php produit la page d'accueil d'un petit blog sans utiliser de framework. Il lit trois articles dans la table posts de la base SQLite database.sqlite. Lisez le code sans le lancer.

<?php
$pdo = new PDO('sqlite:' . __DIR__ . '/database.sqlite');

$order = ($_GET['ordre'] ?? '') === 'ancien' ? 'ASC' : 'DESC';

$posts = $pdo
    ->query("SELECT title, body FROM posts ORDER BY created_at $order")
    ->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Le blog</title>
</head>
<body>
    <h1 style="color: navy">Le blog</h1>
    <p><?= count($posts) ?> articles</p>
    <ul>
    <?php foreach ($posts as $post): ?>
        <li><strong style="color: navy"><?= $post['title'] ?></strong> : <?= $post['body'] ?></li>
    <?php endforeach; ?>
    </ul>
    <a href="?ordre=ancien">Du plus ancien au plus récent</a>
</body>
</html>

Dans l'adresse ?ordre=ancien, ordre est un paramètre d'URL. Un paramètre ajoute une valeur nommée à une adresse. La ligne qui remplit $order lit cette valeur et choisit ASC. Sans cette valeur, elle choisit DESC.

Le serveur intégré de PHP peut recevoir les requêtes destinées à cette page. Dans le dossier qui contient posts.php et sa base, lancez-le dans un premier terminal :

php -S 127.0.0.1:8080

Dans un second terminal, la commande curl demande la page et affiche la réponse complète. Sous PowerShell, tapez curl.exe à la place de curl.

curl -s http://127.0.0.1:8080/posts.php
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Le blog</title>
</head>
<body>
    <h1 style="color: navy">Le blog</h1>
    <p>3 articles</p>
    <ul>
            <li><strong style="color: navy">Le trajet d'une requête</strong> : De l'URL à la page HTML.</li>
            <li><strong style="color: navy">Installer Laravel 13</strong> : Composer, PHP 8.4 et un terminal.</li>
            <li><strong style="color: navy">Bienvenue sur le blog</strong> : Premier article du blog.</li>
        </ul>
    <a href="?ordre=ancien">Du plus ancien au plus récent</a>
</body>
</html>

La page fonctionne, mais il faut déterminer comment la modifier sans casser une autre partie. Pendant cinq minutes, en binôme, examinez trois demandes : changer la couleur des titres, modifier l'ordre des articles et remplacer SQLite par MySQL. Pour chacune, notez le ou les fichiers à ouvrir ainsi que les lignes à modifier.

2. Séparer trois responsabilités

Une responsabilité est une tâche précise confiée à une partie du programme. Lorsqu'une demande change cette tâche, vous savez quelle partie modifier. posts.php en réunit trois : lire les données, choisir leur ordre et produire le HTML. L'organisation MVC, pour Modèle-Vue-Contrôleur, sépare ces tâches dans différentes parties de l'application.

  • Le modèle lit et écrit les données, et porte les règles qui les concernent. Dans posts.php, ce sont les lignes 2 et 6 à 8 : la connexion à la base et la requête SQL.
  • La vue fabrique ce que l'utilisateur voit, ici du HTML, à partir des données qu'on lui donne. Ce sont les lignes 10 à 26.
  • Le contrôleur organise le traitement de la demande. Ici, il lit le paramètre ordre afin de choisir le sens du tri. Il demande ensuite les articles au modèle et transmet les articles obtenus à la vue. Dans posts.php, ces étapes sont mêlées au reste. Dans une application MVC, elles se trouvent dans une méthode du contrôleur.

Le routeur remplit un quatrième rôle : il relie une adresse à la méthode chargée de la traiter. Sans routeur applicatif, le serveur utilise ici le chemin demandé pour choisir directement un fichier. Ainsi, /posts.php lance le fichier posts.php. Cette organisation ne convient pas à une adresse comme /posts/3, car 3 désigne un article et non un fichier différent. Un routeur peut associer toutes les adresses de cette forme à une même méthode, qui reçoit le numéro de l'article.

Le fichier posts.php découpé en trois zones colorées : modèle, contrôleur, vue

Avec ce découpage, la couleur ne concerne que la vue. Le passage à MySQL concerne l'accès aux données et sa configuration, sans modifier le HTML. Le tri concerne le contrôleur, qui choisit l'ordre, et le modèle, qui produit la requête SQL. La nature de chaque demande permet donc de repérer la partie à modifier.

  • Une modification touche le fichier de sa responsabilité. Changer une couleur n'oblige plus à ouvrir un fichier qui contient du SQL.
  • La connexion à la base est configurée à un seul endroit. Le modèle utilise cette configuration au lieu d'ouvrir une nouvelle connexion dans chaque page.
  • Deux personnes peuvent travailler sur des fichiers différents. L'une modifie la vue pendant que l'autre modifie le modèle.

Le modèle s'occupe des données, la vue de l'affichage, le contrôleur coordonne les deux et le routeur choisit le contrôleur.

Note

En 1978 et 1979, Trygve Reenskaug, informaticien norvégien invité au centre de recherche Xerox PARC, travaille sur les interfaces graphiques du langage Smalltalk. Le 10 décembre 1979, sa note « Models-Views-Controllers » donne son nom à MVC. Les frameworks web ont repris l'idée plus tard, en l'adaptant aux requêtes et aux réponses.

3. La même page en Laravel

Dans un projet Laravel 13 neuf, la même liste peut se limiter aux articles, sans compteur, couleur ni lien de tri. Artisan, vu au chapitre Installation, permet de créer les fichiers nécessaires. La commande make:model crée une classe de modèle vide.

Une migration est un fichier qui décrit une modification de la base de données. L'option -m demande aussi la création d'une migration pour la table associée au modèle.

php artisan make:model Post -m
   INFO  Model [app/Models/Post.php] created successfully.

   INFO  Migration [database/migrations/2026_09_26_093317_create_posts_table.php] created successfully.

Sous Windows, Artisan affiche le chemin complet du fichier, C:\laragon\www\premier-test-laravel\app\Models\Post.php, au lieu du chemin relatif au projet. Le fichier créé est le même.

La méthode up() de la migration créée ajoute la table posts. Sa méthode down() peut la supprimer. Ajoutez les colonnes title et body, utilisées par le modèle, puis exécutez les migrations qui n'ont pas encore été appliquées :

php artisan migrate
   INFO  Running migrations.

  2026_09_26_093317_create_posts_table ........................... 3.11ms DONE

La commande make:controller crée un contrôleur vide :

php artisan make:controller PostController
   INFO  Controller [app/Http/Controllers/PostController.php] created successfully.

La table posts contient ensuite les trois articles utilisés dans posts.php. Lorsqu'une requête demande /posts, Laravel consulte la route, exécute le contrôleur, utilise le modèle et produit la vue.

Le routeur : routes/web.php

<?php

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/posts', [PostController::class, 'index']);

La première route est créée avec le projet et affiche la page d'accueil de Laravel. L'import rend le nom court PostController disponible dans ce fichier. La dernière ligne peut ainsi associer /posts à cette classe.

  • Route::get('/posts', …) déclare une route, c'est-à-dire une association entre une requête et le code qui la traite. Cette route correspond aux requêtes GET dont le chemin est /posts.
  • Le second argument, [PostController::class, 'index'], indique quel code traitera la requête. Le premier élément donne la classe PostController et le second la méthode index. La déclaration de la route ne crée pas encore de contrôleur. Laravel créera cet objet et appellera sa méthode lorsqu'il recevra une requête GET pour /posts.

Le chemin /posts ne correspond donc pas au nom d'un fichier sur le disque. Sa déclaration dans routes/web.php l'associe à la méthode index() de PostController.

Le contrôleur : app/Http/Controllers/PostController.php

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\View\View;

class PostController extends Controller
{
    public function index(): View
    {
        $posts = Post::latest()->get();

        return view('posts.index', ['posts' => $posts]);
    }
}
  • Post::latest()->get() demande les articles au modèle. latest() ajoute un tri du plus récent au plus ancien sur la colonne created_at. get() envoie la requête à la base. Il renvoie une collection, c'est-à-dire un objet Laravel qui regroupe ici plusieurs objets Post.
  • view('posts.index', ['posts' => $posts]) choisit la vue et lui transmet les données. Dans le nom posts.index, posts désigne le dossier et index le fichier. Laravel utilise donc resources/views/posts/index.blade.php. Dans le tableau associatif, la clé 'posts' crée la variable $posts utilisée par la vue.

Pour afficher les articles du plus ancien au plus récent, remplacez latest() par oldest() dans le contrôleur. Celui-ci exprime l'ordre voulu sans contenir le SQL qui réalise le tri. Il demande au modèle de récupérer les articles, puis transmet la collection à la vue. Il ne produit lui-même ni SQL ni HTML.

Le modèle : app/Models/Post.php et sa migration

Le fichier app/Models/Post.php contient la classe Post, qui hérite de Model. Cette classe mère appartient à Eloquent, la partie de Laravel qui relie les classes PHP aux tables de la base de données.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    //
}

La classe est vide, telle que make:model l'a créée. Grâce à son héritage, Post dispose des méthodes définies par Model. Une règle de nommage d'Eloquent associe automatiquement Post à la table posts, au pluriel anglais.

La méthode up() de la migration décrit la création de la table :

// …

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

    // …
};

La méthode down(), coupée ici, supprime la table lorsque la migration est annulée. Les lignes title et body ont été ajoutées à la main. id() crée la clé primaire. timestamps() crée les colonnes created_at et updated_at. La méthode latest() utilise created_at pour trier les articles.

Eloquent doit toujours envoyer une requête SQL à la base. Post::latest() construit cette requête à partir du modèle Post et de la convention sur created_at. toRawSql() permet d'afficher le SQL obtenu avant son envoi :

echo Post::latest()->toRawSql();
select * from "posts" order by "created_at" desc

Cette requête effectue le même tri que la ligne 7 de posts.php. Pour utiliser MySQL, modifiez les réglages de connexion dans le fichier .env du projet. La route, le contrôleur, le modèle et la vue restent inchangés. Eloquent produira la requête adaptée à la connexion choisie.

La vue : resources/views/posts/index.blade.php

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Le blog</title>
</head>
<body>
    <h1>Le blog</h1>
    <ul>
        @foreach ($posts as $post)
            <li><strong>{{ $post->title }}</strong> : {{ $post->body }}</li>
        @endforeach
    </ul>
</body>
</html>

L'extension .blade.php indique un template Blade, un fichier qui décrit le HTML à produire. Il contient principalement du HTML, avec des instructions comme @foreach et {{ ... }}. Lorsque Laravel prépare la réponse, Blade exécute ces instructions avec les données reçues du contrôleur et produit le HTML envoyé au navigateur.

  • @foreach ($posts as $post) et @endforeach entourent le HTML répété pour chaque article. Cette instruction correspond au foreach de PHP, sans écrire les balises <?php ?>.
  • {{ $post->title }} affiche le titre après l'avoir échappé, comme au chapitre Framework et librairie : <, > et l'apostrophe deviennent des codes HTML.
  • $post->title s'écrit avec une flèche : chaque article est un objet Post, et non plus un tableau comme dans posts.php.

Le résultat

À la racine du projet, lancez composer run dev dans un premier terminal, comme au chapitre Installation. Si vous utilisez Herd ou Laragon, l'adresse du projet est http://premier-test-laravel.test au lieu de http://127.0.0.1:8000. Dans un second terminal, demandez la page avec curl :

curl -s http://127.0.0.1:8000/posts
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Le blog</title>
</head>
<body>
    <h1>Le blog</h1>
    <ul>
                    <li><strong>Le trajet d&#039;une requête</strong> : De l&#039;URL à la page HTML.</li>
                    <li><strong>Installer Laravel 13</strong> : Composer, PHP 8.4 et un terminal.</li>
                    <li><strong>Bienvenue sur le blog</strong> : Premier article du blog.</li>
            </ul>
</body>
</html>

La réponse contient les trois articles, classés du plus récent au plus ancien. Dans le premier titre, l'apostrophe apparaît sous la forme &#039; dans le HTML. Cette transformation est l'échappement effectué par {{ }}. Le navigateur affiche une apostrophe normale, tandis que le fichier posts.php affichait directement les caractères reçus de la base.

La commande route:list affiche toutes les routes enregistrées dans l'application :

php artisan route:list
  GET|HEAD  / ............................................... routes/web.php:6
  GET|HEAD  posts ....................................... PostController@index
  GET|HEAD  storage/{path} storage.local › vendor/laravel/framework/src/Illum…
  PUT       storage/{path} storage.local.upload › vendor/laravel/framework/sr…
  GET|HEAD  up vendor/laravel/framework/src/Illuminate/Foundation/Configurati…

                                                            Showing [5] routes

La deuxième ligne indique que posts est associé à PostController@index, c'est-à-dire à la méthode index() de PostController. Une requête HEAD demande les mêmes en-têtes qu'une requête GET, mais sans le contenu de la réponse. Laravel l'accepte automatiquement pour cette route. Les quatre autres routes sont présentes dans le projet neuf.

Les quatre fichiers Laravel côte à côte, chacun avec son médaillon : routeur, contrôleur, modèle, vue

4. Le trajet d'une requête

  1. Le navigateur envoie la requête GET /posts au serveur.
  2. Le serveur transmet la requête à public/index.php, qui démarre Laravel.
  3. Le routeur lit routes/web.php et trouve PostController@index.
  4. Laravel crée un objet PostController et appelle index().
  5. Post::latest()->get() interroge la base et renvoie trois objets Post.
  6. view() transmet les trois articles à posts/index.blade.php, puis Blade produit le HTML.
  7. Laravel renvoie la réponse, code 200 et page HTML.

Le fichier public/index.php n'apparaît dans aucune déclaration de route. Le serveur l'exécute pourtant pour chaque requête qui ne correspond pas à un autre fichier public. Son rôle est détaillé au chapitre Structure d'un projet Laravel.

Le trajet d'une requête GET /posts en vue éclatée : navigateur, public/index.php, routes/web.php, PostController, Post, base SQLite, vue Blade, réponse

Le contrôleur coordonne le traitement : il demande les articles au modèle, puis fournit $posts à la vue. La vue utilise uniquement les données reçues et n'exécute aucune requête vers la base. Le modèle récupère les données sans dépendre du fichier HTML qui les affichera.

5. Ce que MVC évite, et ce qu'il n'est pas

Laravel fournit des dossiers et des conventions, mais il peut aussi exécuter du code placé au mauvais endroit. Un tel code fonctionne tout en mélangeant de nouveau les responsabilités réunies dans posts.php.

Le premier mélange place du SQL dans une vue. Ce template interroge directement la base :

@php
    $posts = DB::select('SELECT title FROM posts ORDER BY created_at DESC');
@endphp
@foreach ($posts as $post)
    <li>{{ $post->title }}</li>
@endforeach

Ce code affiche les trois titres. Pour changer le tri, il faut cependant modifier le template. Une autre vue qui affiche les articles devrait aussi recopier la requête. Placez la lecture des articles avec le modèle, puis faites transmettre $posts à la vue par le contrôleur.

Le deuxième mélange place du HTML dans un modèle. Cette méthode produit un morceau de page :

class Post extends Model
{
    public function toHtml(): string
    {
        return '<li><strong style="color: navy">' . $this->title . '</strong></li>';
    }
}

Changer une couleur oblige alors à modifier le modèle. Le titre n'est pas non plus échappé. Si la chaîne produite est insérée telle quelle dans la page, le navigateur exécutera un titre contenant <script>. Dans une vue Blade, {{ }} transforme ces caractères avant de produire le HTML.

Le troisième mélange place de la logique métier dans un contrôleur de 200 lignes. La logique métier regroupe les règles propres à votre application, comme « un article se lit à 200 mots par minute ». Ici, la règle est écrite dans le contrôleur :

public function index(): View
{
    $posts = Post::latest()->get();

    foreach ($posts as $post) {
        $words = str_word_count($post->body);
        $post->reading_time = max(1, (int) ceil($words / 200));
    }

    // … 190 lignes de plus : filtres, statistiques, envoi d'e-mails

    return view('posts.index', ['posts' => $posts]);
}

La page d'un article aura aussi besoin du temps de lecture. Si le calcul reste dans index(), il faudra le copier dans une deuxième méthode. Une correction risque alors de n'être appliquée qu'à une seule copie. Comme le calcul dépend du texte d'un article, placez-le dans une méthode de Post. Le contrôleur peut ainsi demander les articles au modèle et transmettre le résultat à la vue.

La séparation MVC ne suffit pas à rendre tout le code correct. Une requête placée dans un modèle peut rester lente, et le HTML d'une vue peut rester difficile à lire. Laravel exécute les trois mélanges précédents sans erreur. Vous devez donc vérifier vous-même que chaque responsabilité se trouve dans la partie prévue.

6. Où ça mène

Chaque rôle peut traiter des cas plus complets.

  • Une route peut recevoir une valeur dans son chemin, comme /posts/{post}, et porter un nom.
  • Un contrôleur peut recevoir un formulaire, vérifier ses données et demander au navigateur d'ouvrir une autre adresse.
  • Plusieurs vues Blade peuvent partager la même mise en page.
  • Les modèles Eloquent peuvent être reliés, par exemple lorsqu'un article possède plusieurs commentaires.

À retenir

  • Le modèle lit et écrit les données, et porte les règles qui les concernent.
  • La vue fabrique le HTML à partir des données qu'elle reçoit, et {{ }} les échappe.
  • Le contrôleur organise le traitement, demande les données au modèle et transmet le résultat à la vue.
  • Le routeur relie une adresse à une méthode d'un contrôleur, dans routes/web.php.
  • MVC sépare le code selon sa responsabilité. Laravel peut néanmoins exécuter du SQL placé dans une vue.

Exercices

1. Ranger huit extraits (sur papier, 10 minutes)

Pour chaque extrait, écrivez M pour modèle, V pour vue, C pour contrôleur ou R pour routeur. Pour choisir, repérez la responsabilité du code et le type de demande qui obligerait à le modifier.

  1. Route::get('/about', [PageController::class, 'about']);
  2. <h2>{{ $post->title }}</h2>
  3. $table->string('title');
  4. return view('posts.show', ['post' => $post]);
  5. @foreach ($comments as $comment)
  6. class Comment extends Model
  7. $posts = Post::latest()->get();, dans une méthode index()
  8. public function readingTime(): int, qui calcule le temps de lecture d'un article

2. Tracer GET /posts/3 (sur papier, 5 minutes)

Pour l'instant, http://127.0.0.1:8000/posts/3 répond 404 Not Found, car aucune route ne correspond à ce chemin. Vous devez prévoir une page qui affiche uniquement l'article numéro 3.

Appliquez les sept étapes de la section 4 à GET /posts/3. Pour chaque fichier, indiquez s'il reste identique, s'il doit changer ou s'il faut le créer. La base contient déjà l'article 3.

3. Les quatre fichiers dans premier-test-laravel (sur machine, 25 minutes, à faire avant le chapitre Structure, qui s'en sert)

Dans le projet premier-test-laravel créé au chapitre Installation :

  1. Lancez php artisan make:model Post -m. Ajoutez ensuite title et body à la migration comme dans la section 3, puis lancez php artisan migrate.
  2. Exécutez trois fois la commande ci-dessous pour ajouter trois articles. Modifiez chaque fois le titre, le texte et le nombre de jours. Utilisez 3 pour le premier article, puis 2 et 1. L'expression now()->subDays(3) produit la date d'il y a trois jours. Des dates différentes permettent à latest() de classer les articles.
php artisan tinker --execute="App\Models\Post::forceCreate(['title' => 'Bienvenue sur le blog', 'body' => 'Premier article du blog.', 'created_at' => now()->subDays(3)]);"

Tinker permet d'exécuter une instruction PHP dans le projet Laravel. Ici, forceCreate() crée un article avec les valeurs du tableau et l'enregistre dans la table posts.

Indice : un titre ou un texte qui contient une apostrophe, comme « Le trajet d'une requête » ou « De l'URL à la page HTML. », casse la chaîne entre apostrophes. Écrivez d\'une à la place de d'une, et l\'URL à la place de l'URL.

  1. Lancez php artisan make:controller PostController, puis recopiez la méthode index().
  2. Ajoutez la route dans routes/web.php et importez PostController en haut du fichier. Créez ensuite resources/views/posts/index.blade.php.
  3. Lancez composer run dev, puis vérifiez les deux résultats. La commande php artisan route:list doit afficher posts et PostController@index. L'adresse http://127.0.0.1:8000/posts doit afficher vos trois articles du plus récent au plus ancien. Avec Herd ou Laragon, utilisez http://premier-test-laravel.test/posts.

Si la page affiche une erreur, lisez la phrase située sous le nom de l'exception. Le message View [posts.index] not found. indique que Laravel ne trouve pas resources/views/posts/index.blade.php. Vérifiez notamment le nom du dossier posts. Le message Target class [PostController] does not exist. indique souvent que l'import manque dans routes/web.php. Dans ce cas, php artisan route:list affiche aussi Class "PostController" does not exist.

4. Six cartes

Que fait le modèle ?

Il fournit les méthodes qui lisent et écrivent les articles, ainsi que leurs règles. Dans le blog : app/Models/Post.php.

Que fait la vue ?

Elle fabrique le HTML à partir des données qu'elle reçoit. Dans le blog : resources/views/posts/index.blade.php.

Que fait le contrôleur ?

Il organise le traitement, demande les données au modèle et les transmet à la vue avec view().

Que fait le routeur ?

Il relie une adresse à une méthode d'un contrôleur. Les routes s'écrivent dans routes/web.php.

Que fait {{ $post->title }} de plus qu'un simple echo ?

Il échappe le titre : <, > et l'apostrophe deviennent des codes HTML, et un <script> s'affiche au lieu d'être exécuté.

Les titres de la liste doivent passer en rouge. Quel fichier ouvrez-vous ?

La vue, resources/views/posts/index.blade.php. Ni le modèle, ni le contrôleur, ni la route ne contiennent de HTML.