PHP 8.3 est sorti le 23 novembre 2023. Neuf mois plus tard, c'est la version stable recommandée pour la production — PHP 8.4 est encore en phase alpha sur php.net — et pourtant, lors de chaque audit de base de code, le constat est le même : des tableaux de constantes en guise d'états, des DTO avec douze getters identiques, des appels de méthodes à sept arguments positionnels que personne ne comprend sans rouvrir la définition. Les enums typés, les readonly classes et les named arguments sont disponibles et stables sur n'importe quel projet tournant sur PHP 8.1+ — ce guide te montre comment les intégrer concrètement sur une base Symfony 7.1 sans réécriture de zéro.
Pourquoi ces features changent vraiment la donne
L'objection classique, tu la connais : « On n'a pas le temps de tout refactorer. » C'est le mauvais cadre d'analyse. Ces trois features ne se déploient pas en une migration globale — elles s'appliquent fichier par fichier, service par service, à chaque nouveau code que tu écris. L'investissement est marginal, le gain est immédiat : moins d'états invalides atteignables, moins de documentation implicite à maintenir, et une relecture de PR qui prend moitié moins de temps parce que l'intention est dans le code lui-même. Sur un projet Symfony qui vit plusieurs années, c'est de la dette qu'on n'accumule pas.
Les enums typés — exit les magic strings et les constantes de classe
Avant PHP 8.1, gérer les statuts d'une commande revenait à éparpiller des constantes dans une classe, espérer que personne ne passait la chaîne brute 'pendingg' quelque part, et écrire des validations défensives à chaque entrée. Les backed enums (enums avec valeur scalaire associée) règlent tout ça : le type system interdit les valeurs non déclarées, la sérialisation Doctrine est native, et tu peux coller de la logique directement sur l'enum. Voici un enum de statut de commande production-ready, avec la machine à états intégrée :
<?php
namespace App\Order\Domain;
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Pending => 'En attente',
self::Processing => 'En traitement',
self::Shipped => 'Expédié',
self::Delivered => 'Livré',
self::Cancelled => 'Annulé',
};
}
/** @return self[] */
public function allowedTransitions(): array
{
return match($this) {
self::Pending => [self::Processing, self::Cancelled],
self::Processing => [self::Shipped, self::Cancelled],
self::Shipped => [self::Delivered],
default => [],
};
}
public function canTransitionTo(self $next): bool
{
// named argument sur une fonction native — on y revient plus bas
return \in_array(needle: $next, haystack: $this->allowedTransitions(), strict: true);
}
}Doctrine ORM 3 (Symfony 7.x) sait sérialiser un backed enum nativement via l'attribut enumType. Plus besoin de Doctrine type custom ni de listener de conversion — le mapping s'écrit en deux lignes :
<?php
namespace App\Order\Domain;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\DBAL\Types\Types;
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(
type: Types::STRING,
enumType: OrderStatus::class,
length: 20,
)]
private OrderStatus $status = OrderStatus::Pending;
public function __construct(
#[ORM\Column(length: 180)]
private readonly string $customerEmail,
) {}
public function transitionTo(OrderStatus $newStatus): void
{
if (!$this->status->canTransitionTo($newStatus)) {
throw new \DomainException(\sprintf(
'Transition %s → %s interdite',
$this->status->value,
$newStatus->value,
));
}
$this->status = $newStatus;
}
public function getStatus(): OrderStatus
{
return $this->status;
}
}Readonly classes — le DTO enfin propre
Les readonly classes (introduites en PHP 8.2) rendent toutes les propriétés d'une classe immuables d'un seul mot-clé. C'est exactement ce qu'il faut pour les value objects — Money, Address, DateRange, Coordinates — qui ne doivent jamais être modifiés après construction. Fini les douze lignes de boilerplate avec un getter par propriété et une documentation à maintenir. La classe devient auto-documentée, et le compilateur PHP garantit l'immuabilité :
<?php
namespace App\Shared\Domain;
readonly class Money
{
public function __construct(
public int $amountCents,
public string $currency,
) {
if ($this->amountCents < 0) {
throw new \InvalidArgumentException('Un montant ne peut pas être négatif');
}
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Impossible d'additionner {$this->currency} et {$other->currency}"
);
}
return new self(
amountCents: $this->amountCents + $other->amountCents,
currency: $this->currency,
);
}
public function multiplyBy(float $factor): self
{
return new self(
amountCents: (int) \round($this->amountCents * $factor),
currency: $this->currency,
);
}
public function formatted(): string
{
return \number_format($this->amountCents / 100, 2, ',', "\u{202F}")
. "\u{00A0}" . $this->currency;
}
public function equals(self $other): bool
{
return $this->amountCents === $other->amountCents
&& $this->currency === $other->currency;
}
}Une readonly class ne peut pas avoir de propriétés non-typées, et toute tentative de mutation après construction lève une Error fatale au runtime. C'est le contrat d'immuabilité qu'on écrivait autrefois avec une convention de nommage et une bonne volonté collective — maintenant il est enforced par le moteur PHP lui-même. Les méthodes add() et multiplyBy() retournent une nouvelle instance : c'est le pattern wither, idiomatique et lisible.
Named arguments — la lisibilité sans concession
Envie d'aller plus loin ?
Réserver un appel découverte →Les named arguments existent depuis PHP 8.0 et restent sous-utilisés. L'argument numéro un contre eux — « ça fait du bruit » — s'évapore dès qu'on les compare à leur alternative : appels à six arguments positionnels dont les deux du milieu sont des booléens que personne ne retient, ou des constructeurs de DTO où on ne sait plus quel true active quoi. Les named arguments rendent le callsite auto-documenté sans toucher la définition de la méthode, ce qui en fait l'outil de refactoring progressif idéal :
<?php
// ❌ Avant : que font ces arguments ?
$slice = array_slice($items, 0, 10, true);
$date = mktime(0, 0, 0, 7, 9, 2024);
// ✅ Après : l'intention est dans le code
$slice = array_slice(
array: $items,
offset: 0,
length: 10,
preserve_keys: true,
);
$date = mktime(
hour: 0,
minute: 0,
second: 0,
month: 7,
day: 9,
year: 2024,
);
// Constructeur de value object : lisibilité immédiate
$price = new Money(amountCents: 4999, currency: 'EUR');
$shipping = new Money(amountCents: 499, currency: 'EUR');
$total = $price->add($shipping);
// Symfony HttpFoundation : les paramètres optionnels dans l'ordre voulu
return new JsonResponse(
data: ['status' => 'ok', 'total' => $total->formatted()],
status: Response::HTTP_CREATED,
headers: ['X-Order-Id' => (string) $order->getId()],
);Typed class constants — la nouveauté propre à PHP 8.3
PHP 8.3 apporte les constantes de classe typées — la seule feature de cette version qui complète directement les trois patterns précédents. Avant 8.3, une constante d'interface pouvait être silencieusement surchargée avec un type incompatible dans une classe fille. Désormais, le type est enforced à la déclaration et dans toutes les implémentations. C'est particulièrement utile pour les interfaces de configuration et les value objects de domaine :
<?php
namespace App\Payment\Domain;
interface PaymentGatewayConfig
{
const string DEFAULT_CURRENCY = 'EUR';
const int TIMEOUT_SEC = 30;
const int MAX_RETRIES = 3;
const float DEFAULT_TAX_RATE = 0.20;
const bool SANDBOX_MODE = false;
}
// PHP 8.3 interdit ça dans les classes qui implémentent l'interface :
// const int TIMEOUT_SEC = 'trente'; // TypeError à la compilation
class StripeGatewayConfig implements PaymentGatewayConfig
{
// Chaque constante surchargée doit respecter le type déclaré dans l'interface
const string DEFAULT_CURRENCY = 'EUR';
const int TIMEOUT_SEC = 45; // OK : int
const int MAX_RETRIES = 5; // OK : int
const float DEFAULT_TAX_RATE = 0.20;
const bool SANDBOX_MODE = false;
}Tout assembler dans un service Symfony 7.1
Le vrai gain se matérialise quand les quatre features se combinent dans un service métier. Un service Symfony moderne peut lui-même être une readonly class — ses dépendances sont injectées une fois, immuables, et le service est thread-safe par construction. Voici un OrderProcessor qui orchestre l'enum de statut, le value object Money, les named arguments et les constantes typées :
<?php
namespace App\Order\Application;
use App\Order\Domain\{Order, OrderRepository, OrderStatus};
use App\Payment\Domain\{PaymentGatewayInterface, PaymentGatewayConfig};
use App\Shared\Domain\Money;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
readonly class OrderProcessor
{
public function __construct(
private OrderRepository $orders,
private PaymentGatewayInterface $gateway,
private EventDispatcherInterface $events,
private LoggerInterface $logger,
) {}
public function process(int $orderId): ProcessingResult
{
$order = $this->orders->findOrFail(id: $orderId);
try {
$charged = $this->gateway->charge(
order: $order,
retries: PaymentGatewayConfig::MAX_RETRIES,
timeoutSec: PaymentGatewayConfig::TIMEOUT_SEC,
);
$order->transitionTo(OrderStatus::Processing);
$this->orders->save($order);
$this->logger->info('Order processed', [
'order_id' => $orderId,
'charged' => $charged->formatted(),
]);
$this->events->dispatch(new OrderProcessedEvent(
orderId: $orderId,
status: OrderStatus::Processing,
amount: $charged,
));
return ProcessingResult::success(order: $order, charged: $charged);
} catch (\Throwable $e) {
$this->logger->error('Payment failed — order cancelled', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
$order->transitionTo(OrderStatus::Cancelled);
$this->orders->save($order);
return ProcessingResult::failure(
order: $order,
reason: $e->getMessage(),
);
}
}
}Ce service illustre la synergie des quatre features : la readonly class garantit que les dépendances ne peuvent pas être mutées après injection, les named arguments sur charge() et dispatch() rendent les callsites lisibles sans commentaires, les constantes typées MAX_RETRIES et TIMEOUT_SEC éliminent les magic numbers, et la machine à états de l'enum OrderStatus rend toute transition invalide impossible à ignorer — elle lève une DomainException explicite si elle est tentée au runtime. Chaque feature fait son travail ; ensemble, elles suppriment une catégorie entière de bugs possibles.
Par où commencer sur ton projet
L'approche la plus efficace est incrémentale. Semaine 1 : identifie les cinq classes à constantes les plus utilisées et convertis-les en backed enums — PHPStan t'indiquera immédiatement tous les callsites à mettre à jour. Semaine 2 : repère les value objects qui mutent par accident — Money, Address, Coordinates — et passe-les en readonly class. Semaine 3 : applique les named arguments aux callsites à trois arguments booléens ou plus. Chaque PR est indépendante, reviewable isolément, et le bénéfice est immédiatement visible dans la lisibilité du code. Il n'y a pas de migration big-bang, pas de feature freeze : juste des habitudes qui changent, PR après PR, jusqu'à ce que la base de code devienne difficile à utiliser incorrectement.
Envie d'aller plus loin ?
Discutons de votre projet et voyons comment je peux vous aider.