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: unswitchtypé, sans fall-through, qui retourne une valeur et compare en===.nullsafe?->:$a?->b()?->ccourt-circuite ànulldès qu’un maillon estnull.- First-class callables :
strlen(...),$service->method(...)— référencer proprement une fonction.- Fonctions fléchées
fn($x) => …(capture auto) vs closuresfunction () 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 (optionparameter 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 property3.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 :
readonlyPHP ≈readonlyTS appliqué à l’exécution. Le patternwithX()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 :
switch | match | |
|---|---|---|
| Renvoie une valeur | non (instruction) | oui (expression) |
| Comparaison | == (laxiste) | === (stricte) |
break nécessaire | oui (sinon fall-through) | non (pas de fall-through) |
| Cas non couvert | silencieux | lè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 deuse.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 dansuseest 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.CreateBookingInputjoue à la fois le rôle duz.object({...})(validation) et dutype(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 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 () {} ?
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.