PHP 8.3 tourne en production depuis novembre 2023, et je le retrouve sur tous les stacks que j'accompagne — Laravel 11, Symfony 7, APIs internes B2B. Le constat est presque toujours le même : les binaires ont été mis à jour, mais les pratiques sont restées bloquées à PHP 8.0. Les Fibers n'ont pas quitté les slides de conférence, les readonly class font peur à ceux qui doivent sérialiser en JSON, et les constantes typées ? La plupart des développeurs ignorent que c'est précisément en 8.3 qu'elles arrivent. Voici cinq patterns que j'applique réellement en production, avec du code complet prêt à intégrer dès ton prochain sprint.
PHP 8.3 en septembre 2024 : ce que dit vraiment php.net
D'après php.net/supported-versions.php, PHP 8.3 bénéficie d'un support actif jusqu'en novembre 2025 — c'est la cible recommandée de Laravel 11 (mars 2024) et de Symfony 7.1 (mai 2024). Les features analysées ici ont des générations différentes : les Fibers arrivent en 8.1, les readonly classes en 8.2, et les typed class constants constituent la vraie nouveauté structurelle de PHP 8.3. Mais c'est leur combinaison dans un environnement 8.3 qui produit des patterns impossibles à écrire proprement avant — et c'est précisément ce que les équipes passent à côté. PHP 8.4 est en développement actif, ses premiers RC attendus cet automne, mais la priorité ici c'est ce qui tourne en production aujourd'hui.
Pattern 1 — Worker coopératif avec Fibers, sans extension event
Les Fibers (PHP 8.1) sont le mécanisme de coroutines de bas niveau du langage. Contrairement aux générateurs, elles peuvent être suspendues depuis n'importe quel point de la pile d'appels, pas seulement depuis la fonction principale. Sans Amp, Revolt ou ReactPHP, les I/O restent bloquants — c'est le point crucial à comprendre dès le départ. En revanche, les étapes CPU s'entrelacent, et tu obtiens un modèle de code très lisible pour les pipelines à états multiples. Chaque Fiber représente le cycle de vie complet d'un job ; le scheduler les fait tourner en round-robin en appelant start() puis resume() à chaque passage.
<?php
declare(strict_types=1);
final class CooperativeScheduler
{
/** @var list<Fiber> */
private array $fibers = [];
public function schedule(Fiber $fiber): void
{
$this->fibers[] = $fiber;
}
public function run(): void
{
while ($this->fibers !== []) {
$remaining = [];
foreach ($this->fibers as $fiber) {
$fiber->isStarted() ? $fiber->resume() : $fiber->start();
if (!$fiber->isTerminated()) {
$remaining[] = $fiber;
}
}
$this->fibers = $remaining;
}
}
}
// Enrichissement de commandes : chaque job a 3 phases entrelacées
$scheduler = new CooperativeScheduler();
foreach ($orderRepository->findPending() as $order) {
$scheduler->schedule(new Fiber(
function () use ($order, $carrierClient, $entityManager, $mailer): void {
// Phase 1 : appel carrier (bloquant — à remplacer par Amp si besoin)
$tracking = $carrierClient->fetchTracking($order->getId());
Fiber::suspend();
// Phase 2 : persistance locale
$order->updateTracking($tracking);
$entityManager->flush();
Fiber::suspend();
// Phase 3 : notification client
$mailer->sendTrackingUpdate($order);
}
));
}
$scheduler->run();Pattern 2 — DTO immutable avec readonly class
Depuis PHP 8.2, readonly class rend toutes les propriétés readonly automatiquement — plus besoin de répéter le mot-clé sur chaque propriété. Le pattern DTO immutable est le plus direct : un objet naît valide (validation dans le constructeur) et ne peut plus être muté. Pour « modifier » un champ, tu crées un nouvel objet via le wither pattern. C'est exactement le comportement qu'on veut pour les Command et Query du CQRS, ou les Event du domain-driven design. PHPStan infère sans ambiguïté que l'objet reçu en paramètre n'a pas changé entre deux appels, et les revues de code gagnent en vitesse parce que les invariants sont dans les types, pas dans les commentaires.
<?php
declare(strict_types=1);
readonly class CreateUserCommand
{
public function __construct(
public string $email,
public string $firstName,
public string $lastName,
public DateTimeImmutable $requestedAt,
) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Email invalide : {$email}");
}
if (trim($firstName) === '' || trim($lastName) === '') {
throw new \InvalidArgumentException('Prénom et nom obligatoires');
}
}
/**
* Wither pattern : retourne un nouvel objet plutôt que de muter
*/
public function withEmail(string $email): self
{
return new self(
email: $email,
firstName: $this->firstName,
lastName: $this->lastName,
requestedAt: $this->requestedAt,
);
}
}
// Usage dans un handler Symfony Messenger
final class CreateUserHandler
{
public function __construct(
private readonly UserRepository $repository,
) {}
public function __invoke(CreateUserCommand $command): void
{
// $command->email = 'hack@evil.com'; ← Fatal error: Cannot modify readonly property
$user = User::create(
email: $command->email,
name: trim("{$command->firstName} {$command->lastName}"),
);
$this->repository->save($user);
}
}Pattern 3 — Constantes typées pour encoder les états métier sans enum
PHP 8.3 ajoute le typage explicite des constantes de classe. Avant, const PENDING = 'pending' était de type mixed aux yeux des analyseurs statiques dans certains contextes d'héritage. Désormais, const string PENDING = 'pending' est statiquement garanti. L'intérêt face aux enums ? Les constantes typées restent de simples scalaires — tu les passes directement dans un JSON, une colonne SQL ou un header HTTP, sans passer par ->value. Elles permettent aussi de définir un contrat d'interface précis que les backed enums expriment moins élégamment quand le consommateur est un système externe. Voici une machine à états pour les commandes, intégralement analysable par PHPStan :
Votre dette technique s'accumule ?
Demander un audit →<?php
declare(strict_types=1);
final class OrderStatus
{
// PHP 8.3 : type garanti, détectable statiquement par PHPStan/Psalm
const string PENDING = 'pending';
const string CONFIRMED = 'confirmed';
const string SHIPPED = 'shipped';
const string DELIVERED = 'delivered';
const string CANCELLED = 'cancelled';
/** @return list<string> */
public static function allowedTransitions(string $from): array
{
return match($from) {
self::PENDING => [self::CONFIRMED, self::CANCELLED],
self::CONFIRMED => [self::SHIPPED, self::CANCELLED],
self::SHIPPED => [self::DELIVERED],
default => [],
};
}
public static function assertCanTransition(string $from, string $to): void
{
if (!in_array($to, self::allowedTransitions($from), strict: true)) {
throw new \DomainException(
"Transition interdite : {$from} → {$to}"
);
}
}
}
// En PHP < 8.3, dans certains contextes d'héritage :
// const PENDING = 'pending'; ← PHPStan peut inférer 'mixed'
// En PHP 8.3, le contrat est contractualisé dans l'interface elle-même :
interface HasStatusConstants
{
const string INITIAL_STATUS = 'unknown';
}
final class SubscriptionStatus extends OrderStatus implements HasStatusConstants
{
const string INITIAL_STATUS = self::PENDING; // string garanti, pas mixed
const string TRIAL = 'trial'; // extension possible
}Pattern 4 — Intersection de types et DNF combinés avec readonly
PHP 8.1 a introduit les intersection types (A&B) et PHP 8.2 les Disjunctive Normal Form types ((A&B)|null). En 8.3, on combine les trois générations : une readonly class qui implémente deux interfaces, reçue via un type DNF. Le pattern est particulièrement puissant pour les systèmes d'audit, les event sourcing handlers et tout ce qui doit traiter des entités aux capacités variées sans instanceof dans le corps de la fonction. La signature dit tout — le compilateur et l'outillage statique font le reste.
<?php
declare(strict_types=1);
interface Identifiable
{
public function getId(): string;
}
interface Auditable
{
public function getOccurredAt(): DateTimeImmutable;
public function getActor(): string;
}
// Type DNF (PHP 8.2+) : (Identifiable ET Auditable) OU null
function recordAuditTrail((Identifiable&Auditable)|null $event): void
{
if ($event === null) {
return;
}
AuditLog::append(
entityId: $event->getId(),
occurredAt: $event->getOccurredAt(),
actor: $event->getActor(),
);
}
// readonly class (PHP 8.2) + typed constants (PHP 8.3) + interfaces
readonly class OrderShippedEvent implements Identifiable, Auditable
{
const string EVENT_TYPE = 'order.shipped'; // PHP 8.3 : string garanti
public function __construct(
private string $id,
private DateTimeImmutable $occurredAt,
private string $actor,
public string $orderId,
public string $carrier,
) {}
public function getId(): string { return $this->id; }
public function getOccurredAt(): DateTimeImmutable { return $this->occurredAt; }
public function getActor(): string { return $this->actor; }
}
$event = new OrderShippedEvent(
id: bin2hex(random_bytes(16)),
occurredAt: new DateTimeImmutable(),
actor: 'system:warehouse',
orderId: 'ORD-2024-00042',
carrier: 'DHL',
);
recordAuditTrail($event); // type-safe sans aucun instanceofPattern 5 — Pipeline Fiber + readonly DTO, le tout combiné
Le pattern le plus architectural combine les quatre précédents : un scheduler Fiber qui reçoit des readonly DTOs en entrée, applique des transformations via le wither pattern et s'appuie sur des typed constants pour identifier les sources de données. Chaque étape du pipeline correspond à une suspension de la Fiber. Si demain tu migres vers Amp, Revolt ou FrankenPHP en mode worker permanent, le code métier reste intact : seul le scheduler change. C'est la promesse du découplage bien fait — et c'est ce que PHP 8.3 permet d'exprimer directement dans les signatures de types.
<?php
declare(strict_types=1);
readonly class PriceEnrichmentInput
{
// PHP 8.3 : typed constants, utilisables comme scalaires directs
const string SOURCE_MANUAL = 'manual';
const string SOURCE_SUPPLIER = 'supplier';
public function __construct(
public string $sku,
public float $basePrice,
public string $source = self::SOURCE_MANUAL,
) {}
public function withPrice(float $price, string $source): self
{
return new self(
sku: $this->sku,
basePrice: $price,
source: $source,
);
}
}
function buildEnrichmentFiber(
PriceEnrichmentInput $input,
SupplierClient $supplier,
ProductRepository $repo,
): Fiber {
return new Fiber(function () use ($input, $supplier, $repo): void {
// Étape 1 : récupération prix fournisseur
$supplierPrice = $supplier->getPrice($input->sku);
Fiber::suspend();
// Étape 2 : wither pattern sur readonly — nouvel objet, jamais de mutation
$enriched = $input->withPrice(
price: $supplierPrice * 1.30,
source: PriceEnrichmentInput::SOURCE_SUPPLIER,
);
Fiber::suspend();
// Étape 3 : persistance
$repo->upsert(
sku: $enriched->sku,
price: $enriched->basePrice,
source: $enriched->source,
);
});
}
// Orchestration : 200 SKUs, 3 phases entrelacées via CooperativeScheduler
$scheduler = new CooperativeScheduler(); // Pattern 1
foreach ($catalog->getAllSkus() as $sku) {
$scheduler->schedule(buildEnrichmentFiber(
new PriceEnrichmentInput(sku: $sku, basePrice: 0.0),
$supplierClient,
$productRepository,
));
}
$scheduler->run();Ce qui change réellement dans une codebase production
- Les
readonly classéliminent les bugs de mutation sur les DTOs, Commands et Events sans overhead de tests supplémentaires — l'invariant est garanti par le runtime, pas par la discipline d'équipe. - Les typed class constants donnent à PHPStan et Psalm une information de type précise là où
constnon typée pouvait restermixeddans certains contextes d'héritage — sans modifier une seule ligne de logique métier. - Les Fibers permettent d'entrelacer les phases CPU de plusieurs jobs sans event loop tierce. Le code métier reste identique si tu migres vers Amp, Revolt ou FrankenPHP : seul le scheduler change.
- La combinaison readonly + typed constants + Fibers produit des pipelines dont les invariants sont dans les signatures de types, pas dans les commentaires ni dans des tests défensifs redondants.
- Le coût d'adoption est minimal : ces features sont rétrocompatibles, activables pattern par pattern dans une codebase existante, et lisibles immédiatement par toute l'équipe sans formation préalable.
Ces cinq patterns ne demandent ni refactoring massif ni migration périlleuse. Chacun s'introduit progressivement, module par module. L'étape suivante est simple : ouvre un fichier de Command ou de DTO dans ton projet, passe-le en readonly class, et laisse PHPStan te dire ce qui reste à ajuster. La courbe d'adoption est plate — les bénéfices, eux, sont immédiats.
Votre dette technique s'accumule ?
Audit complet en 10 jours. Recommandations priorisées et actionnables.