Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Une requĂȘte web, sans framework

PHP peut servir une page web sans bibliothĂšque, sans code serveur et sans configuration, parce que traiter une requĂȘte HTTP est ce pour quoi le langage a Ă©tĂ© conçu. La requĂȘte est dĂ©jĂ  analysĂ©e quand votre script dĂ©marre, et la rĂ©ponse est ce que vous affichez. Un framework ajoute de la structure par-dessus, mais il n’ajoute pas la capacitĂ©.

Avoir vu une fois cette couche brute rend ensuite chaque framework lisible, parce qu’ils reposent tous sur exactement ces primitives.

Le contrĂŽleur frontal

Pointez le serveur de développement de PHP sur un seul fichier, et chaque URL passe par lui :

php -S localhost:8000 public/index.php

Ce fichier est le contrĂŽleur frontal. En production, le serveur web fait la mĂȘme chose avec une rĂšgle de réécriture (ou FrankenPHP et RoadRunner le font pour vous, comme le dĂ©crit Comment PHP s’exĂ©cute). Avec le serveur de dĂ©veloppement, un dĂ©tail compte : si le script renvoie false, le serveur sert le fichier demandĂ© depuis le disque, et c’est ainsi que passent les ressources statiques.

<?php
declare(strict_types=1);

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

if ($path !== '/' && is_file(__DIR__ . $path)) {
    return false; // let the built-in server send the CSS or image
}

echo 'Every other URL lands here: ', htmlspecialchars($path, ENT_QUOTES);

Lire la requĂȘte

La requĂȘte vit dans les superglobales, des tableaux que PHP remplit avant la premiĂšre ligne de votre code. $_GET contient la chaĂźne de requĂȘte, $_POST les champs d’un formulaire soumis, $_COOKIE les cookies, $_FILES les fichiers envoyĂ©s, et $_SERVER tout le reste : REQUEST_METHOD, REQUEST_URI, et chaque en-tĂȘte HTTP sous la forme HTTP_ suivi de son nom en majuscules, de sorte que Accept-Language devient $_SERVER['HTTP_ACCEPT_LANGUAGE'].

<?php
declare(strict_types=1);

$method = $_SERVER['REQUEST_METHOD'];
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1;
$name = trim($_POST['name'] ?? '');
$lang = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en';

$body = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);

$_POST n’est rempli que pour les corps application/x-www-form-urlencoded et multipart/form-data envoyĂ©s en POST. Un corps JSON, quelle que soit la mĂ©thode, se lit brut depuis php://input. Un formulaire envoyĂ© en PUT ou PATCH n’est pas analysĂ© du tout, sauf si vous le demandez : request_parse_body() (PHP 8.4) renvoie les champs et les fichiers pour ces mĂ©thodes aussi.

Rien dans ces tableaux n’est digne de confiance. HTTP_HOST est ce que le client a envoyĂ©, REQUEST_URI peut contenir n’importe quoi, et un champ attendu comme chaĂźne arrive sous forme de tableau si le client Ă©crit name[]=x. Traitez chaque valeur comme une entrĂ©e utilisateur non typĂ©e, validez-la avec filter_var ou vos propres vĂ©rifications, et seulement ensuite laissez-la approcher votre logique.

Une enveloppe Ă©tiquetĂ©e request s'ouvre sur quatre bacs Ă©tiquetĂ©s GET, POST, COOKIE et SERVER, qui alimentent un script dessinĂ© comme une page ; la sortie imprimĂ©e du script coule dans une seconde enveloppe Ă©tiquetĂ©e response, avec un petit autocollant d'en-tĂȘte collĂ© avant le corps

Écrire la rĂ©ponse

Tout ce que votre script affiche est le corps de la rĂ©ponse, qu’il s’agisse d’un echo, d’un print ou de texte placĂ© hors des balises <?php ?>. Le statut et les en-tĂȘtes se rĂšglent avec deux fonctions, Ă  appeler avant le premier octet de sortie, parce que les en-tĂȘtes voyagent en premier :

<?php
declare(strict_types=1);

http_response_code(201);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
setcookie('theme', 'dark', [
    'expires' => time() + 86400 * 30,
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

echo json_encode(['created' => true], JSON_THROW_ON_ERROR);

Affichez quoi que ce soit avant, mĂȘme un saut de ligne Ă©garĂ© devant <?php, et header() Ă©choue avec « headers already sent ». La mise en tampon de la sortie (ob_start() au dĂ©but, ob_end_flush() Ă  la fin) retient le corps en mĂ©moire jusqu’à la fin du script et rend l’ordre indiffĂ©rent ; c’est ce que font les frameworks.

Les gabarits sont du PHP

PHP a commencĂ© comme langage de gabarits, et il l’est toujours : un fichier HTML avec des <?= $expr ?> dedans est un gabarit, et un include suffit Ă  l’afficher. La seule rĂšgle porte sur la sortie : Ă©chappez chaque valeur avec htmlspecialchars avant qu’elle n’atterrisse dans le HTML, sinon le premier utilisateur nommĂ© <script> est propriĂ©taire de votre page.

<?php
declare(strict_types=1);

function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$todos = ['Write the chapter', 'Escape <everything>'];
?>
<ul>
<?php foreach ($todos as $todo): ?>
    <li><?= e($todo) ?></li>
<?php endforeach; ?>
</ul>

La forme foreach (...): ... endforeach; existe prĂ©cisĂ©ment pour cet entrelacement. Un utilitaire e() de deux lignes est toute l’histoire de l’échappement pour le HTML ; les attributs, les URL et les contextes JavaScript demandent chacun leur propre encodage, et c’est la partie que les moteurs de gabarits automatisent.

Une application complĂšte

L’application ci-dessous fonctionne telle quelle, en un seul fichier : une liste de notes stockĂ©es dans SQLite, avec un formulaire pour en ajouter une. Elle tourne avec php -S localhost:8000 index.php et rien d’autre.

<?php
declare(strict_types=1);

$db = new PDO('sqlite:' . __DIR__ . '/notes.db', options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL)');

function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$route = $_SERVER['REQUEST_METHOD'] . ' ' . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

match ($route) {
    'GET /' => (function () use ($db): void {
        $notes = $db->query('SELECT id, body FROM notes ORDER BY id DESC')->fetchAll();
        echo '<h1>Notes</h1><form method="post" action="/notes">',
            '<input name="body" required> <button>Add</button></form><ul>';
        foreach ($notes as $note) {
            echo '<li>', e($note['body']), '</li>';
        }
        echo '</ul>';
    })(),
    'POST /notes' => (function () use ($db): void {
        $body = trim($_POST['body'] ?? '');
        if ($body === '') {
            http_response_code(422);
            echo 'A note needs a body.';
            return;
        }
        $stmt = $db->prepare('INSERT INTO notes (body) VALUES (:body)');
        $stmt->execute(['body' => $body]);
        http_response_code(303);
        header('Location: /');
    })(),
    default => (function (): void {
        http_response_code(404);
        echo 'Not found';
    })(),
};

Ce fichier illustre l’essentiel de ce qu’il y a Ă  savoir. Le routeur est un match sur la mĂ©thode et le chemin, ce qui tient jusqu’à une dizaine de routes avant que vous n’en vouliez un vrai. PDO est l’API de base de donnĂ©es, une seule interface pour SQLite, MySQL, PostgreSQL et d’autres, et ERRMODE_EXCEPTION transforme chaque Ă©chec en PDOException levĂ©e plutĂŽt qu’en false que vous oubliez de vĂ©rifier. La requĂȘte utilise un marqueur nommĂ© et execute() lie la valeur ; le texte SQL et les donnĂ©es ne se rencontrent jamais sous forme de chaĂźne, donc aucune injection SQL Ă  craindre. PHP 8.4 ajoute des sous-classes par pilote (Pdo\Sqlite, Pdo\Mysql, Pdo\Pgsql) via Pdo::connect(), qui exposent les spĂ©cificitĂ©s de chaque pilote avec des types corrects.

Construire le SQL par interpolation, "WHERE id = $id", est la seule habitude des vieux tutoriels PHP que le langage vous laisse encore garder, et c’est prĂ©cisĂ©ment celle qu’il faut abandonner.

Sessions et mots de passe

Une session est un stockage cĂŽtĂ© serveur indexĂ© par un cookie. Appelez session_start() avant toute sortie, et $_SESSION devient un tableau qui survit d’une requĂȘte Ă  l’autre pour ce visiteur. Par dĂ©faut, les donnĂ©es vivent dans des fichiers sur le serveur ; les frameworks y substituent une base de donnĂ©es ou un cache via session_set_save_handler(). Le cookie ne transporte que l’identifiant de session.

<?php
declare(strict_types=1);

session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $ok = password_verify($_POST['password'] ?? '', $storedHash ?? '');
    if ($ok) {
        session_regenerate_id(true);
        $_SESSION['user_id'] = 42;
    }
}

$csrf = $_SESSION['csrf'] ??= bin2hex(random_bytes(32));

password_hash et password_verify, vus dans ChaĂźnes, nombres, dates et JSON, suffisent pour les mots de passe, Ă  condition de rĂ©gĂ©nĂ©rer l’identifiant de session Ă  la connexion. Pour la protection CSRF, mettez un jeton alĂ©atoire dans la session, imprimez-le en champ cachĂ© dans chaque formulaire, et comparez-le Ă  la soumission avec hash_equals() ; cela tient en quatre lignes, et chaque framework fait la mĂȘme chose sous un nom plus flatteur.

La couche des standards

Les superglobales et header() fonctionnent, mais c’est de l’état global, ce qui rend le code difficile Ă  tester et impossible Ă  composer. Le PHP-FIG a rĂ©pondu par des interfaces :

  • PSR-7 dĂ©finit des objets immuables RequestInterface et ResponseInterface, de sorte qu’une requĂȘte est une valeur que vous passez et une rĂ©ponse une valeur que vous renvoyez.
  • PSR-15 dĂ©finit l’intergiciel (middleware) : un gestionnaire prend une requĂȘte et renvoie une rĂ©ponse, et un middleware enveloppe des gestionnaires. Authentification, CORS, journalisation, limitation de dĂ©bit sont chacun une classe.
  • PSR-17 dĂ©finit les fabriques qui crĂ©ent ces objets, pour qu’une bibliothĂšque ne dĂ©pende jamais d’une implĂ©mentation prĂ©cise.
  • PSR-18 dĂ©finit un client HTTP, le cĂŽtĂ© sortant des mĂȘmes objets.

Une bibliothĂšque Ă©crite contre PSR-7 et PSR-15 tourne dans tout framework qui les parle, c’est-Ă -dire aujourd’hui la plupart. CakePHP, Laminas, Laravel, Symfony et Yii, et les micro-frameworks Mezzio et Slim, ajoutent chacun routage, injection de dĂ©pendances, gabarits et couche de base de donnĂ©es par-dessus ces primitives ; les primitives en dessous sont celles que vous venez de voir.

Des anneaux concentriques dessinĂ©s comme un oignon coupĂ© en deux, Ă©tiquetĂ©s de l'extĂ©rieur vers l'intĂ©rieur : logging, auth, CORS, et au centre une petite boĂźte Ă©tiquetĂ©e handler ; une flĂšche Ă©tiquetĂ©e request entre par la gauche Ă  travers chaque anneau et une flĂšche Ă©tiquetĂ©e response ressort Ă  droite Ă  travers les mĂȘmes anneaux

En production, le contrĂŽleur frontal reste le mĂȘme, et seul change ce qui l’appelle : PHP-FPM derriĂšre nginx, Apache ou Caddy, ou un runtime persistant comme FrankenPHP ou RoadRunner. Rien dans ce chapitre n’a besoin d’ĂȘtre adaptĂ© Ă  l’un ou Ă  l’autre.

L’application ci-dessus n’a ni tests ni analyse statique, et Tests, analyse statique et outillage y remĂ©die.