🧱 Symfony 8.1 HTTP-less : une application Symfony sans HttpKernel

📚 Introduction
Symfony 8.1 est sorti fin mai 2026, et le premier article de la série « Living on the edge » ne parle ni de Twig ni de Doctrine. Il pose une question qu’on n’avait plus posée depuis longtemps : est-ce qu’une application Symfony doit forcément être une application web ?
Jusqu’ici, la réponse était oui par construction. Le kernel vivait dans HttpKernel, KernelInterface étendait HttpKernelInterface, et un worker Messenger ou une application console tirait toute la couche HTTP sans jamais s’en servir. Ça fonctionnait très bien. Ça restait bancal.
8.1 déplace le kernel là où il a toujours eu sa place : dans DependencyInjection. J’ai déjà fait le tour de la release dans son ensemble. Ici je tire un seul fil, celui qui change la façon dont on découpe une application.
🧱 Un kernel qui ne connaît plus HTTP
Le nouveau namespace Symfony\Component\DependencyInjection\Kernel contient tout ce qu’il faut pour démarrer un container, charger des bundles et lire une configuration. Sans une ligne de HTTP.
// src/Kernel.php
namespace App;
use Symfony\Component\DependencyInjection\Kernel\AbstractKernel;
use Symfony\Component\DependencyInjection\Kernel\KernelTrait;
class Kernel extends AbstractKernel
{
use KernelTrait;
}KernelTrait offre le même cycle de vie que MicroKernelTrait — construction, compilation et mise en cache du container — et les mêmes conventions : config/bundles.php, config/packages/, config/services.yaml. On ne réapprend rien.
L’autre moitié du travail est dans les types. Une nouvelle KernelInterface arrive dans DependencyInjection et n’expose que l’API liée au container. Avant, un service qui voulait juste inspecter les bundles ou lire le répertoire de cache devait typer un kernel qui traînait HttpFoundation avec lui :
use Symfony\Component\DependencyInjection\Kernel\KernelInterface;
class BundleInspector
{
public function __construct(private KernelInterface $kernel)
{
}
}Rétrocompatible de bout en bout : HttpKernel\Kernel étend maintenant AbstractKernel, les applications web existantes ne bougent pas. Trois classes déménagent quand même vers DependencyInjection, avec des alias dépréciés côté HttpKernel : BundleInterface, MergeExtensionConfigurationPass et FileLocator. À corriger dans les imports au prochain passage.
Détail qui trahit le sérieux de l’implémentation : getLogDir() devient nullable, et APP_LOG_DIR=false dans le .env suffit pour qu’un kernel headless se passe du var/log/ conventionnel.
🚀 ConsoleBundle, ServicesBundle et l’attribut #[RequiredBundle]
Sortir HttpKernel de l’équation ne servait pas à grand-chose tant que FrameworkBundle restait monolithique. 8.1 en découpe le cœur en deux bundles autonomes : ServicesBundle (event dispatcher, filesystem, clock, processeurs de variables d’environnement) et ConsoleBundle (enregistrement des commandes, argument resolver, listener d’erreurs).
Une application console minimale déclare une seule ligne :
// config/bundles.php
return [
Symfony\Component\Console\ConsoleBundle::class => ['all' => true],
];ServicesBundle se charge tout seul, parce que ConsoleBundle le déclare via #[RequiredBundle]. L’attribut est disponible pour vos propres bundles :
use Symfony\Component\DependencyInjection\Kernel\RequiredBundle;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
#[RequiredBundle(AcmeCoreBundle::class)]
#[RequiredBundle(AcmeUtilBundle::class, ignoreOnInvalid: true)]
class AcmeBlogBundle extends AbstractBundle
{
}Il est répétable, se résout récursivement — si A requiert B qui requiert C, les trois se chargent dans le bon ordre — et ignoreOnInvalid: true ignore silencieusement une dépendance optionnelle absente.
🎯 Pourquoi se passer de HttpKernel ?
Pour arrêter de faire semblant. Un consumer qui tourne vingt-quatre heures sur vingt-quatre n’a aucune raison de compiler des services de session, de routing et de négociation de contenu qu’il n’appellera jamais.
Le gain se lit d’abord dans le graphe de dépendances : moins de composants installés, et un container compilé plus petit à la sortie. Symfony ne publie pas de chiffres sur ce point, donc je ne vais pas en inventer. Mesurez sur votre projet avec debug:container.
Le vrai bénéfice est ailleurs, et il est architectural. Une commande qui type KernelInterface déclare enfin ce dont elle a besoin, sans embarquer une dépendance HTTP au passage. Sur une base de code partagée entre une API et une flotte de workers, cette frontière-là finit toujours par payer.
🧰 Console : les commandes rattrapent les contrôleurs
Deux nouveautés convergentes, et c’est probablement ce que vous utiliserez le plus vite.
D’abord, #[AsCommand] s’applique désormais sur des méthodes. Fini la classe par commande quand cinq commandes partagent le même repository :
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
class UserCommands
{
public function __construct(
private UserRepository $users,
private LoggerInterface $logger,
) {
}
#[AsCommand('app:user:create', description: 'Creates a new user')]
public function create(OutputInterface $output): int
{
// ...
return Command::SUCCESS;
}
#[AsCommand('app:user:delete', description: 'Deletes an existing user')]
public function delete(OutputInterface $output): int
{
// ...
return Command::SUCCESS;
}
}Chaque méthode annotée devient une commande indépendante par autoconfiguration. En usage standalone, on enregistre les first-class callables : $application->addCommand($instance->create(...)). Même chose pour CommandTester, qui accepte le callable directement.
Ensuite, les argument resolvers. Le mécanisme des contrôleurs, appliqué aux arguments et options CLI :
use App\Entity\User;
use App\Service\ReportGenerator;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapDateTime;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
#[AsCommand(name: 'app:report:generate')]
final class GenerateReportCommand
{
public function __invoke(
ReportGenerator $reports,
#[Argument, MapEntity]
User $user,
#[Option, MapDateTime(format: 'Y-m-d')]
\DateTimeInterface $date,
): int {
// ...
return Command::SUCCESS;
}
}php bin/console app:report:generate 42 --date=2026-05-08 charge l’entité depuis la base et convertit l’option. Des résolveurs natifs couvrent aussi les enums backed, les UUID et les ULID. Notez ReportGenerator injecté directement dans __invoke() : le constructeur devient optionnel, et #[Autowire] comme #[Target] fonctionnent là aussi.
📨 Messenger : ce qui change pour les workers
Le composant que j’avais détaillé dans Symfony Messenger : exécuter des jobs asynchrones proprement récolte plusieurs améliorations pensées pour le haut volume.
--fetch-size demande plusieurs messages par aller-retour réseau, quand le protocole du transport le permet (SQS jusqu’à 10, XREADGROUP COUNT sur Redis, LIMIT sur Doctrine) :
php bin/console messenger:consume async --fetch-size=8--no-reset accepte maintenant un entier. Entre le reset après chaque message et pas de reset du tout, on peut réinitialiser les services tous les N messages :
php bin/console messenger:consume async --no-reset=100Le reste en vrac : AmqpPriorityStamp expose la priorité native RabbitMQ ; #[AsMessage(serializedTypeName: 'crawler.vectorization_finished')] remplace le FQCN dans le header type, ce qui rend enfin les messages lisibles par un consumer non-Symfony ; getIdleTimeout() vide un batch partiel après un délai d’inactivité au lieu d’attendre qu’il se remplisse.
Le changement que je trouve le plus utile est moins visible. Un message indécodable — la classe a disparu après un refactoring — était jusqu’ici silencieusement jeté de la queue. Il passe désormais par le circuit normal d’échec, retry et transport failed compris, et DecodeFailedMessageMiddleware retente le décodage à chaque tentative. Redéployez la classe manquante, le message repart avec tous ses stamps d’origine.
🔍 DeepCloner : cloner sans passer par serialize()
Le clonage profond en PHP, c’était unserialize(serialize($value)). Efficace, mais lent et gourmand en mémoire : la méthode casse le copy-on-write en reconstruisant tout le graphe depuis une représentation sérialisée.
DeepCloner, dans le composant VarExporter, reconstruit le graphe directement et préserve le COW sur les chaînes et les tableaux :
use Symfony\Component\VarExporter\DeepCloner;
$clone = DeepCloner::deepClone($originalObject);
// pour cloner le même prototype en boucle, le graphe n'est analysé qu'une fois
$cloner = new DeepCloner($prototype);
$clone1 = $cloner->clone();
$clone2 = $cloner->clone();Les benchmarks officiels annoncent 4 fois plus rapide sur un graphe typique de 100 objets, et jusqu’à 15 fois sur 50 objets à 20 propriétés. Un DeepCloner s’exporte aussi en tableau via toArray() / fromArray(), pour une charge utile 30 à 40 % plus légère que serialize(). Et vous en profitez sans rien faire : Symfony s’en sert en interne pour cloner les définitions de services à la compilation, dans le dump du container, sur les snapshots de formulaires et dans l’ArrayAdapter du Cache.
L’équipe publie en parallèle une extension PHP, symfony/php-ext-deepclone, que DeepCloner détecte et utilise à la place du polyfill userland si elle est installée.
🧩 Côté HTTP, moins de plomberie
L’ironie de cette release : pendant qu’on apprend à s’en passer, la couche HTTP se simplifie aussi.
#[Serialize] supprime le trio sérialiseur + JsonResponse + en-tête Content-Type que tout le monde recopie :
use Symfony\Component\HttpKernel\Attribute\Serialize;
final readonly class GetUserController
{
#[Serialize(code: 201, headers: ['X-Custom-Header' => 'abc'])]
public function __invoke(): User
{
return new User(1, 'Jane Smith', '...');
}
}Le format se déduit de la requête, donc /products/42.json et /products/42.xml sortent du même contrôleur. Un format non supporté renvoie automatiquement un 415.
#[MapRequestPayload] progresse sur trois axes : il gère le multipart/form-data et remplit donc une propriété UploadedFile dans le DTO, il accepte les paramètres variadiques (#[MapRequestPayload] Price ...$prices), et son option validationGroups accepte une Expression ou une Closure évaluée au moment de la validation. L’option mapWhenEmpty: true force enfin la dénormalisation sur une entrée vide, ce qui laisse un denormalizer maison injecter l’utilisateur courant dans le DTO.
⚠️ Quelques précautions
- PHP 8.4 minimum. Symfony 8.1 est une mineure maintenue jusqu’en janvier 2027 : elle sert de marche vers la 8.2, pas de socle long terme.
- HTTP-less n’est pas un interrupteur. Vous ne « passez » pas une application web en mode headless. C’est un point de départ pour une nouvelle application, ou pour un binaire console extrait d’un monolithe existant.
- Les alias dépréciés répondent encore.
BundleInterfaceet compagnie fonctionnent toujours depuis HttpKernel. Autant nettoyer les imports maintenant, tant que le diff tient en trois lignes. --fetch-sizedépend du transport. Le worker envoie une indication que le transport suit quand son protocole sait lire par lot, et ignore sinon.--no-reset=100échange de la performance contre du risque d’état partagé. Si un handler laisse traîner de l’état dans un service, cent messages passeront dessus avant le nettoyage. Réglez la valeur sur votre charge réelle plutôt que de recopier la mienne.
🎉 Conclusion
Sur le papier, déplacer un kernel d’un composant à l’autre est une note de bas de page. En pratique, c’est la fin d’un postulat qui datait de Symfony 2 : le framework arrête de supposer qu’il y a une requête à l’autre bout.
Ce que je retiens pour mes projets : les commandes par méthode et les argument resolvers changent le quotidien dès la mise à jour, sans rien réorganiser. Le kernel HTTP-less, lui, est un pari sur la prochaine application que vous démarrerez — un worker, un consumer, un binaire d’import. Le jour où vous l’écrirez, vous n’aurez plus à expliquer pourquoi il dépend de HttpFoundation.
🔗 Liens utiles
- Symfony 8.1 : HTTP-Less Symfony Applications
- Symfony 8.1 : Method-Based Commands
- Symfony 8.1 : Console Argument Resolvers
- Symfony 8.1 : Deep Cloner
- Symfony 8.1 : Messenger Improvements
- Symfony 8.1 : the Serialize Attribute
- Symfony 8.1 : Improved Request Payload Mapping
- Calendrier des versions Symfony
/faq
Questions fréquentes
Qu'est-ce qu'une application Symfony HTTP-less ?
+
C'est une application qui utilise le container de services, les bundles et la configuration de Symfony sans charger le composant HttpKernel. Depuis Symfony 8.1, le kernel vit dans le composant DependencyInjection : un worker Messenger ou une application console n'a plus besoin de la couche HTTP pour démarrer.
Faut-il migrer une application Symfony existante vers le mode HTTP-less ?
+
Non. Le changement est rétrocompatible : HttpKernel\Kernel étend désormais AbstractKernel, donc les applications web existantes continuent de fonctionner sans modification. Le mode HTTP-less est un point de départ pour une nouvelle application headless, pas un interrupteur à activer sur un projet web.
Comment regrouper plusieurs commandes console dans une seule classe ?
+
Symfony 8.1 autorise l'attribut #[AsCommand] sur des méthodes et plus seulement sur la classe. Chaque méthode annotée devient une commande indépendante grâce à l'autoconfiguration, et toutes partagent le constructeur de la classe.
Quelle version de PHP demande Symfony 8.1 ?
+
Symfony 8.1 requiert PHP 8.4.0 ou plus. Sorti en mai 2026, c'est une version mineure maintenue jusqu'en janvier 2027.