Symfony 8.1 est sorti le 29 mai 2026. PHP 8.4 reste le minimum requis et aucun changement cassant n’est introduit. L’ajout principal est architectural : le kernel n’est plus couplé à HttpKernel. Le reste est incrémental mais genuinement utile.
Une application sans HTTP
Depuis les débuts de Symfony, chaque application embarque un kernel basé sur HttpKernel, même quand elle ne sert aucun trafic HTTP. Un worker Messenger qui consomme depuis SQS traînait malgré lui toute la machinerie HTTP. 8.1 règle ça à la racine.
AbstractKernel et KernelTrait vivent maintenant dans le composant DependencyInjection. HttpKernel\Kernel étend AbstractKernel, donc les applications existantes sont entièrement compatibles. Ce qui est nouveau, c’est la possibilité d’écrire un kernel qui étend AbstractKernel directement, sans la couche HTTP.
Deux nouveaux bundles rendent ça utile immédiatement :
ServicesBundlecâble le DI, l’event dispatcher et le clock, sans dépendance HTTP.ConsoleBundles’appuie dessus et ajoute le résolveur de commandes.
Pour une application CLI uniquement :
// config/bundles.php
return [
Symfony\Component\Console\ConsoleBundle::class => ['all' => true],
];
namespace App;
use Symfony\Component\DependencyInjection\Kernel\AbstractKernel;
use Symfony\Component\DependencyInjection\Kernel\KernelTrait;
class Kernel extends AbstractKernel
{
use KernelTrait;
}
Les auteurs de bundles héritent d’un attribut #[RequiredBundle] pour déclarer explicitement les dépendances entre bundles, avec un flag ignoreOnInvalid pour les dépendances optionnelles :
#[RequiredBundle(AcmeCoreBundle::class)]
#[RequiredBundle(AcmeUtilBundle::class, ignoreOnInvalid: true)]
class AcmeBlogBundle extends AbstractBundle {}
Rate limiting déclaratif
8.1 ajoute un attribut #[RateLimit] pour les controllers. Posé sur une action, il applique la limite configurée dans framework.rate_limiter et renvoie automatiquement un 429 avec Retry-After.
use Symfony\Component\HttpKernel\Attribute\RateLimit;
class ApiController extends AbstractController
{
#[RateLimit('api')]
public function index(): JsonResponse { /* ... */ }
#[RateLimit('api', methods: ['POST', 'PUT'])]
public function edit(): JsonResponse { /* ... */ }
#[RateLimit('api', tokens: 5)]
public function export(): JsonResponse { /* ... */ }
}
L’attribut est répétable, il est donc possible d’empiler deux politiques sur la même action. Par défaut, la clé de bucket combine l’IP client, la méthode HTTP et le path. Elle peut être remplacée par une expression pour des buckets par utilisateur :
use Symfony\Component\ExpressionLanguage\Expression;
#[RateLimit('per_account', key: new Expression('request.request.get("email")'))]
public function resetPassword(): Response { /* ... */ }
La politique fixed_window gagne anchor_at, qui aligne les resets sur un moment calendaire plutôt que sur la première requête. Pratique pour les quotas de facturation mensuelle :
framework:
rate_limiter:
api_quota:
policy: 'fixed_window'
limit: 10000
interval: '1 month'
anchor_at: '2026-01-05 00:00:00 UTC'
Injection de dépendances
Plusieurs améliorations DI arrivent dans 8.1.
Env vars en Closure ou Stringable. Les workers longue durée ont parfois besoin de rafraîchir des variables d’environnement entre les itérations. Autowirer une env var en Closure fournit une factory plutôt qu’une valeur figée :
class Worker
{
public function __construct(
#[Autowire(env: 'DB_URL')]
private \Closure $dbUrl,
#[Autowire(env: 'APP_NAME')]
private string|\Stringable $appName = 'default',
) {}
}
#[AsTagDecorator]. Décorer tous les services portant un tag donné en posant un seul attribut sur la classe décorateur :
#[AsTagDecorator('app.handler')]
class LoggingHandler
{
public function __construct(private object $inner) {}
}
Env vars avec des points dans le nom. Les noms comme DATABASE.PRIMARY.URL sont désormais valides, ce qui compte quand on consomme des variables structurées par des plateformes cloud.
Messenger
Messenger reçoit plusieurs ajouts concrets dans 8.1.
Batch fetching. La nouvelle option --fetch-size de messenger:consume réduit le nombre d’aller-retours vers le transport. Avec SQS (qui autorise jusqu’à 10 messages par appel), ça coupe une bonne partie de l’overhead à fort débit :
php bin/console messenger:consume async --fetch-size=8
Nom de type sérialisé personnalisé. Quand un consommateur non-Symfony identifie un message par une chaîne stable plutôt que par un nom de classe PHP, #[AsMessage] couvre ce cas :
#[AsMessage(serializedTypeName: 'crawler.vectorization_finished')]
final readonly class VectorizationFinished
{
public function __construct(public string $crawlId) {}
}
Priorité AMQP par message. AmqpPriorityStamp définit la priorité sur un dispatch individuel plutôt qu’au niveau de la queue :
$bus->dispatch($message, [new AmqpPriorityStamp(5)]);
Contrôle du reset. --no-reset=100 exécute le reset des services tous les 100 messages plutôt qu’après chacun, ce qui réduit l’overhead des workers longue durée à grande échelle.
Idle timeout pour BatchHandler. Les batches partiels sont maintenant flushés après une période d’inactivité configurable, pas seulement quand le batch est plein.
Redis receiver listable. Le receiver Redis expose maintenant all() et find(), ce qui permet d’inspecter les messages en attente par programmation.
Console
Commandes méthodes. Plusieurs commandes peuvent cohabiter dans une même classe comme méthodes distinctes. Moins de boilerplate quand une fonctionnalité génère un groupe de commandes liées.
#[AskChoice]. Déclarer un prompt de choix interactif directement dans la signature de l’argument, avec support des enums :
#[AsCommand('app:create-user')]
class CreateUserCommand
{
public function __invoke(
#[Argument, AskChoice('Select a role', ['admin', 'editor', 'viewer'])]
string $role,
): int { /* ... */ }
}
Validation sur #[Ask]. Les prompts interactifs acceptent maintenant des contraintes de validation :
public function __invoke(
#[Argument, Ask('Enter your email:', constraints: [
new Assert\NotBlank(),
new Assert\Email(),
])]
string $email,
): int { /* ... */ }
#[MapInput]. Mappe automatiquement les arguments et options d’une commande dans un DTO validé.
RawInputInterface. Donne accès aux tokens bruts de l’input, utile pour forwarder des arguments à un sous-processus sans les re-parser.
HttpClient
Connexions cURL persistantes (PHP 8.5+) : réutilisation du cache DNS et des sessions SSL entre les requêtes, ce qui réduit la latence pour les clients à haute fréquence.
GuzzleHttpHandler. Symfony HttpClient peut maintenant servir de transport Guzzle, ce qui permet au code existant utilisant Guzzle de bénéficier du retry, du mock et du scopage Symfony sans migration complète :
use Symfony\Component\HttpClient\GuzzleHttpHandler;
$guzzle = new \GuzzleHttp\Client(['handler' => new GuzzleHttpHandler()]);
Allowlist SSRF pour NoPrivateNetworkHttpClient. Passer une IP ou un range spécifique à autoriser à travers le blocage réseau privé :
$client = new NoPrivateNetworkHttpClient(HttpClient::create(), null, '10.0.0.42');
max_connect_duration. Un timeout limité à la phase de connexion uniquement, pour un contrôle plus fin sur les DNS lents et les handshakes TLS.
Mapping du payload de requête
#[MapRequestPayload] gère maintenant le multipart/form-data, ce qui permet d’avoir des propriétés UploadedFile dans les DTOs mappés :
class ProductDto
{
public ?string $name = null;
public ?UploadedFile $image = null;
}
public function upload(#[MapRequestPayload] ProductDto $data): Response { /* ... */ }
Les arguments variadiques permettent de mapper un tableau JSON directement en une série d’objets typés :
public function createPrices(#[MapRequestPayload] Price ...$prices): Response { /* ... */ }
validationGroups accepte maintenant une Expression ou une Closure pour une sélection de groupes dynamique. mapWhenEmpty: true déclenche la dénormalisation même sur un payload vide.
Formulaires
Le thème daisyUI 5 est maintenant inclus, ce qui couvre les frontends Tailwind sans thème personnalisé :
twig:
form_themes: ['daisyui_5_layout.html.twig']
DateType en mode choice accepte une option labels pour renommer les selects année, mois et jour sans template personnalisé. Les checkboxes non cochées sont maintenant soumises automatiquement comme false plutôt qu’être absentes du payload, ce qui corrige une incohérence de longue date.
Sérialisation automatique des réponses
L’attribut #[Serialize] posé sur une méthode de controller sérialise automatiquement la valeur de retour dans une Response, en choisissant le format (JSON, XML) depuis le format de la requête. Fini les $this->json() manuels pour les endpoints API simples :
use Symfony\Component\HttpKernel\Attribute\Serialize;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
final readonly class CreateProductController
{
#[Serialize(
code: 201,
headers: ['X-Custom-Header' => 'abc'],
context: [DateTimeNormalizer::FORMAT_KEY => 'd.m.Y H:i:s'],
)]
public function __invoke(): ProductCreated
{
return new ProductCreated(101);
}
}
Une route avec {_format} retourne du JSON pour /products/42.json et du XML pour /products/42.xml. Les formats non supportés donnent un 415.
DeepCloner
DeepCloner arrive dans le composant VarExporter comme remplacement de Instantiator et Hydrator (tous deux dépréciés en 8.1). Il clone des graphes d’objets PHP complexes directement, sans le round-trip unserialize(serialize()), en préservant la sémantique copy-on-write pour les strings et les tableaux.
use Symfony\Component\VarExporter\DeepCloner;
// clone one-shot
$clone = DeepCloner::deepClone($originalObject);
// cloner réutilisable sur le même prototype
$cloner = new DeepCloner($prototype);
$clone1 = $cloner->clone();
$clone2 = $cloner->clone();
// clone vers une sous-classe compatible
$childDefinition = (new DeepCloner($definition))
->cloneAs(ChildDefinition::class);
Une fonction deepclone_hydrate() remplace le Hydrator déprécié :
$user = deepclone_hydrate(User::class, ['name' => 'Alice']);
DI, FrameworkBundle, Form et Cache (ArrayAdapter) l’utilisent tous en interne dans 8.1. Une extension C optionnelle (symfony/php-ext-deepclone) est disponible pour des performances natives.
Attribut #[Cache] amélioré
L’attribut #[Cache] sur les méthodes de controller gagne trois choses en 8.1.
Variables d’expression nommées. lastModified et etag exposent maintenant request (l’objet Request) et args (les arguments du controller sous forme de tableau), en remplacement de la fusion plate précédente qui causait des collisions de noms :
#[Cache(
etag: "args['article'].computeETag()",
lastModified: "args['article'].getUpdatedAt()",
public: true,
)]
public function show(Article $article): Response { ... }
Support des closures pour lastModified et etag :
#[Cache(
lastModified: static function (array $args, Request $request): \DateTimeInterface {
return $args['post']->getUpdatedAt();
},
etag: static function (array $args, Request $request): string {
return (string) $args['post']->getId();
},
)]
public function show(Post $post): Response { ... }
Application conditionnelle via if (expression string ou closure retournant un bool). Mettre en cache une réponse uniquement quand la requête ne porte pas de paramètre preview :
#[Cache(
public: true,
maxage: 3600,
if: static fn (array $args, Request $request): bool => !$request->query->has('preview'),
)]
public function show(Request $request): Response { ... }
L’attribut est aussi répétable, ce qui permet d’empiler plusieurs politiques avec des conditions différentes sur la même méthode.
JSON streaming et JsonPath
Value objects dans JsonStreamer. Une interface ValueObjectTransformerInterface couvre les types qui se sérialisent en scalaire et vice-versa. Il suffit de l’implémenter et de taguer le service :
/** @implements ValueObjectTransformerInterface<Money, string> */
class MoneyTransformer implements ValueObjectTransformerInterface
{
public function transform(object $object, array $options = []): string
{
return $object->amount.' '.$object->currency;
}
public function reverseTransform(string $scalar, array $options = []): Money
{
[$amount, $currency] = explode(' ', $scalar);
return new Money((int) $amount, $currency);
}
public static function getStreamValueType(): BuiltinType { return Type::string(); }
public static function getValueObjectClassName(): string { return Money::class; }
}
Types date. DateInterval se sérialise en ISO 8601 (P2Y6M1DT12H30M5S), DateTimeZone en nom de timezone. L’option date_time_timezone gère la conversion à l’encode/decode.
Options par défaut via config :
framework:
json_streamer:
default_options:
include_null_properties: true
Fonctions JsonPath personnalisées via #[AsJsonPathFunction] :
use Symfony\Component\JsonPath\Attribute\AsJsonPathFunction;
#[AsJsonPathFunction('upper')]
final class UppercaseFunction
{
public function __invoke(mixed $value): ?string
{
return \is_string($value) ? strtoupper($value) : null;
}
}
$result = $crawler->find('$.items[?upper(@.title) == "HELLO"]');
Validator
Support Clock dans les contraintes de comparaison. GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual et Range acceptent une ClockInterface. Avec MockClock, les expressions de date relatives comme -10 days se résolvent par rapport à un point fixe dans le temps, rendant la validation temporelle déterministe dans les tests.
Validators réentrants. Le pattern stateful avec initialize($context) posait des problèmes lors d’appels récursifs. Une nouvelle méthode validateInContext() remplace validate() + initialize() sur ConstraintValidatorInterface. Les validators qui étendent la classe abstraite ConstraintValidator n’ont rien à changer.
Contrainte Xml. Valide qu’une chaîne est du XML bien formé, avec validation optionnelle via schéma XSD qui reporte les erreurs individuelles avec numéros de ligne.
Vérification d’existence de propriété. ValidatorBuilder::enablePropertyMetadataExistenceCheck() lève une ValidatorException quand une contrainte cible une propriété inexistante, ce qui attrape les typos au warmup.
En résumé
8.1 est une version dense en fonctionnalités. Le kernel sans HTTP est la pièce architecturalement significative : elle fait de Symfony un choix crédible pour les applications CLI et worker purs, sans le poids de la couche HTTP. Tout le reste gravite autour de l’ergonomie : moins de boilerplate sur les controllers (#[Serialize], #[RateLimit], #[Cache] amélioré), moins de boilerplate sur les commandes (attributs ask, commandes méthodes), moins de friction dans Messenger (taille de batch, noms de types, priorité AMQP), un clonage profond plus rapide dans VarExporter, et un Validator qui comprend enfin le temps. Beaucoup d’irritants de longue date traités en une seule version.