Symfony 8.1 shipped May 29, 2026. It keeps PHP 8.4 as the minimum and introduces no breaking changes. The headline addition is architectural: the kernel is no longer coupled to HttpKernel. Everything else is incremental but genuinely useful.
An application without HTTP
Every Symfony app has shipped an HttpKernel-based kernel since the beginning, even when the app served no HTTP traffic at all. A Messenger worker that consumed from an SQS queue still dragged in the full HTTP machinery. 8.1 fixes this at the root.
The AbstractKernel and KernelTrait now live in the DependencyInjection component. HttpKernel\Kernel extends AbstractKernel, so existing apps are fully backward compatible. What’s new is that you can write a kernel that extends AbstractKernel directly, without the HTTP layer.
Two new bundles make this useful immediately:
ServicesBundlewires DI, the event dispatcher, and the clock, with no HTTP dependency.ConsoleBundlebuilds on top of it and adds the command resolver.
For CLI-only applications, the setup is now:
// 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;
}
Bundle authors get a #[RequiredBundle] attribute to declare dependencies between bundles explicitly, with an optional ignoreOnInvalid flag when the dependency is soft:
#[RequiredBundle(AcmeCoreBundle::class)]
#[RequiredBundle(AcmeUtilBundle::class, ignoreOnInvalid: true)]
class AcmeBlogBundle extends AbstractBundle {}
Declarative rate limiting
8.1 adds a #[RateLimit] attribute for controllers. Drop it on an action and it enforces the limit you configured in framework.rate_limiter, returning a 429 with Retry-After automatically.
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 { /* ... */ }
}
The attribute is repeatable, so stacking two policies on the same action works. By default, the bucket key combines the client IP, HTTP method, and path. You can override it with an Expression Language value for user-based buckets:
use Symfony\Component\ExpressionLanguage\Expression;
#[RateLimit('per_account', key: new Expression('request.request.get("email")'))]
public function resetPassword(): Response { /* ... */ }
The fixed_window policy also gains anchor_at, which aligns resets to a calendar moment rather than first request. Useful for monthly billing quotas:
framework:
rate_limiter:
api_quota:
policy: 'fixed_window'
limit: 10000
interval: '1 month'
anchor_at: '2026-01-05 00:00:00 UTC'
Dependency injection
Several DI improvements land in 8.1.
Env vars as Closure or Stringable. Long-running workers sometimes need to refresh environment variables between iterations. Autowiring an env var as a Closure gives you a factory instead of a fixed value:
class Worker
{
public function __construct(
#[Autowire(env: 'DB_URL')]
private \Closure $dbUrl,
#[Autowire(env: 'APP_NAME')]
private string|\Stringable $appName = 'default',
) {}
}
#[AsTagDecorator]. Decorate every service carrying a given tag with a single attribute on the decorator class:
#[AsTagDecorator('app.handler')]
class LoggingHandler
{
public function __construct(private object $inner) {}
}
Env var names with dots. Names like DATABASE.PRIMARY.URL are now valid, which matters when consuming env from cloud platforms that structure them with dots.
Messenger
Messenger gets several practical additions in 8.1.
Batch fetching. A new --fetch-size option on messenger:consume reduces the number of round-trips to the transport. With SQS (which allows up to 10 messages per receive call), this cuts overhead noticeably at high throughput:
php bin/console messenger:consume async --fetch-size=8
Custom serialized type name. When a non-Symfony consumer needs to identify a message by a stable string instead of a PHP class name, use #[AsMessage]:
#[AsMessage(serializedTypeName: 'crawler.vectorization_finished')]
final readonly class VectorizationFinished
{
public function __construct(public string $crawlId) {}
}
AMQP priority per message. AmqpPriorityStamp sets the priority on a single dispatch rather than at the queue level:
$bus->dispatch($message, [new AmqpPriorityStamp(5)]);
Reset control. --no-reset=100 runs the service reset every 100 messages instead of after every one, which reduces the overhead of long-running workers at scale.
Idle timeout for BatchHandler. Partial batches are now flushed after a configurable idle period, not only when the batch is full.
Listable Redis receiver. The Redis receiver now exposes all() and find(), making it possible to inspect queued messages programmatically.
Console
Method commands. Multiple commands can live in a single class as methods. Less boilerplate when a feature produces a cluster of related commands.
#[AskChoice]. Declare an interactive choice prompt directly in the argument signature, with enum support:
#[AsCommand('app:create-user')]
class CreateUserCommand
{
public function __invoke(
#[Argument, AskChoice('Select a role', ['admin', 'editor', 'viewer'])]
string $role,
): int { /* ... */ }
}
Validation on #[Ask]. Interactive prompts can now carry Validator constraints:
public function __invoke(
#[Argument, Ask('Enter your email:', constraints: [
new Assert\NotBlank(),
new Assert\Email(),
])]
string $email,
): int { /* ... */ }
#[MapInput]. Map command arguments and options into a validated DTO automatically.
RawInputInterface. Gives access to raw tokenized input, useful when you need to forward arguments to a subprocess without re-parsing them.
HttpClient
Persistent cURL connections (PHP 8.5+) reuse DNS cache and SSL sessions across requests, cutting latency for high-frequency clients.
GuzzleHttpHandler. You can now use Symfony HttpClient as the Guzzle transport, which lets existing code using Guzzle benefit from Symfony’s retry, mock, and scoping features without a full migration:
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\GuzzleHttpHandler;
$guzzle = new \GuzzleHttp\Client(['handler' => new GuzzleHttpHandler()]);
SSRF allowlist for NoPrivateNetworkHttpClient. Pass a specific IP or range to allow through the private network block:
$client = new NoPrivateNetworkHttpClient(HttpClient::create(), null, '10.0.0.42');
max_connect_duration. A timeout that applies only to the connection phase, not the full request, giving you finer control over slow DNS and TLS handshakes.
Request payload mapping
#[MapRequestPayload] now handles multipart/form-data, so UploadedFile properties work in mapped DTOs:
class ProductDto
{
public ?string $name = null;
public ?UploadedFile $image = null;
}
public function upload(#[MapRequestPayload] ProductDto $data): Response { /* ... */ }
Variadic arguments let you map a JSON array directly to a spread of typed objects:
public function createPrices(#[MapRequestPayload] Price ...$prices): Response { /* ... */ }
validationGroups now accepts an Expression or a Closure for dynamic group selection. mapWhenEmpty: true triggers denormalization even on an empty payload.
Forms
The daisyUI 5 form theme is now bundled, which covers Tailwind-based frontends without a custom theme:
twig:
form_themes: ['daisyui_5_layout.html.twig']
DateType in choice widget mode accepts a labels option to rename the year, month, and day selects without a custom template. Unchecked checkboxes are now submitted as false automatically rather than being absent from the payload, which was a long-standing inconsistency.
Automatic response serialization
The #[Serialize] attribute on a controller method serializes the return value and wraps it in a Response, picking the format (JSON, XML) from the request format. No more manual $this->json() calls for straightforward API endpoints:
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);
}
}
A route with {_format} in its path returns JSON for /products/42.json and XML for /products/42.xml. Unsupported formats get a 415.
DeepCloner
DeepCloner lands in the VarExporter component as the replacement for Instantiator and Hydrator (both deprecated in 8.1). It clones complex PHP object graphs directly, without the unserialize(serialize()) round-trip, and preserves copy-on-write semantics for strings and arrays.
use Symfony\Component\VarExporter\DeepCloner;
// one-shot
$clone = DeepCloner::deepClone($originalObject);
// reusable cloner for the same prototype
$cloner = new DeepCloner($prototype);
$clone1 = $cloner->clone();
$clone2 = $cloner->clone();
// clone as a compatible subclass
$childDefinition = (new DeepCloner($definition))
->cloneAs(ChildDefinition::class);
A deepclone_hydrate() function replaces the deprecated Hydrator:
$user = deepclone_hydrate(User::class, ['name' => 'Alice']);
DI, FrameworkBundle, Form, and Cache (ArrayAdapter) all use it internally in 8.1. An optional C extension (symfony/php-ext-deepclone) is available for native performance when that matters.
Improved #[Cache] attribute
The #[Cache] attribute on controller methods gains three things in 8.1.
Named expression variables. lastModified and etag now expose request (the Request object) and args (the controller arguments as an array), replacing the previous flat merge that caused name collisions:
#[Cache(
etag: "args['article'].computeETag()",
lastModified: "args['article'].getUpdatedAt()",
public: true,
)]
public function show(Article $article): Response { ... }
Closure support for lastModified and 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 { ... }
Conditional application via if (expression string or closure returning a bool). Cache a response only when the request does not carry a preview parameter:
#[Cache(
public: true,
maxage: 3600,
if: static fn (array $args, Request $request): bool => !$request->query->has('preview'),
)]
public function show(Request $request): Response { ... }
The attribute is also repeatable, so you can stack multiple policies with different conditions on the same method.
JSON streaming and JsonPath
Value objects in JsonStreamer. A ValueObjectTransformerInterface covers types that serialize to a scalar and back. Implement it, tag the service, and JsonStreamer handles the conversion:
/** @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; }
}
Date types. DateInterval serializes to ISO 8601 (P2Y6M1DT12H30M5S), DateTimeZone to the timezone name. The date_time_timezone option handles conversion at encode/decode time.
Default options via config:
framework:
json_streamer:
default_options:
include_null_properties: true
Custom JsonPath functions 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
Clock support in comparison constraints. GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, and Range accept a ClockInterface. With MockClock, relative date expressions like -10 days resolve against a fixed point in time, making temporal validation deterministic in tests.
Reentrant validators. The stateful initialize($context) pattern caused problems when validators were called recursively. A new validateInContext() method replaces validate() + initialize() on ConstraintValidatorInterface. Validators extending the abstract ConstraintValidator class need no changes.
Xml constraint. Validates that a string is well-formed XML, with optional XSD schema validation that reports individual errors with line numbers.
Property existence check. ValidatorBuilder::enablePropertyMetadataExistenceCheck() raises a ValidatorException when a constraint targets a property that does not exist, catching typos at warmup time.
In short
8.1 is a dense feature release. The HTTP-less kernel is the architecturally significant piece: it makes Symfony a credible choice for pure CLI and worker applications without the HTTP overhead. Everything else clusters around ergonomics: less boilerplate on controllers (#[Serialize], #[RateLimit], improved #[Cache]), less boilerplate on commands (ask attributes, method commands), less friction in Messenger (batch size, type names, AMQP priority), a faster deep clone story in VarExporter, and a validator that finally understands time. A lot of long-standing annoyances addressed in one release.