Skip to Content

Chapitre 2.5 — Classes modernes : promotion, match, readonly, nullsafe

Partie 2 : PHP moderne — Chapitre 5/8 Version de référence : PHP 8.4 pour Symfony 7.4.

⏱️ TL;DR

  • Promotion de constructeur : déclarez vos propriétés dans les paramètres du constructeur. Fin du boilerplate $this->x = $x;.
  • Arguments nommés : new Booking(status: X, start: $d) — l’ordre n’importe plus, le code s’auto-documente.
  • readonly : propriété (8.1) ou classe entière (8.2) immuable après initialisation. Idéal pour les DTO et les value objects.
  • match : un switch typé, sans fall-through, qui retourne une valeur et compare en ===.
  • nullsafe ?-> : $a?->b()?->c court-circuite à null dès qu’un maillon est null.
  • First-class callables : strlen(...), $service->method(...) — référencer proprement une fonction.
  • Fonctions fléchées fn($x) => … (capture auto) vs closures function () use ($y) {} (capture explicite).
  • Le tout donne le DTO : un petit objet typé et immuable, brique centrale de Symfony/API Platform.

🎯 Objectifs

  • Écrire des classes concises avec promotion et arguments nommés.
  • Utiliser readonly, match, nullsafe, first-class callables à bon escient.
  • Distinguer fonction fléchée et closure.
  • Comprendre le pattern DTO et pourquoi il est central côté Symfony.

1. Promotion de constructeur : la fin du boilerplate

Avant (PHP 7), déclarer une classe avec 4 propriétés imposait de les écrire trois fois (propriété, paramètre, assignation) :

class Booking { private int $id; private string $email; private \DateTimeImmutable $start; public function __construct(int $id, string $email, \DateTimeImmutable $start) { $this->id = $id; $this->email = $email; $this->start = $start; } }

Avec la promotion (PHP 8.0), on déclare tout dans la signature du constructeur : ajouter un modificateur de visibilité (public/private/protected) devant un paramètre crée automatiquement la propriété et l’assigne.

final class Booking { public function __construct( private int $id, private string $email, private \DateTimeImmutable $start, ) {} }

Trois fois moins de code, aucune perte d’information. C’est l’écriture standard en Symfony 7.4 — vous la verrez dans chaque service, chaque DTO, chaque contrôleur.

💡 Pour un dev Next.js : c’est exactement le constructor(private id: number) de TypeScript (option parameter properties). Même syntaxe, même effet. Vous connaissez déjà.

2. Arguments nommés : l’ordre ne compte plus

Depuis PHP 8.0, on passe les arguments par nom, dans n’importe quel ordre, et on peut sauter les paramètres optionnels :

function createSlot(int $capacity, bool $recurring = false, ?string $label = null): Slot { /* … */ } // positionnel (classique) createSlot(10, false, 'Matin'); // nommé : lisible, on saute 'recurring' (reste à sa valeur par défaut) createSlot(capacity: 10, label: 'Matin');

C’est particulièrement précieux avec les objets Symfony aux nombreux paramètres optionnels (attributs #[Route(...)], contraintes de validation…), où le nommage rend le code auto-documenté.

⚠️ Piège : les arguments nommés couplent votre appel au nom des paramètres. Renommer un paramètre d’une fonction devient un breaking change pour ses appelants nommés. Pour votre code, aucun souci ; pour une bibliothèque publique, les noms de paramètres font partie du contrat (Symfony les considère d’ailleurs comme couverts par la BC promise).

3. readonly : l’immuabilité

3.1 Propriété readonly (8.1)

Une propriété readonly peut être initialisée une fois (typiquement dans le constructeur) puis jamais modifiée :

final class Money { public function __construct( public readonly int $amount, // en centimes public readonly string $currency, ) {} } $m = new Money(1990, 'EUR'); $m->amount = 0; // 💥 Error : Cannot modify readonly property

3.2 Classe readonly (8.2)

Marquer toute la classe readonly applique readonly à toutes ses propriétés d’un coup :

final readonly class Money { public function __construct( public int $amount, public string $currency, ) {} }

Pour « modifier » un objet immuable, on en crée un nouveau (méthode with…()), à la manière des objets immuables JS :

public function withAmount(int $amount): self { return new self($amount, $this->currency); }

💡 Pour un dev Next.js : readonly PHP ≈ readonly TS appliqué à l’exécution. Le pattern withX() qui renvoie une nouvelle instance ≈ votre habitude { ...obj, x } en immutable. Les value objects (Money, Email, DateRange…) immuables sont une excellente pratique que Symfony encourage.

4. match : le switch qu’on aurait dû avoir

match (8.0) est une expression (elle retourne une valeur), sans fall-through, avec comparaison stricte === :

$label = match ($status) { BookingStatus::Pending => 'En attente', BookingStatus::Confirmed => 'Confirmée', BookingStatus::Cancelled => 'Annulée', };

Différences clés avec switch :

switchmatch
Renvoie une valeurnon (instruction)oui (expression)
Comparaison== (laxiste)=== (stricte)
break nécessaireoui (sinon fall-through)non (pas de fall-through)
Cas non couvertsilencieuxlève UnhandledMatchError

Ce dernier point est un filet de sécurité : un match sur une enum qui oublie un cas plante bruyamment (et PHPStan le détecte avant l’exécution), là où un switch passerait dessous en silence. On peut regrouper plusieurs valeurs (self::A, self::B => …) et fournir un défaut (default => …).

5. nullsafe : chaîner sans crouler sous les if

L’opérateur ?-> (8.0) court-circuite à null dès qu’un maillon de la chaîne est null, au lieu de provoquer une erreur :

// Sans nullsafe : $city = null; if ($booking !== null) { $customer = $booking->getCustomer(); if ($customer !== null) { $city = $customer->getAddress()?->getCity(); } } // Avec nullsafe : $city = $booking?->getCustomer()?->getAddress()?->getCity();

Si $booking, le customer ou l’adresse est null, $city vaut null sans erreur.

💡 Pour un dev Next.js : c’est l’optional chaining ?. de JS/TS, à l’identique. booking?.customer?.address?.city. Même sémantique, même court-circuit.

6. First-class callables : référencer une fonction proprement

Depuis 8.1, ... transforme un appel en référence appelable (au lieu des vieilles chaînes 'strlen' ou tableaux [$obj, 'method']) :

$fn = strlen(...); // référence à la fonction strlen $fn('hello'); // 5 $normalizer = $this->normalize(...); // référence à une méthode d'instance // Idéal avec les fonctions d'ordre supérieur : $lengths = array_map(strlen(...), ['a', 'bb', 'ccc']); // [1, 2, 3] $names = array_map(fn (Booking $b) => $b->email, $bookings);

7. Fonctions fléchées vs closures

Deux façons de créer une fonction anonyme, avec une différence de capture :

$taxRate = 0.2; // Fonction fléchée : 1 expression, capture AUTOMATIQUE des variables externes $withTax = fn (int $price) => $price * (1 + $taxRate); // Closure : corps multi-lignes, capture EXPLICITE via use() $withTax = function (int $price) use ($taxRate): int { return (int) round($price * (1 + $taxRate)); };
  • fn () => expr : concise, une seule expression, capture implicite (comme les arrow functions JS). Pas de use.
  • function () use ($x) {} : corps complet, capture explicite (vous listez ce que vous importez). Utile pour capturer par référence (use (&$x)).

⚠️ Piège : contrairement à JS, une closure classique ne capture pas automatiquement le scope — vous devez lister les variables dans use(). Oublier une variable dans use est l’erreur n°1 du dev JS qui écrit sa première closure PHP. Les fonctions fléchées (fn), elles, capturent automatiquement — d’où leur commodité.

8. Le pattern DTO : la brique qui relie tout

Un DTO (Data Transfer Object) est un petit objet sans logique, juste des données typées, souvent immuable. C’est la synthèse de ce chapitre — et une pièce centrale de Symfony moderne :

declare(strict_types=1); namespace App\Dto; use Symfony\Component\Validator\Constraints as Assert; final readonly class CreateBookingInput { public function __construct( #[Assert\NotBlank] #[Assert\Email] public string $customerEmail, #[Assert\NotNull] public \DateTimeImmutable $start, #[Assert\Positive] public int $seats = 1, ) {} }

Ce DTO combine : promotion, readonly, types, et des attributs de validation. Symfony sait, à partir de la requête HTTP entrante, construire automatiquement cet objet et le valider (via #[MapRequestPayload], Partie 4) — puis vous le manipulez comme une donnée sûre et typée. C’est le pont entre « données brutes du réseau » et « objets métier fiables ».

💡 Pour un dev Next.js : un DTO PHP avec attributs #[Assert\...] ≈ un schéma Zod + le type inféré, réunis dans un seul objet. CreateBookingInput joue à la fois le rôle du z.object({...}) (validation) et du type (forme typée). Vous retrouverez ce réflexe « valider aux frontières » que vous avez déjà en TS.

✏️ Exercices

Exercice 1. Réécrivez cette classe en PHP 8.4 idiomatique (promotion, readonly, types) :

class Address { private $street; private $city; private $zip; public function __construct($street, $city, $zip) { $this->street = $street; $this->city = $city; $this->zip = $zip; } public function getCity() { return $this->city; } }

✅ Solution

declare(strict_types=1); final readonly class Address { public function __construct( public string $street, public string $city, public string $zip, ) {} }

Les propriétés étant public readonly, on n’a même plus besoin de getCity() : $address->city est lisible mais non modifiable. (Si l’encapsulation est requise, remettez-les private avec des getters.)

Exercice 2. Transformez ce switch en match, et expliquez le bénéfice :

switch ($role) { case 'admin': $level = 3; break; case 'provider': $level = 2; break; default: $level = 1; }

✅ Solution

$level = match ($role) { 'admin' => 3, 'provider' => 2, default => 1, };

Bénéfices : match retourne directement la valeur (une seule affectation, pas de $level répété), compare en === (pas de coercition surprise), et ne peut pas oublier de break (pas de fall-through). Si on retire default et qu’une valeur n’est pas couverte, il lève une erreur explicite au lieu de filer silencieusement.

Exercice 3. Écrivez, en une ligne avec nullsafe, l’extraction de l’e-mail du client d’une réservation potentiellement nulle, en valant null si un maillon manque.

✅ Solution

$email = $booking?->getCustomer()?->getEmail();

Si $booking ou son customer est null, $email vaut null sans erreur — l’équivalent PHP de booking?.customer?.email en TS.

Exercice 4. Pourquoi ce code lève-t-il une erreur, et comment le corriger ?

$prefix = 'REF-'; $addPrefix = function (string $id) { return $prefix . $id; }; echo $addPrefix('42');

✅ Solution

Une closure (function () {}) ne capture pas automatiquement $prefix : il faut le déclarer dans use(). Sinon $prefix est indéfini dans la closure.

$addPrefix = function (string $id) use ($prefix) { return $prefix . $id; }; // ou, plus concis, une fonction fléchée (capture auto) : $addPrefix = fn (string $id) => $prefix . $id;

🧠 Quiz de révision

1. Qu’apporte la promotion de constructeur ?

Elle déclare et assigne les propriétés directement dans les paramètres du constructeur (en ajoutant une visibilité), supprimant le boilerplate $this->x = $x;.

2. Trois différences entre match et switch ?

match retourne une valeur (expression), compare en === (strict), n’a pas de fall-through (pas de break) et lève une erreur si un cas n’est pas couvert.

3. Différence de capture entre fn et function () {} ?

La fonction fléchée fn capture automatiquement les variables du scope englobant ; la closure function exige une capture explicite via use(...).

4. Que fait l’opérateur ?-> ?

Le nullsafe : il court-circuite la chaîne à null dès qu’un maillon est null, au lieu de lever une erreur (équivalent de l’optional chaining ?. de JS).

5. Qu’est-ce qu’un DTO et pourquoi est-il central en Symfony ?

Un Data Transfer Object : un petit objet typé, souvent immuable, sans logique, qui transporte des données. Central car Symfony construit et valide ces objets à partir des requêtes (via #[MapRequestPayload], attributs #[Assert\...]), faisant le pont entre données brutes et objets métier sûrs.


Prochain chapitre : 2.6 — Composer, PSR-4, namespaces — comment PHP « importe » du code, pourquoi il n’y a pas d’import, et comment tout se relie via l’autoload.