← Toutes les fiches

Fiche 2 Parcours : les fondamentaux Lecture : 15 minutes

Écrire du code lisible

Du code que tu comprendras encore dans six mois, et que d'autres pourront modifier sans avoir peur de tout casser.

Un code est lu bien plus souvent qu'il n'est écrit : par toi dans six mois, par un collègue qui corrige un bug, par la personne qui fait ta revue de code. Chaque minute passée à deviner ce qu'il fait est une minute perdue, et chaque malentendu est un bug possible.

La lisibilité n'est pas un talent. C'est un ensemble d'habitudes simples, que cette fiche présente une par une sur un même exemple. Aucune n'est une loi : chacune a un coût, et on verra quand ne pas l'appliquer.

1.Le point de départ

Voici une fonction qui calcule le total d'une commande. Elle fonctionne. Prends une minute pour essayer de comprendre ce qu'elle fait.

exemples/02/before.php
<?php

declare(strict_types=1);

// calcul
function calc(array $u, array $c): int
{
    $t = 0;
    if (count($c) > 0) {
        foreach ($c as $i) {
            // on ajoute
            $t = $t + $i['p'] * $i['q'];
        }
        if ($u['t'] == 'vip') {
            $t = $t - intdiv($t * 10, 100);
        }
        if ($t < 5000) {
            $t = $t + 499;
        }
        // $t = round($t);
        return $t;
    } else {
        return 0;
    }
}

$c = [['p' => 1990, 'q' => 2], ['p' => 450, 'q' => 1]];
echo calc(['t' => 'vip'], $c), "\n";
echo calc(['t' => 'standard'], $c), "\n";
echo calc(['t' => 'standard'], []), "\n";
Sortie
4486
4929
0

Quelques questions viennent tout de suite :

  1. Que contiennent $u, $c, $t ? Et les clés 'p', 'q', 't' ?
  2. D'où viennent 10, 5000 et 499 ? Ce sont des euros, des centimes, un pourcentage ?
  3. Le commentaire // on ajoute apprend-il quelque chose ? Et la ligne commentée, faut-il la garder ?
  4. Pourquoi le cas du panier vide est-il traité tout en bas, dans un else ?

Chacune de ces questions correspond à une section de cette fiche.

2.Nommer

Un bon nom évite d'aller lire le code autour. C'est l'habitude qui rapporte le plus, pour le moins d'effort.

exemples/02/naming.php · illustration, non exécuté
<?php

// Flou : il faut lire tout le code autour pour comprendre
$d = 30;
$list = $repo->get(true);
if ($u->check()) { /* … */ }

// Clair : le nom dit ce que c'est, avec son unité si elle compte
$trialDurationInDays = 30;
$activeCustomers = $customerRepository->findActive();
if ($user->hasVerifiedEmail()) { /* … */ }
  1. Une variable dit ce qu'elle contient : $activeCustomers, pas $list ni $data.
  2. Une fonction dit ce qu'elle fait, avec un verbe : findActive(), sendInvoice(). Un booléen se lit comme une question : isActive, hasVerifiedEmail, canEdit.
  3. L'unité fait partie du nom quand elle compte : $priceInCents, $timeoutInSeconds. Une confusion entre euros et centimes ne se voit pas, mais elle coûte cher.
  4. Un nombre magique devient une constante. Un nombre magique est une valeur écrite en dur dont le sens ne se devine pas, comme 5000. FREE_SHIPPING_FROM_CENTS dit ce que c'est, et le jour où le seuil change, tu le modifies à un seul endroit.
  5. Pas d'abréviation maison. $cust, $qte, $tmp2 obligent à traduire. Les conventions connues de tous restent acceptées : $id, $i dans une boucle de trois lignes.
Quand ne pas s'acharner

La longueur d'un nom suit sa portée : $i suffit dans une boucle de trois lignes, alors qu'une propriété utilisée dans toute une classe mérite un nom complet. Une valeur évidente, comme le 100 qui convertit des centimes en euros, n'a pas besoin de constante.

Et ne renomme pas tout un vieux projet d'un coup : le diff devient illisible et le risque de casser quelque chose grimpe. Renomme ce que tu touches, au fil des modifications.

3.Des fonctions courtes, qui font une seule chose

La fonction calc fait trois choses : additionner les articles, appliquer la remise, ajouter les frais de port. Pour la comprendre, il faut tout lire d'un bloc. Si on la découpe, chaque morceau porte un nom qui explique ce qu'il fait.

Trois signes qu'une fonction en fait trop :

  1. Pour la décrire, tu as besoin du mot « et » (« elle calcule le total et envoie l'e-mail »).
  2. Des commentaires séparent des blocs à l'intérieur (// étape 2 : la remise). Chaque bloc est candidat pour devenir une fonction, dont le nom remplace le commentaire.
  3. Tu as du mal à lui trouver un nom. C'est souvent qu'elle fait plusieurs choses.

Les paramètres comptent aussi. Au-delà de trois, l'appel devient difficile à lire. Un booléen en paramètre est particulièrement trompeur :

exemples/02/flag_argument.php · illustration, non exécuté
<?php

// À la lecture de l'appel, impossible de savoir ce que veulent dire true et false
$mailer->sendInvoice($order, true, false);

// Option 1 : des arguments nommés (PHP 8) rendent l'appel lisible
$mailer->sendInvoice($order, attachPdf: true, copyToAccounting: false);

// Option 2 : si le booléen change tout le comportement, deux méthodes distinctes
$mailer->sendInvoiceWithPdf($order);
Quand ne pas découper

Il n'existe pas de nombre de lignes magique. Découper à l'excès crée l'effet inverse : pour suivre la logique, il faut sauter entre dix fonctions de deux lignes. Garde ensemble ce qui change ensemble, et découpe quand un morceau a un sens propre que tu peux nommer.

4.Les retours anticipés

Chaque if imbriqué ajoute un niveau d'indentation, et un contexte de plus à garder en tête. Le retour anticipé (en anglais guard clause, « clause de garde ») consiste à traiter d'abord les cas qui sortent tôt, puis à laisser le cas normal sans imbrication.

exemples/02/guard_clauses.php
<?php

declare(strict_types=1);

// Avant : chaque condition ajoute un niveau d'imbrication
function canEditNested(?array $user, array $article): bool
{
    if ($user !== null) {
        if ($user['active']) {
            if ($user['role'] === 'admin' || $article['authorId'] === $user['id']) {
                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    } else {
        return false;
    }
}

// Après : on écarte d'abord les cas qui échouent, le cas normal reste à gauche
function canEdit(?array $user, array $article): bool
{
    if ($user === null) {
        return false;   // visiteur non connecté
    }
    if (!$user['active']) {
        return false;   // compte désactivé
    }
    if ($user['role'] === 'admin') {
        return true;
    }

    return $article['authorId'] === $user['id'];
}

// Les deux versions doivent répondre la même chose dans tous les cas
$article = ['authorId' => 7];
$users = [
    'visiteur' => null,
    'auteur' => ['id' => 7, 'active' => true, 'role' => 'user'],
    'autre membre' => ['id' => 8, 'active' => true, 'role' => 'user'],
    'admin' => ['id' => 1, 'active' => true, 'role' => 'admin'],
    'compte inactif' => ['id' => 7, 'active' => false, 'role' => 'user'],
];
foreach ($users as $label => $user) {
    $before = var_export(canEditNested($user, $article), true);
    $after = var_export(canEdit($user, $article), true);
    printf("%-15s avant : %-5s  après : %s\n", $label, $before, $after);
}
Sortie
visiteur        avant : false  après : false
auteur          avant : true   après : true
autre membre    avant : false  après : false
admin           avant : true   après : true
compte inactif  avant : false  après : false

Les deux versions répondent exactement pareil, mais la seconde se lit de haut en bas comme une liste de règles. Le bas du script montre aussi une bonne habitude : quand tu réécris du code, vérifie que l'ancienne et la nouvelle version donnent le même résultat sur tous les cas. La fiche 3 montrera comment le faire avec de vrais tests.

Sécurité

Ce genre de fonction décide qui a le droit de faire quoi : c'est du contrôle d'accès. Une règle lisible est une règle qu'on peut relire et vérifier. Dans une imbrication de if/else, un return true mal placé passe facilement inaperçu, et ouvre l'accès à tout le monde. Dans Symfony, ces règles ont leur place dans un Voter, présenté dans la fiche 10.

Quand ne pas les utiliser

Certaines équipes préfèrent un seul return par fonction : respecte la convention de ton équipe. Et si une fonction a besoin de dix return pour être lisible, le vrai problème est sa longueur (section 3).

5.Des commentaires utiles

Le code dit ce qui se passe. Un bon commentaire dit pourquoi : la règle métier, la contrainte externe, le piège à éviter. Tout ce qu'on ne peut pas deviner en lisant le code.

exemples/02/comments.php · illustration, non exécuté
<?php

// ✗ Paraphrase le code : à supprimer
// on incrémente le compteur
$attempts++;

// ✗ Code commenté : Git s'en souvient déjà, à supprimer
// $total = round($total, 2);

// ✓ Explique POURQUOI : la règle métier ne se devine pas dans le code
// Le transporteur facture au palier de 500 g entamé (contrat 2026, annexe B).
$billedWeight = (int) ceil($weightInGrams / 500) * 500;

// ✓ Prévient d'un piège qu'on ne voit pas en lisant
// L'API de paiement renvoie le montant en centimes, PAS en euros.
$amountInCents = $response['amount'];

// ✓ Donne aux outils un type que PHP ne sait pas exprimer
/** @param list<array{priceInCents: int, quantity: int}> $items */
function subtotalInCents(array $items): int { /* … */ }
  1. Si un commentaire répète le code, supprime-le. S'il explique un nom obscur, renomme plutôt.
  2. Supprime le code commenté. Git en garde l'historique (fiche 4) : personne n'ose jamais le supprimer après toi.
  3. Un // TODO sans ticket ni date reste là pour toujours. Crée plutôt un ticket.
  4. Les blocs /** … */ (PHPDoc) servent surtout aux types que PHP ne sait pas écrire, comme « une liste de tableaux avec telles clés ». Des outils comme PHPStan (fiche 5) s'en servent pour trouver des bugs.

6.Le résultat

Voici la fonction de départ, avec les quatre habitudes appliquées.

exemples/02/after.php
<?php

declare(strict_types=1);

const VIP_DISCOUNT_PERCENT = 10;
const FREE_SHIPPING_FROM_CENTS = 5000;
const SHIPPING_FEE_CENTS = 499;

/**
 * Total à payer, en centimes : articles, remise VIP, puis frais de port.
 *
 * @param list<array{priceInCents: int, quantity: int}> $items
 */
function orderTotalInCents(string $customerType, array $items): int
{
    if ($items === []) {
        return 0;   // panier vide : pas de frais de port non plus
    }

    $total = subtotalInCents($items);
    if ($customerType === 'vip') {
        $total -= intdiv($total * VIP_DISCOUNT_PERCENT, 100);
    }

    return $total + shippingFeeInCents($total);
}

/** @param list<array{priceInCents: int, quantity: int}> $items */
function subtotalInCents(array $items): int
{
    $subtotal = 0;
    foreach ($items as $item) {
        $subtotal += $item['priceInCents'] * $item['quantity'];
    }

    return $subtotal;
}

function shippingFeeInCents(int $totalInCents): int
{
    return $totalInCents >= FREE_SHIPPING_FROM_CENTS ? 0 : SHIPPING_FEE_CENTS;
}

$items = [
    ['priceInCents' => 1990, 'quantity' => 2],
    ['priceInCents' => 450, 'quantity' => 1],
];
echo orderTotalInCents('vip', $items), "\n";
echo orderTotalInCents('standard', $items), "\n";
echo orderTotalInCents('standard', []), "\n";
Sortie
4486
4929
0

La sortie est identique à celle de la section 1 : le comportement n'a pas changé. Modifier la structure du code sans changer ce qu'il fait s'appelle un refactoring. Remarque aussi deux détails :

Sécurité : == ou ===

== compare de façon souple : si les deux chaînes ressemblent à des nombres, PHP les convertit avant de comparer. === compare de façon stricte : même type et même valeur.

exemples/02/loose_comparison.php
<?php

declare(strict_types=1);

// Deux chaînes différentes… mais qui ressemblent à des nombres (0 × 10^123 et 0 × 10^456)
$expected = '0e123';
$given = '0e456';

var_dump($expected == $given);              // comparaison souple : convertit en nombres
var_dump($expected === $given);             // comparaison stricte : compare les chaînes
var_dump(hash_equals($expected, $given));   // pour des jetons et des empreintes
Sortie
bool(true)
bool(false)
bool(false)

Avec ==, deux empreintes de mot de passe ou deux jetons différents peuvent être déclarés égaux. Utilise === par défaut, et hash_equals() pour comparer des secrets : elle prend toujours le même temps, ce qui empêche de deviner un jeton caractère par caractère en mesurant le temps de réponse.

7.Laisser les outils gérer la forme

Espaces, accolades, ordre des use : la forme compte pour la lecture, mais en débattre en revue de code est une perte de temps. La communauté PHP a une convention commune, le PER Coding Style (qui prolonge l'ancienne PSR-12), et un outil l'applique tout seul : PHP CS Fixer, très utilisé dans les projets Symfony.

  1. Installe l'outil dans le projet et choisis un jeu de règles (@Symfony ou @PER-CS).
  2. Lance-le avant chaque commit, ou laisse ton éditeur le faire à l'enregistrement.
  3. Ajoute-le à l'intégration continue pour qu'un code mal formaté soit signalé automatiquement.
Quand ne pas l'appliquer

Ne reformate pas tout un fichier dans le même commit qu'une correction de bug : le vrai changement disparaît au milieu de centaines de lignes modifiées. Fais deux commits séparés (fiche 4).

8.Lisible pour tout le monde : l'accessibilité

La lisibilité concerne aussi ce que tu produis pour les utilisateurs. Certains naviguent au clavier seulement, d'autres avec un lecteur d'écran (un logiciel qui lit la page à voix haute), d'autres zooment à 200 %. L'accessibilité, c'est faire en sorte que ton site fonctionne pour eux aussi. En France, le RGAA en fixe les règles pour les services publics, et l'Acte européen sur l'accessibilité impose depuis juin 2025 des obligations à une partie des entreprises privées, dont le commerce en ligne. Vérifie ce qui s'applique à ton projet.

Voici un formulaire courant :

exemples/02/form_before.html.twig · illustration, non exécuté
<div class="title">Inscription à la newsletter</div>
<div class="form">
    <input type="text" name="email" placeholder="Email">
    <img src="/img/secure.png">
    <div class="btn" onclick="subscribe()">OK</div>
</div>
  1. Pas de vrai titre : un lecteur d'écran permet de sauter de titre en titre, mais une <div class="title"> n'en est pas un.
  2. Pas de label : le placeholder disparaît dès qu'on tape, et beaucoup de lecteurs d'écran ne le lisent pas comme un nom de champ.
  3. Une image sans alt : le lecteur d'écran peut lire le nom du fichier, « secure point png ».
  4. Une <div> cliquable : impossible à atteindre avec la touche Tab, impossible à activer avec Entrée, et annoncée comme du simple texte.
  5. Pas de <form> : la touche Entrée n'envoie rien, et tout dépend de JavaScript.

La même chose en HTML sémantique, c'est-à-dire en utilisant les balises pour ce qu'elles signifient :

exemples/02/form_after.html.twig · illustration, non exécuté
<h2>Inscription à la newsletter</h2>
<form method="post" action="{{ path('newsletter_subscribe') }}">
    <label for="newsletter-email">Adresse e-mail</label>
    <input type="email" id="newsletter-email" name="email"
           autocomplete="email" required aria-describedby="newsletter-help">
    <p id="newsletter-help">Une lettre par mois, désinscription en un clic.</p>
    <img src="/img/secure.png" alt="">  {# image décorative : alt vide #}
    <input type="hidden" name="_token" value="{{ csrf_token('newsletter_subscribe') }}">
    <button type="submit">S'inscrire</button>
</form>

Avec les formulaires Symfony, {{ form_row(form.email) }} génère le label, l'id et leur lien à ta place. C'est une bonne raison de ne pas écrire tes champs à la main.

Sécurité

Le champ caché _token est un jeton CSRF. Sans lui, un autre site pourrait envoyer ce formulaire à la place de ton utilisateur, à son insu. Les formulaires Symfony l'ajoutent automatiquement. Un formulaire écrit à la main, comme ici, doit l'ajouter lui-même, et le contrôleur doit le vérifier. La fiche 10 y revient en détail.

Quand ne pas en faire trop

Les attributs aria-* sont puissants mais faciles à mal utiliser. La règle du W3C : si une balise HTML native fait le travail (<button>, <nav>, <label>), utilise-la plutôt que d'ajouter de l'ARIA sur une <div>. Un ARIA mal utilisé est pire que pas d'ARIA du tout.

Le test le plus simple : débranche ta souris et remplis ton formulaire avec Tab, Maj+Tab et Entrée. Tu verras tout de suite ce qui bloque.

Pièges fréquents

PiègeCe qui arriveLa parade
$data, $tmp, $xIl faut lire tout le code autour pour comprendreUn nom qui dit ce que la variable contient
Un nombre magiquePersonne ne sait ce que vaut 5000, ni où le changerUne constante nommée, avec son unité
Un booléen en paramètresend($o, true, false) est illisibleDes arguments nommés, ou deux méthodes
Des if imbriqués sur quatre niveauxUn else oublié passe inaperçuDes retours anticipés
Du code commentéPersonne n'ose le supprimer, il pourritLe supprimer : Git s'en souvient
== par habitude'0e123' == '0e456' vaut true=== par défaut, hash_equals() pour les secrets
Reformater et corriger dans le même commitLe vrai changement est noyé dans le diffDeux commits séparés
Un placeholder à la place d'un labelLe champ n'a plus de nom dès qu'on tape<label for>, ou form_row()
Une <div> cliquableInutilisable au clavier et au lecteur d'écran<button> ou <a>

Checklist avant de proposer ton code

Exercice : refactorer sans rien casser

Partie 1. Le fichier exemples/02/exercice.php existe déjà. Voici ce qu'il affiche aujourd'hui :

Sortie
Array
(
    [0] => alice@example.com
    [1] => dave@example.com
)
  1. Lis la fonction f et écris en une phrase ce qu'elle fait. Si tu bloques, c'est justement le problème que tu vas corriger.
  2. Renomme la fonction, ses paramètres, ses variables et les clés du tableau ('e', 'a', 'l').
  3. Remplace 2592000 par une constante nommée. Indice : combien de jours cela fait-il ?
  4. Supprime l'imbrication avec des retours anticipés (dans une boucle, c'est continue qui joue ce rôle), et corrige les comparaisons.
  5. Nettoie les commentaires.
  6. Lance le fichier : la sortie doit rester exactement la même.
docker compose run --rm php php exemples/02/exercice.php

Partie 2. Rends ce bouton accessible et explique chacun de tes changements :

<div class="modal-close" onclick="closeModal()">
    <img src="/img/cross.svg">
</div>

Indice : le bouton n'a pas de texte visible. Comment le lecteur d'écran saura-t-il quoi annoncer ?

Envoie-moi tes deux parties quand tu veux, on en fera la revue ensemble.

Conseils

  1. Améliore un peu ce que tu touches. Quand tu corriges un bug dans une fonction, renomme une variable obscure ou extrais un bloc. Petit à petit, le code s'améliore sans grand chantier risqué.
  2. Relis ton code le lendemain. Ce qui te paraît évident aujourd'hui ne le sera plus demain. Le recul révèle les mauvais noms.
  3. Demande en revue « qu'est-ce qui n'est pas clair ? » Les questions de tes collègues montrent exactement où le code manque de clarté.
  4. Lis du bon code. Le code source de Symfony est un bon modèle : ouvre une classe que tu utilises tous les jours, comme Request, et regarde comment elle est nommée et découpée.
  5. Ne vise pas la perfection. Un code lisible n'est pas un code parfait. C'est un code que la prochaine personne comprendra sans t'appeler.

Documentation officielle