Symfony 4.4 and 5.0 both landed November 21, 2019. 4.4 is the LTS: same feature set as 5.0, deprecation layer baked in, and a long support window for teams that can’t follow every release.

The feature worth singling out arrived in 4.2 and matured through 4.3 and 4.4: HttpClient.

:globe_with_meridians: HttpClient

PHP’s built-in HTTP options (file_get_contents with stream contexts, cURL, Guzzle) each have their own model, their own quirks, and their own abstraction cost. Symfony 4.2 introduced HttpClient, a first-party HTTP client with one API over multiple transports.

The interface is clean:

$response = $client->request('GET', 'https://api.example.com/users');
$users = $response->toArray();

The implementation is async by default. Responses are lazy: the network request doesn’t happen until you actually read the response. Multiple requests can be initiated and resolved as data arrives, no threads or callbacks needed.

The built-in mock transport (MockHttpClient) makes testing HTTP calls painless without spinning up servers or patching global functions.

:envelope: Mailer

Also stabilized in 4.4: the Mailer component, replacing SwiftMailerBundle as the recommended email solution. Transport is configured via DSN:

MAILER_DSN=smtp://user:pass@smtp.example.com:587

The DSN approach means switching providers (Mailgun, Postmark, SES, local SMTP) is a config change, not a code change. Email testing uses a spooler by default in non-production environments.

:envelope_with_arrow: Messenger matures

The Messenger component landed in 3.4 as experimental. By 4.4 it’s stable and battle-tested: async message handling with retry logic, failure transport, and adapters for AMQP, Redis, Doctrine, and in-process transports.

The pattern it enables (handle a request synchronously, dispatch work asynchronously, retry on failure) replaces a class of Gearman/RabbitMQ setups that required separate libraries and significant configuration.

:calendar: The LTS window

4.4 is supported for bugs until November 2022 and security fixes until November 2023. If you’re on 4.x and want stability, this is a comfortable place to land. The deprecation warnings it introduces point directly at what 5.0 will require.

The Messenger component, from experimental to production

Messenger arrived in 4.1 as an experiment. The concept was simple: dispatch a message object to a bus, handle it immediately or route it to a transport for async processing. By 4.3 and 4.4, the experiment had become infrastructure.

The 4.3 release added a dedicated failure transport. When a message fails after all retry attempts, it goes somewhere recoverable rather than just disappearing:

framework:
    messenger:
        failure_transport: failed
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
            failed: 'doctrine://default?queue_name=failed'
        routing:
            App\Message\SendEmail: async

Messages that land in failed can be inspected and retried manually. Before this, failed messages were a log entry and a headache. After this, they’re a queue you can actually work with.

Event dispatching, finally using objects properly

Since the beginning, Symfony’s event system used string event names as the primary identifier. You’d define OrderEvents::NEW_ORDER = 'order.new_order', listen on that string, and pass the event object as a secondary parameter.

4.3 flipped this around. The event object comes first, and the event name becomes optional:

// Before
$dispatcher->dispatch(OrderEvents::NEW_ORDER, $event);

// 4.3+
$dispatcher->dispatch($event);

Omit the name and Symfony uses the class name as the identifier. Listeners and subscribers can now reference the class directly:

public static function getSubscribedEvents(): array
{
    return [
        OrderPlacedEvent::class => 'onOrderPlaced',
    ];
}

The HttpKernel events were renamed accordingly: GetResponseEvent became RequestEvent, FilterResponseEvent became ResponseEvent. The old names stayed as aliases through 4.x.

VarDumper gets a server

dump() in a controller that returns JSON means your debug output gets injected straight into the response body. For API development, that’s annoying enough to make people disable dumping entirely.

4.1 added a VarDumper server that captures dumps separately:

bin/console server:dump

Configure the dump destination in config/packages/dev/debug.yaml:

debug:
    dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"

Now dump() in your API controller sends data to the server’s console instead of polluting the response. The server shows the dump alongside its source file, the HTTP request that triggered it, and the timestamp.

VarExporter, for when var_export() fails you

var_export() has two problems: it ignores serialization semantics and its output isn’t PSR-2 compliant. The 4.2 VarExporter component fixes both.

$exported = VarExporter::export([123, ['abc', true]]);
// Returns:
// [
//     123,
//     [
//         'abc',
//         true,
//     ],
// ]

More importantly, it correctly handles objects implementing Serializable, __sleep, and __wakeup. Where var_export() silently drops serialization methods and exports raw properties, VarExporter produces code that calls the same hooks unserialize() would. The practical use case is cache warming: generating PHP files that can be loaded by OPcache without re-executing expensive computations.

Passwords that check against breach databases

The NotCompromisedPassword constraint arrived in 4.3. It checks submitted passwords against haveibeenpwned.com’s breach database without sending the actual password anywhere.

use Symfony\Component\Validator\Constraints as Assert;

class User
{
    #[Assert\NotCompromisedPassword]
    public string $plainPassword;
}

The implementation uses k-anonymity: SHA-1 hash the password, send only the first five characters to the API, get back all matching hashes, check locally. The password never leaves your server. For registration forms, adding this constraint is one line and a genuinely useful security signal.

Workflow gets context

The Workflow component existed before 4.x, but 4.3 added context propagation: the ability to pass arbitrary data through a transition and access it in listeners.

$workflow->apply($article, 'publish', [
    'user' => $user->getUsername(),
    'reason' => $request->request->get('reason'),
]);

The context arrives in TransitionEvent and gets stored alongside the marking. For audit trails, this is the difference between knowing a transition happened and knowing who triggered it and why. You can also inject context from a subscriber without touching every apply() call, which is handy for cross-cutting concerns like timestamps or current user.

The autowiring got smarter

4.2 added binding by type and name together. Before, you could bind by type (LoggerInterface) or by name ($logger), but not both at once. That caused problems when a service needs two different implementations of the same interface:

services:
    _defaults:
        bind:
            Psr\Log\LoggerInterface $orderLogger: '@monolog.logger.orders'
            Psr\Log\LoggerInterface $paymentLogger: '@monolog.logger.payments'
class OrderService
{
    public function __construct(
        private LoggerInterface $orderLogger,   // gets monolog.logger.orders
        private LoggerInterface $paymentLogger, // gets monolog.logger.payments
    ) {}
}

The match requires both type and argument name to align, so there’s no risk of accidentally injecting the wrong logger.

ErrorHandler replaces the Debug component

The Debug component, unchanged since 2013, had an awkward dependency on TwigBundle even for API-only apps. Any uncaught exception in a JSON API would render an HTML error page unless you wrote custom exception listeners.

4.4 extracts this into a dedicated ErrorHandler component. For non-HTML requests, error responses now follow RFC 7807 out of the box:

{
    "title": "Not Found",
    "status": 404,
    "detail": "Sorry, the page you are looking for could not be found"
}

No Twig required. The format follows the Accept header: JSON for JSON requests, XML for XML requests. To customize further, you provide a normalizer via the Serializer component rather than a Twig template.

PHP 7.4 preloading, wired in automatically

PHP 7.4 introduced OPcache preloading: load files into shared memory before any requests arrive, so they’re available as compiled opcodes from the very first request. The practical gain is 30-50% faster response times with no code changes.

The catch is configuration: you need to specify exactly which files to preload in php.ini. Symfony 4.4 generates that file automatically in the cache directory:

; php.ini
opcache.preload=/path/to/project/var/cache/prod/App_KernelProdContainer.preload.php
opcache.preload_user=www-data

Run cache:warmup in production and point OPcache at the generated file. Symfony preloads the container, compiled routes, and Twig templates: the files that are read on every request and never change between deploys.

Console: return codes and NO_COLOR

Two small things in 4.4 that honestly should have existed earlier. Commands that don’t return an integer from execute() now trigger a deprecation warning. In 5.0, the return type becomes mandatory. Returning 0 for success, non-zero for failure: standard Unix behavior, and it makes integration with process supervisors and CI pipelines unambiguous.

protected function execute(InputInterface $input, OutputInterface $output): int
{
    // ...
    return Command::SUCCESS; // = 0
}

The second: NO_COLOR environment variable support, following the convention from no-color.org. Set it and every Symfony console command drops ANSI escape codes regardless of what the terminal claims to support. Useful for CI environments that capture output as text and then choke on color codes embedded in logs.