Symfony 5.0 released November 21, 2019, same day as 4.4. Where 4.4 is about stability and a long support window, 5.0 is the next chapter: no deprecated code, PHP 7.2.5 minimum, and a handful of new components that finally address gaps that had piled up for years.

:abc: The String component

PHP’s string handling is famously scattered: prefix-style functions here (str_), suffix-style there (strpos), inconsistent encoding support, and nothing object-oriented in sight. The String component wraps all of this into a fluent, unicode-aware object API:

use Symfony\Component\String\UnicodeString;

$str = new UnicodeString('  Hello World  ');
echo $str->trim()->lower()->replace(' ', '-'); // hello-world

The practical addition is the Slugger, a locale-aware slug generator that actually handles accented characters correctly:

$slug = $slugger->slug('L\'été à Montréal'); // l-ete-a-montreal

Before, you’d pull in a third-party library or write your own. Now it ships with FrameworkBundle, available by default.

:bell: Notifier

Email is handled by Mailer. SMS, push notifications, chat messages: no first-party story, until now. The Notifier component adds one: a unified interface over dozens of channels and providers.

The same notification can hit Slack, trigger an SMS via Twilio, or end up as a push notification, all configured through DSNs. Adding a new channel is a config change, not a code change.

:key: Secrets vault

Storing secrets in .env files works, but the values are plain text, shared environments are a pain, and there’s no native way to encrypt anything at rest.

Symfony 5.0 adds a secrets: command family and a vault mechanism. Secrets are encrypted with a key pair stored outside the repository. The encrypted files get committed; the decrypt key does not. In production, the key comes in as an environment variable or gets injected from a secret manager.

php bin/console secrets:set DATABASE_PASSWORD
php bin/console secrets:decrypt-to-local --force

Not a full-blown secrets management solution, but a real step up from a plain .env file sitting unencrypted in your repo.

Mailer gets a notification layer

The Mailer component arrived in 4.4. What 5.0 adds on top is the NotificationEmail — a pre-styled, responsive email built on Foundation for Emails, with an explicit API for importance levels and call-to-action buttons:

use Symfony\Bridge\Twig\Mime\NotificationEmail;

$email = (new NotificationEmail())
    ->from('alerts@example.com')
    ->to('admin@example.com')
    ->subject('Disk usage critical')
    ->markdown('The disk on **prod-01** is at 94%. Check it now.')
    ->action('Open dashboard', 'https://example.com/servers')
    ->importance(NotificationEmail::IMPORTANCE_URGENT);

No template to write, no inline CSS to wrestle with. For transactional alerts, billing notifications, and system emails, it covers 80% of what you need without touching anything.

Lazy firewalls and the caching problem

Every stateful firewall in Symfony loads the user from session on every request, whether the action needs it or not. Which means any response is uncacheable by default, even for pages that never touch $this->getUser().

5.0 adds lazy mode for firewalls, which defers session access until the code actually calls is_granted() or reaches for the user token:

# config/packages/security.yaml
security:
    firewalls:
        main:
            pattern: ^/
            anonymous: lazy

Pages that don’t need the user become cacheable again. New projects get this by default via the Flex recipe; existing ones need a one-line config change.

Password migrations without the big bang

Migrating a live app from bcrypt to argon2id used to mean forcing a password reset on every user. The PasswordUpgraderInterface makes it gradual: at login, Symfony checks whether the stored hash matches the current algorithm. If not, it rehashes on the spot and calls your upgrader to save it:

// src/Repository/UserRepository.php
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
    public function upgradePassword(UserInterface $user, string $newHashedPassword): void
    {
        $user->setPassword($newHashedPassword);
        $this->getEntityManager()->flush();
    }
}

Pair that with algorithm: auto in the encoder config, and old hashes migrate silently as users log in. No migration script, no downtime, no user friction.

ErrorHandler replaces Debug

The Debug component is gone. Its replacement, ErrorHandler, does the same job (converting PHP errors to exceptions, showing nice error pages) but without requiring Twig. For API apps that never render HTML, that matters: ErrorHandler generates errors in the format of the request (JSON, XML, plain text) following RFC 7807:

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

The routing config moves from TwigBundle to FrameworkBundle, and that’s the only migration step for most projects. One line, done.

Event listeners, finally less verbose

Registering a kernel event listener used to mean explicitly naming the event in the service tag. Symfony 5.0 infers it from the method signature:

// No tag configuration needed beyond kernel.event_listener
final class SecurityListener
{
    public function onKernelRequest(RequestEvent $event): void
    {
        // Symfony reads the type hint and figures out the event
    }
}
# config/services.yaml
App\EventListener\SecurityListener:
    tags: [kernel.event_listener]

Use __invoke() and it works the same way. Bulk-register a whole directory of listeners with one resource block, and Symfony figures out which event each one handles.

HttpClient grows up

The HttpClient component arrived in 4.4 as stable. 5.0 adds a few useful things on top:

NTLM authentication for corporate environments, conditional buffering via a callback (buffer large responses only when the content-type matches), a max_duration option that caps the total request time regardless of network conditions, and toStream() to turn any response into a standard PHP stream for code that expects fread():

$response = $client->request('GET', 'https://api.example.com/large-export', [
    'max_duration' => 30.0,
    'buffer' => fn(array $headers): bool => str_contains($headers['content-type'][0] ?? '', 'json'),
]);

// Stream it instead of loading it all into memory
$stream = $response->toStream();

The client also got full interoperability with PSR-18 and HTTPlug v1/v2, so any library that depends on those abstractions just works with it.

:broom: What 5.0 removes

5.0 drops everything deprecated in 4.4. The most notable:

  • WebServerBundle (use symfony server:start from the CLI tool instead)
  • The old security system’s AnonymousToken (replaced by NullToken)
  • Old form event names
  • Symfony’s internal ClassLoader
  • The Debug component (replaced by ErrorHandler)

If you ran your 4.4 app with deprecation notices active and fixed the warnings, upgrading to 5.0 requires no code changes.