← Toutes les fiches

Fiche 5 Parcours : les fondamentaux Lecture : 15 minutes

PHP moderne

Laisser le langage attraper les bugs à ta place, avant qu'ils n'arrivent en production.

Le PHP d'il y a quinze ans acceptait presque tout : une chaîne à la place d'un nombre, une propriété modifiée de n'importe où, un null qui se promène jusqu'à l'erreur. Le code tournait, et les bugs apparaissaient plus tard, loin de leur cause.

Depuis PHP 7 et surtout PHP 8, le langage permet de dire ce qu'on attend : quel type, quelles valeurs possibles, ce qui peut changer ou non. En échange, il refuse tout de suite ce qui ne correspond pas. Chaque outil de cette fiche transforme un bug silencieux en erreur visible, au moment où on peut encore la corriger facilement.

1.strict_types : refuser les conversions silencieuses

Par défaut, quand une fonction attend un int et reçoit autre chose, PHP essaie de convertir la valeur. On appelle ça le mode coercitif :

exemples/05/strict_off.php
<?php

// Sans declare(strict_types=1) : PHP convertit les valeurs en silence (ou presque)

function addToCart(int $quantity): string
{
    return "Ajout de $quantity article(s)";
}

echo addToCart('12'), "\n";     // la chaîne '12' devient l'entier 12
echo addToCart(2.7), "\n";      // 2,7 devient 2 : on perd 0,7 article
echo addToCart(true), "\n";     // true devient 1
Sortie
Ajout de 12 article(s)

Deprecated: Implicit conversion from float 2.7 to int loses precision in /app/exemples/05/strict_off.php on line 5
Ajout de 2 article(s)
Ajout de 1 article(s)

Ajouter 2,7 articles au panier et en obtenir 2, sans erreur, juste un avertissement dans les logs : voilà le genre de bug qui coûte des heures. Avec declare(strict_types=1); en tête de fichier, PHP passe en mode strict :

exemples/05/strict_on.php
<?php

declare(strict_types=1);

// Avec strict_types : un mauvais type est une erreur, détectée tout de suite

function addToCart(int $quantity): string
{
    return "Ajout de $quantity article(s)";
}

echo addToCart(12), "\n";

foreach (['12', 2.7, true] as $wrongValue) {
    try {
        addToCart($wrongValue);
    } catch (TypeError $error) {
        echo get_debug_type($wrongValue), ' refusé : ', $error->getMessage(), "\n";
    }
}
Sortie
Ajout de 12 article(s)
string refusé : addToCart(): Argument #1 ($quantity) must be of type int, string given, called in /app/exemples/05/strict_on.php on line 16
float refusé : addToCart(): Argument #1 ($quantity) must be of type int, float given, called in /app/exemples/05/strict_on.php on line 16
bool refusé : addToCart(): Argument #1 ($quantity) must be of type int, true given, called in /app/exemples/05/strict_on.php on line 16
  1. La déclaration se met en première ligne de chaque fichier, juste après <?php.
  2. Elle s'applique aux appels faits depuis ce fichier. C'est pourquoi on la met partout, sans exception.
  3. Une seule conversion reste permise : un int là où un float est attendu.
Sécurité : strict_types ne valide pas les entrées

Tout ce qui arrive d'une URL ou d'un formulaire est une chaîne. Pour obtenir un entier, on est tenté de faire un cast (int), mais il ne vérifie rien :

exemples/05/user_input.php
<?php

declare(strict_types=1);

// Ce qui arrive d'un formulaire ou d'une URL est TOUJOURS une chaîne.
// Un cast (int) ne vérifie rien : il prend ce qu'il peut, en silence.
foreach (['42', '42abc', 'abc', ''] as $raw) {
    $cast = (int) $raw;
    $validated = filter_var($raw, FILTER_VALIDATE_INT);
    printf(
        "%-7s cast : %-3d  filter_var : %s\n",
        var_export($raw, true),
        $cast,
        var_export($validated, true),
    );
}
Sortie
'42'    cast : 42   filter_var : 42
'42abc' cast : 42   filter_var : false
'abc'   cast : 0    filter_var : false
''      cast : 0    filter_var : false

'42abc' devient 42 et 'abc' devient 0, sans erreur. Un identifiant invalide peut ainsi désigner un autre enregistrement. Valide d'abord (filter_var, les contraintes du composant Validator, ou les paramètres de route typés de Symfony), convertis ensuite.

2.Des types partout

Paramètres, valeurs de retour et propriétés peuvent tous être typés. Chaque type est une information pour le lecteur, pour l'éditeur (l'autocomplétion) et pour PHPStan (section 8).

Quand se méfier

Une union large comme string|int|array|null est souvent le signe qu'une fonction fait trop de choses (fiche 2). Et un tableau typé array ne dit pas ce qu'il contient : quand sa forme compte, une petite classe ou un PHPDoc (list<Product>) vaut mieux.

3.Les enums : une liste fermée de valeurs

Un statut de commande stocké dans une chaîne peut valoir 'paid', 'Paid', 'payed'… et rien ne t'avertit d'une faute de frappe. Une enum (énumération) déclare la liste complète des valeurs possibles. Toute autre valeur devient impossible.

exemples/05/enum.php
<?php

declare(strict_types=1);

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        // match doit couvrir tous les cas : un cas oublié lève une erreur
        return match ($this) {
            self::Pending => 'En attente de paiement',
            self::Paid => 'Payée',
            self::Shipped => 'Expédiée',
            self::Cancelled => 'Annulée',
        };
    }

    public function canBeCancelled(): bool
    {
        return $this === self::Pending || $this === self::Paid;
    }
}

$status = OrderStatus::Paid;
echo $status->label(), ' (en base : ', $status->value, ")\n";
echo 'Annulable : ', var_export($status->canBeCancelled(), true), "\n";

// Depuis la base de données ou un formulaire : une chaîne à convertir
var_dump(OrderStatus::tryFrom('shipped'));   // le cas correspondant
var_dump(OrderStatus::tryFrom('refunded'));  // null : valeur inconnue

try {
    OrderStatus::from('refunded');           // from() lève une erreur
} catch (ValueError $error) {
    echo $error->getMessage(), "\n";
}
Sortie
Payée (en base : paid)
Annulable : true
enum(OrderStatus::Shipped)
NULL
"refunded" is not a valid backing value for enum OrderStatus
  1. Une enum « adossée » (backed enum, ici : string) associe une valeur à chaque cas. C'est elle qu'on stocke en base, et Doctrine sait la convertir automatiquement.
  2. L'enum peut porter des méthodes : le libellé, les règles (canBeCancelled). La logique liée au statut vit au même endroit que le statut.
  3. from() lève une ValueError pour une valeur inconnue. tryFrom() renvoie null, que tu dois ensuite traiter.
Sécurité

Pour une valeur qui vient de l'utilisateur, préfère tryFrom() et réponds par une erreur claire (400 ou 404). Avec from(), une valeur inventée provoque une erreur 500 : c'est une page d'erreur de plus, et un bruit de plus dans les logs. Symfony sait aussi convertir directement un paramètre de route en enum, et répond 404 si la valeur n'existe pas.

4.readonly et la promotion de propriétés

Un objet qui peut être modifié de partout est difficile à suivre : qui a changé ce montant, et quand ? Une propriété readonly ne peut être écrite qu'une seule fois, dans le constructeur. Une classe readonly applique cette règle à toutes ses propriétés.

exemples/05/readonly.php
<?php

declare(strict_types=1);

// readonly : chaque propriété s'écrit une seule fois, dans le constructeur.
// La promotion de propriétés déclare et remplit la propriété en une ligne.
final readonly class Money
{
    public function __construct(
        public int $amountInCents,
        public string $currency,
    ) {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('Un montant ne peut pas être négatif');
        }
    }

    // Pas de modification : on renvoie un NOUVEL objet
    public function add(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException('Devises différentes');
        }

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }
}

$price = new Money(1990, 'EUR');
$total = $price->add(new Money(499, 'EUR'));
echo $price->amountInCents, ' + 499 = ', $total->amountInCents, "\n";

try {
    $price->amountInCents = 0;
} catch (Error $error) {
    echo $error->getMessage(), "\n";
}
Sortie
1990 + 499 = 2489
Cannot modify readonly property Money::$amountInCents
Quand ne pas l'utiliser

Les entités Doctrine changent d'état au cours de leur vie (une commande passe de « payée » à « expédiée ») : elles ne peuvent pas être entièrement readonly. Réserve-le aux objets-valeurs (montant, adresse, période), aux DTO et aux services. Attention aussi : readonly protège la propriété, pas le contenu d'un objet qu'elle référence.

5.match plutôt que switch

Un délai de livraison qui dépend du pays, écrit deux fois. Regarde ce que donne la France dans chaque version :

exemples/05/match.php
<?php

declare(strict_types=1);

// switch : comparaison souple, et un break oublié « tombe » dans le cas suivant
function shippingDelaySwitch(string $country): string
{
    switch ($country) {
        case 'FR':
            $delay = '2 jours';
            // break oublié !
        case 'BE':
            $delay = '3 jours';
            break;
        default:
            $delay = '7 jours';
    }

    return $delay;
}

// match : une expression qui renvoie une valeur, comparaison stricte, pas de break
function shippingDelay(string $country): string
{
    return match ($country) {
        'FR' => '2 jours',
        'BE', 'LU' => '3 jours',
        default => '7 jours',
    };
}

echo 'switch FR : ', shippingDelaySwitch('FR'), "\n";
echo 'match  FR : ', shippingDelay('FR'), "\n";

// Sans default, une valeur imprévue lève une erreur au lieu de passer en silence
try {
    echo match ('DE') { 'FR' => 'France', 'BE' => 'Belgique' };
} catch (UnhandledMatchError $error) {
    echo $error->getMessage(), "\n";
}
Sortie
switch FR : 3 jours
match  FR : 2 jours
Unhandled match case 'DE'
  1. match est une expression : elle renvoie une valeur, qu'on peut affecter ou retourner directement.
  2. Elle compare avec ===, alors que switch compare avec == (fiche 2).
  3. Pas de break, donc pas de « chute » accidentelle dans le cas suivant, comme ici avec la France.
  4. Une valeur non prévue lève UnhandledMatchError au lieu de passer en silence. Avec une enum, c'est précieux : si quelqu'un ajoute un cas, chaque match qui l'oublie le signale.
Quand garder switch

Quand chaque cas exécute plusieurs instructions plutôt que de calculer une valeur. Même là, une série de if avec retours anticipés (fiche 2), ou une méthode par cas, est souvent plus claire.

6.Exception ou null ?

Quand une fonction ne peut pas donner le résultat attendu, elle a deux façons de le dire. La règle : null pour un cas normal, exception pour un cas anormal.

exemples/05/null_or_exception.php
<?php

declare(strict_types=1);

final class CouponExpired extends RuntimeException {}

final class CouponRepository
{
    private const array COUPONS = [
        'NOEL10' => ['percent' => 10, 'expiresAt' => '2026-12-31'],
        'ETE20' => ['percent' => 20, 'expiresAt' => '2026-08-31'],
    ];

    // Absent : un cas normal (code mal tapé). null, et le type ?array le dit.
    public function find(string $code): ?array
    {
        return self::COUPONS[$code] ?? null;
    }
}

final class CouponService
{
    public function __construct(private CouponRepository $coupons) {}

    // Utilisé alors qu'il est expiré : une règle métier violée. Exception.
    public function discountPercent(string $code, string $today): int
    {
        $coupon = $this->coupons->find($code);
        if ($coupon === null) {
            return 0;
        }
        if ($today > $coupon['expiresAt']) {
            throw new CouponExpired("Le code $code a expiré le {$coupon['expiresAt']}");
        }

        return $coupon['percent'];
    }
}

$service = new CouponService(new CouponRepository());
foreach (['NOEL10', 'INCONNU', 'ETE20'] as $code) {
    try {
        $percent = $service->discountPercent($code, '2026-11-03');
        echo $code, ' : ', $percent, " %\n";
    } catch (CouponExpired $error) {
        echo $code, ' : refusé (', $error->getMessage(), ")\n";
    }
}
Sortie
NOEL10 : 10 %
INCONNU : 0 %
ETE20 : refusé (Le code ETE20 a expiré le 2026-08-31)
  1. Un code promo introuvable est un cas normal : quelqu'un a mal tapé. find() renvoie null, et le type ?array oblige l'appelant à y penser.
  2. Un code promo expiré utilisé quand même viole une règle métier. On lève une exception, avec un nom qui dit le problème (CouponExpired), et le code qui sait quoi faire l'attrape.
  3. Une exception dédiée se rattrape précisément : catch (CouponExpired) ne capture pas, par erreur, une panne de base de données.
Sécurité

Le message d'une exception est fait pour les développeurs et les logs, pas pour l'utilisateur. Il peut contenir un chemin de fichier, une requête SQL ou une donnée personnelle. En production (APP_DEBUG=0), Symfony affiche une page d'erreur générique : n'affiche jamais $error->getMessage() brut à l'écran, sauf pour une exception dont tu as écrit le message pour l'utilisateur.

7.Ce qu'apporte PHP 8.4

PHP 8.4 ajoute deux outils qui remplacent beaucoup de getters et setters écrits à la main :

exemples/05/php84.php
<?php

declare(strict_types=1);

final class Product
{
    // Visibilité asymétrique : tout le monde peut lire, seule la classe peut écrire
    public private(set) int $stock = 0;

    // Hook de propriété : la validation s'exécute à chaque écriture, même de l'extérieur
    public string $name {
        set(string $value) {
            $value = trim($value);
            if ($value === '') {
                throw new InvalidArgumentException('Le nom ne peut pas être vide');
            }
            $this->name = $value;
        }
    }

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function restock(int $quantity): void
    {
        $this->stock += $quantity;
    }
}

$product = new Product('  Clavier  ');
$product->restock(5);
echo "« {$product->name} », stock : {$product->stock}\n";

try {
    $product->stock = 1000;
} catch (Error $error) {
    echo $error->getMessage(), "\n";
}

try {
    $product->name = '   ';
} catch (InvalidArgumentException $error) {
    echo $error->getMessage(), "\n";
}
Sortie
« Clavier », stock : 5
Cannot modify private(set) property Product::$stock from global scope
Le nom ne peut pas être vide
Quand attendre

Ces nouveautés demandent PHP 8.4 en production et des outils à jour (PHPStan, PHP CS Fixer, ton éditeur). Sur un projet en PHP 8.2 ou 8.3, garde les getters : un code moderne qui ne tourne pas sur le serveur ne sert à rien. Et un hook qui contient de la logique métier lourde (appel en base, envoi d'e-mail) surprendrait le lecteur : un hook doit rester léger.

8.PHPStan : trouver les bugs sans exécuter le code

Les sections précédentes détectent les erreurs quand le code s'exécute. PHPStan est un outil d'analyse statique : il lit ton code, sans l'exécuter, et vérifie que les types sont cohérents d'un bout à l'autre. Voici le genre de bug qu'il trouve :

exemples/05/phpstan_bug.php · illustration, non exécuté
<?php

declare(strict_types=1);

final class UserRepository
{
    public function find(int $id): ?User { /* … */ }
}

final class WelcomeMailer
{
    public function __construct(private UserRepository $users) {}

    public function send(int $userId): void
    {
        $user = $this->users->find($userId);

        // Fonctionne dans 99 % des cas… et plante le jour où l'utilisateur n'existe pas.
        // PHPStan le signale avant même l'exécution : find() peut renvoyer null.
        $this->mail($user->getEmail());
    }

    private function mail(string $to): void { /* … */ }
}

Ici, PHPStan signale qu'on appelle getEmail() sur une valeur qui peut être null. Le bug est trouvé à l'écriture, pas le jour où un utilisateur supprimé déclenche une erreur 500.

  1. Installe-le avec composer require --dev phpstan/phpstan, et l'extension phpstan/phpstan-symfony pour qu'il comprenne le conteneur de services.
  2. Crée un fichier de configuration :
exemples/05/phpstan.neon · illustration, non exécuté
# phpstan.neon, à la racine du projet
parameters:
    level: 6          # de 0 (très tolérant) à 10 (très strict) : monte petit à petit
    paths:
        - src
        - tests
  1. Lance vendor/bin/phpstan analyse, corrige, puis monte d'un niveau quand tout est propre.
  2. Sur un projet existant avec des centaines d'erreurs, la baseline (--generate-baseline) enregistre les erreurs actuelles pour ne signaler que les nouvelles. Tu peux ainsi l'adopter dès aujourd'hui, et rembourser la dette petit à petit.
  3. Ajoute-le à l'intégration continue, à côté des tests (fiche 3).
À propos des exemples de cette section

PHPStan n'est pas installé dans l'environnement de ces fiches : l'extrait et la configuration marqués « illustration, non exécuté » n'ont pas été passés à PHPStan. Tous les autres exemples de la fiche ont été exécutés avec PHP 8.4.

Pièges fréquents

PiègeCe qui arriveLa parade
Oublier strict_types dans un fichierLes appels de ce fichier convertissent en silenceLa déclaration dans tous les fichiers, imposée par PHP CS Fixer
(int) $request->get('id')'42abc' devient 42, 'abc' devient 0Valider avant de convertir
Un statut stocké en chaîneUne faute de frappe crée un statut fantômeUne enum adossée
from() sur une saisie utilisateurUne erreur 500 pour une simple valeur invalidetryFrom() et une réponse 400 ou 404
Un break oublié dans un switchLe cas suivant s'exécute aussimatch
Renvoyer false ou -1 pour « pas trouvé »L'appelant oublie de tester, et utilise la valeurnull avec un type nullable, ou une exception
catch (Exception $e) partoutUne vraie panne est avalée en silenceDes exceptions dédiées, attrapées précisément
Afficher getMessage() à l'utilisateurDes détails techniques fuientUn message prévu pour lui, et les détails dans les logs

Checklist avant de proposer ton code

Exercice : moderniser une classe

Le fichier exemples/05/exercice.php contient une classe Subscription écrite à l'ancienne. Aujourd'hui, il affiche :

Sortie
monthly  'Actif'     990
yearly   'En pause'  825
monthly  NULL        1490
  1. Regarde la troisième ligne : pourquoi le libellé vaut-il NULL ? Est-ce que PHP t'a prévenu ?
  2. Ajoute strict_types et des types partout.
  3. Crée une enum SubscriptionStatus avec une méthode label() écrite avec match, et une enum Plan. Que devient la faute de frappe de la troisième ligne ?
  4. Utilise la promotion de propriétés, et rends la classe readonly si c'est pertinent. Ça l'est ici, mais pourquoi ne pourrait-on pas faire pareil avec une entité Doctrine dont le statut change ?
  5. Regarde monthlyPriceInCents() avec un abonnement annuel à 9 990 centimes. Quel type de valeur obtiens-tu ? Décide comment arrondir, et écris un test (fiche 3) qui fixe ce choix.
docker compose run --rm php php exemples/05/exercice.php

Conseils

  1. Modernise par petites touches. Ajoute strict_types et des types quand tu touches un fichier, avec des tests pour vérifier que rien ne casse. Un grand chantier « tout moderniser » est risqué et rarement terminé.
  2. Laisse les outils t'aider. Rector peut automatiser une bonne partie des mises à jour de syntaxe, et PHP CS Fixer peut imposer strict_types. Relis toujours ce qu'ils changent.
  3. Lis les notes de version. Chaque version de PHP a sa page « migration » sur php.net, avec les nouveautés et les changements incompatibles.
  4. Un type est une documentation qui ne ment pas. Un commentaire peut être faux, un type est vérifié à chaque appel.

Documentation officielle