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.
Ă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
RequestInterfaceetResponseInterface, 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.
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.