Symfony 6.4 LTS: AssetMapper, Scheduler, Webhook, and the long-term release
Symfony 6.4 landed November 29, 2023. It’s an LTS with a story: four components that shipped as experimental in earlier releases are now stable. The biggest deal is AssetMapper.
AssetMapper
Modern frontend tooling in Symfony meant Webpack Encore. Encore works: it handles transpilation, bundling, versioning, hot reload. It also requires Node.js, a separate build step, and a non-trivial amount of configuration for what is often a pretty modest frontend.
AssetMapper takes a different position. Modern browsers support ES modules natively. Instead of bundling, ship the files as-is, let the browser resolve imports through an importmap, and manage vendor dependencies through downloaded files rather than npm packages.
composer require symfony/asset-mapper
php bin/console importmap:require lodash
No Node.js. No npm. No build step. JavaScript and CSS files are versioned and served directly, with a digest in the URL for cache busting. For apps where the frontend is not the primary engineering concern, this removes an entire toolchain from the equation.
6.4 adds CSS files to the importmap, automatic CSS preloading via WebLink, and commands to audit and update vendor dependencies. The package.json experience, minus npm.
Scheduler
The Scheduler component (periodic and cron-style task scheduling without an external job runner) exits experimental and becomes stable. The API uses attributes:
#[AsCronTask('0 * * * *')]
class HourlyReport implements ScheduledTaskInterface
{
public function run(): void { ... }
}
Backed by Messenger transports, tasks run in any environment where a worker is running. For many use cases, this replaces the classic cron entry + console command pattern.
Webhook and RemoteEvent
Also graduating from experimental: the Webhook component handles incoming webhooks from external services. Instead of writing raw controllers that parse payloads and dispatch events by hand, you configure parsers for known services (Stripe, GitHub, Mailgun) and get typed events.
DatePoint
A new DatePoint class in the Clock component: an immutable DateTime wrapper that throws exceptions on invalid modifiers instead of silently returning false. Small thing, but meaningful for code that manipulates dates and actually wants to know when something goes wrong.
The support window
6.4 LTS gets bug fixes until November 2026 and security fixes until November 2027. The path from 6.4 to 7.4 (the next LTS) runs through the 6.4 deprecation notices, as usual.
Routes without magic strings
FQCN-based route aliases are now generated automatically. If a controller method has a single route, Symfony creates an alias using its fully qualified class name:
// Previously: only 'blog_index' worked
// Now: both work identically
$this->urlGenerator->generate('blog_index');
$this->urlGenerator->generate(BlogController::class.'::index');
For invokable controllers, the alias is just the class name. The practical benefit is IDE navigation and refactoring safety: you’re referencing a class constant, not a string that can silently drift.
Two new DI attributes
#[AutowireLocator] and #[AutowireIterator] join the DI attribute family. Instead of configuring service locators and tagged iterables in YAML, you just declare them on constructor parameters:
public function __construct(
#[AutowireLocator([FooHandler::class, BarHandler::class])]
private ContainerInterface $handlers,
) {}
Aliases, optional services (prefixed with ?), and parameter injection via SubscribedService are all supported. The locator lazy-loads, so only the handlers you actually call get instantiated.
Messenger gets built-in handlers
Three new message classes cover common tasks that previously required custom handlers.
RunProcessMessage dispatches a Process command through the bus. RunCommandMessage does the same for console commands. Both return a context object with the exit code and output. PingWebhookMessage pings a URL, which is useful for monitoring scheduled tasks without spinning up a dedicated health-check service:
$this->bus->dispatch(new RunCommandMessage('cache:clear'));
$this->bus->dispatch(new PingWebhookMessage('GET', 'https://healthchecks.io/ping/abc123'));
The subprocess inheritance problem also got addressed with PhpSubprocess. When you run PHP with a custom memory limit (-d memory_limit=-1), child processes launched with Process don’t inherit it. PhpSubprocess does:
$sub = new PhpSubprocess(['bin/console', 'app:heavy-import']);
Security: three fixes for real situations
The profiler now shows how security badges were resolved during authentication: which ones passed, which failed, and why. Before, you had to add debug output manually when a custom authenticator wasn’t behaving.
Login throttling via RateLimiter now hashes PII in logs automatically. IP addresses and usernames get hashed with the kernel secret before they’re written. No config needed, no regex on log lines.
Firewall patterns now accept arrays:
firewalls:
no_security:
pattern:
- "^/register$"
- "^/api/webhooks/"
No more regex gymnastics for multi-path exclusions.
Logout without a dummy controller
The logout route used to require a controller that did nothing but throw an exception, with a comment explaining that yes, this is intentional. 6.4 eliminates that:
# config/routes/security.yaml
_security_logout:
resource: security.route_loader.logout
type: service
The route loader handles it. The dummy controller is gone. Flex updates the recipe.
The serializer in better shape
Three serializer improvements that each solve a real problem.
Class-level #[Groups] attribute: apply a group to the entire class, then override per property. Useful when a resource has a default serialization group and a few fields that need finer control.
Translatable objects now have a dedicated normalizer. Translatable strings (wrapping Doctrine’s TranslatableInterface) get translated to the locale passed via NORMALIZATION_LOCALE_KEY during normalization. Before this, you had to write a custom normalizer.
In debug mode, JSON decoding errors now use seld/jsonlint for better messages. Instead of “Syntax error”, you get the line and what actually went wrong:
Parse error on line 1: {'foo': 'bar'}
^ Invalid string, used single quotes instead of double quotes
Profilers for the things that weren’t HTTP requests
The command profiler extends the existing profiler to console commands. Add --profile to any command and get a full profiler entry: input/output, execution time, memory, database queries, log messages. Commands that used to need --verbose plus manual timing now have the same debugging experience as HTTP requests.
The workflow profiler does the same for state machines. A new panel shows a graphical representation of your workflows and which transitions fired during the request. Zero configuration.
The DX accumulation
Several smaller additions that compound.
renderBlock() and renderBlockView() on AbstractController let you render a named Twig block and return it as a Response or string. Handy for Turbo Stream responses where you want to update a fragment without a full controller action.
The defined env var processor returns a boolean rather than the value: true if the variable exists and is non-empty, false otherwise. Useful for feature flags driven by environment variables:
parameters:
is_feature_enabled: '%env(defined:FEATURE_FLAG_KEY)%'
HttpClient now accepts max_retries per request, overriding the global retry strategy. The Finder component’s filter() method accepts a second argument to prune entire directories early, which matters when you’re searching large trees.
The BrowserKit click() method now accepts server parameters as extra headers, useful in functional tests that need to simulate authenticated API calls while following links.
Impersonation becomes usable in templates
Two new Twig helpers: impersonation_path() and impersonation_url(). They generate the correct URLs including the switch-user query parameter, which is configurable and has no business being hardcoded in templates. Pair them with the existing impersonation_exit_path() for the full admin impersonation flow.
Locale control, everywhere it was missing
Three gaps filled. TemplatedEmail now has a locale() method for rendering emails in the recipient’s language. The locale switcher’s runWithLocale() now passes the locale as an argument to the callback, so you don’t have to capture it from the outer scope. And app.enabledLocales is available in Twig, so you can build language switchers without hardcoding locale lists.
Deploying to read-only filesystems
APP_BUILD_DIR is now an environment variable recognized by the kernel. Set it to redirect compiled artifacts (router cache, Doctrine proxies, preloaded translations) to a directory that exists, even when the default cache directory doesn’t. The MicroKernelTrait uses it automatically. The WarmableInterface gained a $buildDir parameter to support this separation: custom cache warmers that write read-only artifacts should update accordingly.