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 :
<?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 1Ajout 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 :
<?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";
}
}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
- La déclaration se met en première ligne de chaque fichier, juste après
<?php. - Elle s'applique aux appels faits depuis ce fichier. C'est pourquoi on la met partout, sans exception.
- Une seule conversion reste permise : un
intlà où unfloatest attendu.
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 :
<?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),
);
}'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).
?Usersignifie « unUserounull». C'est un type nullable, raccourci deUser|null.int|floatest un type union : l'une ou l'autre valeur est acceptée.voidindique qu'une fonction ne renvoie rien,neverqu'elle ne rend jamais la main (elle lève toujours une exception, ou arrête le script).mixedaccepte tout. C'est un aveu : « je ne sais pas ». Il est parfois nécessaire, mais chaquemixedest un endroit où PHP ne te protège plus.
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.
<?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";
}Payée (en base : paid) Annulable : true enum(OrderStatus::Shipped) NULL "refunded" is not a valid backing value for enum OrderStatus
- 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. - 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. from()lève uneValueErrorpour une valeur inconnue.tryFrom()renvoienull, que tu dois ensuite traiter.
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.
<?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";
}1990 + 499 = 2489 Cannot modify readonly property Money::$amountInCents
- La promotion de propriétés (
public int $amountInCentsdans les paramètres du constructeur) déclare la propriété et la remplit en une seule ligne. - Le constructeur valide : un objet
Moneynégatif ne peut tout simplement pas exister. On parle d'invariant, une règle toujours vraie pour l'objet. - Pour « modifier », on renvoie un nouvel objet (
add). C'est un objet-valeur (value object) : comme le nombre 5, il ne change jamais, on en crée un autre.
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 :
<?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";
}switch FR : 3 jours match FR : 2 jours Unhandled match case 'DE'
matchest une expression : elle renvoie une valeur, qu'on peut affecter ou retourner directement.- Elle compare avec
===, alors queswitchcompare avec==(fiche 2). - Pas de
break, donc pas de « chute » accidentelle dans le cas suivant, comme ici avec la France. - Une valeur non prévue lève
UnhandledMatchErrorau lieu de passer en silence. Avec une enum, c'est précieux : si quelqu'un ajoute un cas, chaquematchqui l'oublie le signale.
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.
<?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";
}
}NOEL10 : 10 % INCONNU : 0 % ETE20 : refusé (Le code ETE20 a expiré le 2026-08-31)
- Un code promo introuvable est un cas normal : quelqu'un a mal tapé.
find()renvoienull, et le type?arrayoblige l'appelant à y penser. - 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. - Une exception dédiée se rattrape précisément :
catch (CouponExpired)ne capture pas, par erreur, une panne de base de données.
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 :
<?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";
}« Clavier », stock : 5 Cannot modify private(set) property Product::$stock from global scope Le nom ne peut pas être vide
- La visibilité asymétrique :
public private(set)se lit de partout, mais ne s'écrit que dans la classe. Plus besoin d'une propriété privée et d'un getter. - Les hooks de propriété : un bloc
set(ouget) s'exécute à chaque accès. La validation reste attachée à la propriété, même quand on l'écrit de l'extérieur. - Deux petits plus :
array_find()et ses cousines (fiche 1), et l'appel direct sur unnewsans parenthèses autour, commenew Product('Souris')->restock(3).
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 :
<?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.
- Installe-le avec
composer require --dev phpstan/phpstan, et l'extensionphpstan/phpstan-symfonypour qu'il comprenne le conteneur de services. - Crée un fichier de configuration :
# 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- Lance
vendor/bin/phpstan analyse, corrige, puis monte d'un niveau quand tout est propre. - 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. - Ajoute-le à l'intégration continue, à côté des tests (fiche 3).
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ège | Ce qui arrive | La parade |
|---|---|---|
Oublier strict_types dans un fichier | Les appels de ce fichier convertissent en silence | La déclaration dans tous les fichiers, imposée par PHP CS Fixer |
(int) $request->get('id') | '42abc' devient 42, 'abc' devient 0 | Valider avant de convertir |
| Un statut stocké en chaîne | Une faute de frappe crée un statut fantôme | Une enum adossée |
from() sur une saisie utilisateur | Une erreur 500 pour une simple valeur invalide | tryFrom() et une réponse 400 ou 404 |
Un break oublié dans un switch | Le cas suivant s'exécute aussi | match |
Renvoyer false ou -1 pour « pas trouvé » | L'appelant oublie de tester, et utilise la valeur | null avec un type nullable, ou une exception |
catch (Exception $e) partout | Une vraie panne est avalée en silence | Des exceptions dédiées, attrapées précisément |
Afficher getMessage() à l'utilisateur | Des détails techniques fuient | Un message prévu pour lui, et les détails dans les logs |
Checklist avant de proposer ton code
- Chaque fichier commence par
declare(strict_types=1);. - Chaque paramètre, chaque retour et chaque propriété a un type, et aucun
mixedn'est là par paresse. - Les listes fermées de valeurs (statuts, types, rôles) sont des enums.
- Les objets-valeurs sont
readonlyet valident leurs données dans le constructeur. - Les entrées utilisateur sont validées avant d'être converties.
- « Pas trouvé » renvoie
null, une règle violée lève une exception dédiée. - PHPStan passe au niveau choisi par l'équipe.
Exercice : moderniser une classe
Le fichier exemples/05/exercice.php contient une classe Subscription écrite à l'ancienne. Aujourd'hui, il affiche :
monthly 'Actif' 990 yearly 'En pause' 825 monthly NULL 1490
- Regarde la troisième ligne : pourquoi le libellé vaut-il
NULL? Est-ce que PHP t'a prévenu ? - Ajoute
strict_typeset des types partout. - Crée une enum
SubscriptionStatusavec une méthodelabel()écrite avecmatch, et une enumPlan. Que devient la faute de frappe de la troisième ligne ? - Utilise la promotion de propriétés, et rends la classe
readonlysi c'est pertinent. Ça l'est ici, mais pourquoi ne pourrait-on pas faire pareil avec une entité Doctrine dont le statut change ? - 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
- Modernise par petites touches. Ajoute
strict_typeset 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é. - 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. - Lis les notes de version. Chaque version de PHP a sa page « migration » sur php.net, avec les nouveautés et les changements incompatibles.
- Un type est une documentation qui ne ment pas. Un commentaire peut être faux, un type est vérifié à chaque appel.
Documentation officielle
- Les déclarations de type, dont
strict_types - Les énumérations
- Les propriétés, dont
readonly, la visibilité asymétrique et les hooks match- Migrer vers PHP 8.4
filter_varet la validation dans Symfony- PHPStan : premiers pas