Kodokon kodokon.com

Une architecture MVC artisanale

Écrivez un micro-framework MVC complet - routeur à expressions régulières, contrôleurs injectés, vues temporisées - pour comprendre ce que les frameworks font à votre place.

11 min · 3 questions

Ouvrir cette leçon dans Kodokon

Comprendre un framework, c'est savoir en écrire un petit. Toute architecture MVC moderne repose sur un contrôleur frontal : un unique index.php reçoit toutes les requêtes (le serveur y réécrit chaque URL), puis un routeur fait correspondre méthode HTTP et chemin à un gestionnaire. Le nôtre convertit des motifs comme /posts/{id} en expressions régulières à groupes de capture nommés : preg_match remplit alors $found['id'] directement, sans découpage manuel de l'URL.

PHP
<?php
declare(strict_types=1);

final class Router
{
    private array $routes = [];

    public function add(
        string $method,
        string $pattern,
        callable $handler
    ): void {
        $regex = preg_replace(
            '#\{(\w+)\}#',
            '(?P<$1>[^/]+)',
            $pattern
        );
        $this->routes[] = [
            $method, "#^$regex$#", $handler
        ];
    }

    public function dispatch(
        string $method,
        string $uri
    ): mixed {
        foreach ($this->routes as $route) {
            [$m, $re, $handler] = $route;
            if ($m !== $method) {
                continue;
            }
            if (preg_match($re, $uri, $found) === 1) {
                return $handler($found);
            }
        }
        http_response_code(404);
        return 'Page not found';
    }
}
Le motif /posts/{id} devient #^/posts/(?P<id>[^/]+)$#.

Le contrôleur orchestre : il lit les paramètres, interroge le modèle - seule couche autorisée à parler à la base - et choisit une vue. Les dépendances entrent par le constructeur : c'est de l'injection de dépendances sans conteneur, et la promotion de propriétés readonly garantit qu'elles ne changeront plus après construction. Le modèle rend null explicite quand la ressource n'existe pas, plutôt que false : le contrôleur peut alors décider du 404.

PHP
<?php
declare(strict_types=1);

final class PostModel
{
    public function __construct(
        private readonly PDO $pdo
    ) {}

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM posts WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row === false ? null : $row;
    }
}

final class PostController
{
    public function __construct(
        private readonly PostModel $posts
    ) {}

    public function show(array $params): string
    {
        $id = (int) $params['id'];
        $post = $this->posts->find($id);
        if ($post === null) {
            http_response_code(404);
            return render('not-found');
        }
        return render('post', ['post' => $post]);
    }
}
La fonction render est définie dans le bloc suivant.

La vue est un simple fichier PHP qui n'affiche que des variables déjà préparées. Deux mécanismes internes la rendent possible : extract transforme le tableau de données en variables locales, et la temporisation de sortie (ob_start puis ob_get_clean) capture l'affichage pour le retourner en chaîne au lieu de l'envoyer immédiatement - indispensable pour composer des gabarits imbriqués ou décider des en-têtes après le rendu.

PHP
<?php
declare(strict_types=1);

function render(
    string $view,
    array $data = []
): string {
    extract($data, EXTR_SKIP);
    ob_start();
    require __DIR__ . "/views/$view.php";
    return (string) ob_get_clean();
}

$pdo = new PDO('sqlite:blog.db');
$controller = new PostController(
    new PostModel($pdo)
);

$router = new Router();
$router->add(
    'GET',
    '/posts/{id}',
    fn (array $p): string => $controller->show($p)
);

$path = parse_url(
    $_SERVER['REQUEST_URI'],
    PHP_URL_PATH
);

echo $router->dispatch(
    $_SERVER['REQUEST_METHOD'],
    (string) $path
);
Le contrôleur frontal complet : rendu, câblage et répartition.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Dans ce routeur, que produit la conversion du motif /posts/{id} ?
    • Une expression régulière avec un groupe de capture nommé, accessible ensuite via $found['id']
    • Un tableau associatif des segments de l'URL
    • Une simple comparaison de chaînes segment par segment
  2. Pourquoi la fonction render utilise-t-elle ob_start ?
    • Pour compresser la sortie HTML avant l'envoi
    • Pour capturer l'affichage de la vue et le retourner en chaîne au lieu de l'envoyer immédiatement au navigateur
    • Pour empêcher la vue d'exécuter du code PHP
    • Pour mettre le rendu de la vue en cache sur le disque
  3. Quel est le rôle du contrôleur frontal ?
    • Servir les fichiers statiques plus rapidement
    • Vérifier les droits de l'utilisateur avant chaque page
    • Être l'unique point d'entrée de l'application : toute requête y est réécrite, puis déléguée au routeur