Symfony 5.4 landed November 29, 2021, same day as Symfony 6.0 and one day after PHP 8.1 was released. Not a coincidence.

5.4 is the LTS, and its job is to carry as much of 6.0’s feature set as possible while keeping 5.x compatibility intact. It’s also the first Symfony release that actually understands PHP 8.1 features.

:card_index_dividers: Enum support

PHP 8.1 introduced native enums. Symfony 5.4 embraces them immediately:

enum Status: string {
    case Active = 'active';
    case Inactive = 'inactive';
}

The EnumType form type renders enums as select fields, no custom transformers needed. The validator understands backed enums. The serializer maps enum values to their backing type and back. Three components updated in one shot, which meant migrating codebases from pseudo-enum constants to real PHP 8.1 enums was actually pretty smooth.

:busts_in_silhouette: Security voter cache

The CacheableVoterInterface lets voters that always abstain on a given attribute signal that to the security system, which can then skip them on subsequent checks. For apps with many voters, the gain on permission checks adds up fast. Small change, noticeable in practice.

:envelope: Messenger matures further

Messenger batch processing (handling multiple messages in a single transaction instead of one by one) is now stable. Rate limiting per transport. Dead letter queues get better tooling. After years as “experimental”, Messenger in 5.4 is finally the async foundation you can bet on for serious workloads.

Console grew a tab key

Symfony 5.4 ships shell autocompletion for all commands. Press Tab and the shell suggests command names, argument values, and option values. For built-in commands this works out of the box. For custom commands, add a complete() method:

use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;

public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
    if ($input->mustSuggestOptionValuesFor('format')) {
        $suggestions->suggestValues(['json', 'xml', 'csv']);
    }
}

No interface required, just the method and Symfony picks it up. The community also went through all built-in commands (debug:router, cache:pool:clear, secrets:remove, lint:twig, and a dozen more) to add completions before the release.

Routes can be aliases now

The routing component now supports aliasing: one route can point to another. The obvious use case is renaming a route without breaking anything that still generates URLs with the old name.

# config/routes.yaml
admin_dashboard:
    path: /admin

# legacy name kept during transition
dashboard:
    alias: admin_dashboard
    deprecated:
        package: 'acme/my-bundle'
        version: '2.3'

Generating a URL with dashboard still works, but fires a deprecation notice. Clean rename paths for bundles that need to maintain public route names while moving on.

Exceptions map to HTTP status codes in config

Before 5.4, mapping an exception class to an HTTP status code meant implementing HttpExceptionInterface or writing a listener. Now it’s just a YAML entry:

# config/packages/framework.yaml
framework:
    exceptions:
        App\Exception\PaymentRequiredException:
            status_code: 402
            log_level: warning
        App\Exception\MaintenanceException:
            status_code: 503
            log_level: info

The exception doesn’t need to implement anything. The framework reads the map, sets the status code, logs at the configured level. Handy for domain exceptions that have no business knowing about HTTP.

Two new validator constraints

5.4 adds Cidr and CssColor to the Validator component.

Cidr validates network notation — IP address plus subnet mask — with control over which IP version to accept and bounds on the mask value:

#[Assert\Cidr(version: 4, netmaskMin: 16, netmaskMax: 28)]
private string $allowedSubnet;

CssColor validates that a string is a valid CSS color. Useful for theme editors, CMS config, or any UI that lets users pick colors:

#[Assert\CssColor(
    formats: Assert\CssColor::HEX_LONG,
    message: 'The accent color must be a 6-digit hex value.',
)]
private string $accentColor;

Nested PHP attributes for validation constraints

Symfony 5.2 added validator constraints as PHP attributes, but PHP 8.0 had a hard limit on nested attributes. Complex constraints like All, Collection, or AtLeastOneOf were impossible to express in attribute syntax alone. PHP 8.1 lifted that restriction, and 5.4 makes the most of it:

use Symfony\Component\Validator\Constraints as Assert;

class CartItem
{
    #[Assert\All([
        new Assert\NotNull(),
        new Assert\Range(min: 1),
    ])]
    private array $quantities;

    #[Assert\AtLeastOneOf(
        constraints: [new Assert\Email(), new Assert\Url()],
        message: 'Must be a valid email or URL.',
    )]
    private string $contact;
}

No annotation doc-blocks, no XML mapping. Pure PHP 8.1 attributes all the way down.

Dependency injection: three things worth knowing

Tagged iterators can now be injected into service locators, which previously only accepted explicit service lists. Union type autowiring works when both sides of the union resolve to the same service, which is common with serializer interfaces:

public function __construct(
    private NormalizerInterface & DenormalizerInterface $serializer
) {}

#[SubscribedService] replaces the automatic introspection that ServiceSubscriberTrait did implicitly. It’s now an explicit attribute on methods, which makes the dependency visible without any magic:

use Symfony\Contracts\Service\Attribute\SubscribedService;

class SomeService implements ServiceSubscriberInterface
{
    #[SubscribedService]
    private function router(): RouterInterface
    {
        return $this->container->get(__METHOD__);
    }
}

Messenger: attributes, worker state, and service reset

Messenger handlers can drop the MessageHandlerInterface in favor of #[AsMessageHandler], which also lets you bind a handler to a specific transport and set its priority, all without touching YAML:

#[AsMessageHandler(fromTransport: 'async', priority: 10)]
class ProcessOrderHandler
{
    public function __invoke(ProcessOrder $message): void { /* ... */ }
}

Worker state is now inspectable via WorkerMetadata inside event listeners, useful when you have workers on multiple transports and need to know which one fired a given event.

Long-running workers accumulate state across messages: entity manager buffers, in-memory caches, open connections. The new reset_on_message option takes care of resetting all resettable services between messages:

framework:
    messenger:
        reset_on_message: true

Serializer: collect errors instead of throwing

Deserializing external JSON into a typed DTO used to throw on the very first type mismatch. The COLLECT_DENORMALIZATION_ERRORS option changes that: all type errors get collected into a PartialDenormalizationException, so you can return a proper 400 with a full list of field-level problems:

try {
    $dto = $serializer->deserialize($request->getContent(), OrderDto::class, 'json', [
        DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
    ]);
} catch (PartialDenormalizationException $e) {
    return $this->json(
        array_map(fn($err) => ['path' => $err->getPath(), 'expected' => $err->getExpectedTypes()], $e->getErrors()),
        400
    );
}

The serializer’s default context can also be set globally in YAML, so you stop passing the same options on every call.

Language negotiation out of the box

Two new framework options handle the Accept-Language header without custom listeners:

framework:
    enabled_locales: ['en', 'fr', 'de']
    set_locale_from_accept_language: true
    set_content_language_from_locale: true

With this in place, Symfony reads the browser’s preferred language, picks the best match from enabled_locales, sets the request locale, and adds a Content-Language header to the response. The {_locale} route attribute still takes precedence when present.

Translation: extraction, not update

The translation:update command is renamed to translation:extract. The old name sticks around as deprecated. The distinction matters: the command never writes to a database, it extracts translatable strings from source files. The new name finally says what it does.

lint:xliff also gains a --format=github option that outputs errors as GitHub Actions annotations, so translation lint failures show up as PR review comments instead of getting buried in log output.

Controller shortcuts pruned

Three AbstractController shortcuts are deprecated: getDoctrine(), dispatchMessage(), and the generic get() method for pulling arbitrary services from the container. The direction is explicit constructor injection. For getDoctrine() specifically:

// before
$em = $this->getDoctrine()->getManager();

// after — inject it directly
public function __construct(private EntityManagerInterface $em) {}

Request::get() is also deprecated. It searched route attributes, query string, and POST body in an undocumented order, which was a great way to get surprising results. Use $request->query->get(), $request->request->get(), or $request->attributes->get() and be explicit about where the value comes from.

The Path utility class

The Filesystem component gets a Path class ported from webmozart/path-util. It handles the awkward cases that dirname() and realpath() fumble:

use Symfony\Component\Filesystem\Path;

Path::canonicalize('../config/../config/services.yaml'); // '../config/services.yaml'
Path::getDirectory('C:/');                               // 'C:/' (dirname() returns '.')
Path::getLongestCommonBasePath([
    '/var/www/project/src/Controller/FooController.php',
    '/var/www/project/src/Controller/BarController.php',
    '/var/www/project/src/Entity/User.php',
]);
// '/var/www/project/src'

Useful whenever your code deals with paths that cross OS boundaries or involve relative segments.

Smaller things that add up

debug:dotenv shows which .env files were loaded and what value each variable resolves to. The first thing you reach for when environment-specific behavior is acting up.

The String component adds trimPrefix() and trimSuffix() for removing known prefixes or suffixes without writing a substr calculation:

u('file-image-0001.png')->trimPrefix('file-');    // 'image-0001.png'
u('template.html.twig')->trimSuffix('.twig');      // 'template.html'

DomCrawler gets innerText(), which returns only the direct text of a node, excluding child elements. text() returns everything including nested text; innerText() returns just the node’s own content. Small difference, but it matters when scraping.

The RateLimiter component extends its interval support to perMonth() and perYear(), for apps that need to limit events over longer windows: newsletter sends, API quota resets, annual plan limits.

The Finder component now respects .gitignore files in all subdirectories when you call ignoreVCSIgnored(true), not just the root. Child directory rules override parent rules, exactly like git itself.

:calendar: The LTS window

5.4 gets bug fixes until November 2024 and security fixes until November 2025. The migration from 5.4 to 6.4 (the next LTS) is intentionally smooth: fix the 5.4 deprecation warnings, and the 6.x jump is mechanical.

The deprecation layer in 5.4 points at everything 6.0 removes: the remaining pieces of the old security system, ContainerAwareTrait, and a handful of legacy form and serializer patterns.