Symfony 6.0: PHP 8.1 only, and the security system rebuilt
Symfony 6.0 released November 29, 2021. The defining characteristic: PHP 8.1 is the minimum. Not supported, required. The releases team waited for PHP 8.1 to ship, then cut Symfony 6.0 the next day.
This isn’t just a version bump. It’s a commitment to build against the current language instead of the historical floor.
The security system, finally rebuilt
The Symfony security component has two systems. The old one (AnonymousToken, GuardAuthenticatorInterface, a tangle of interfaces that made you implement methods you didn’t need) had been deprecated. 6.0 removes it entirely.
The new security system (security.enable_authenticator_manager: true in 5.x) is now the only system. It’s cleaner: one interface to implement, clear separation between authentication and authorization, passport-based credential checking. The upgrade from the old guard authenticators isn’t painless, but the destination is a lot less confusing.
The Filesystem Path class
Working with filesystem paths in PHP is basically a string manipulation problem. __DIR__, concatenation, realpath(), platform-specific separators: the standard library gives you primitives but no real model.
The new Path class handles this:
use Symfony\Component\Filesystem\Path;
Path::join('/var/www', 'html', '../uploads'); // /var/www/uploads
Path::makeRelative('/var/www/html', '/var/www'); // html
Path::isAbsolute('./relative/path'); // false
Cross-platform, no side effects, no filesystem access needed. Also in 6.0: nested .gitignore pattern support in Finder.
Enums in the form system
Building on 5.4’s groundwork, 6.0 takes enum support further. BackedEnum values round-trip through forms and the serializer without custom transformers. The form component understands enum cases as choice options out of the box.
What 6.0 removes
The removal list is extensive: the old security system, the Templating component, PHP annotations support (replaced by native attributes), Doctrine Cache support, ContainerAwareTrait. Six years of accumulated @deprecated markers, finally cleaned out.
Apps that took 5.4 deprecation warnings seriously had a clean upgrade path. Apps that didn’t had work to do.
Tab completion was always the gap
The Console component got shell autocompletion, and it’s properly integrated: define a complete() method on your command, and Tab in Bash will suggest valid values for options and arguments.
class DeployCommand extends Command
{
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('env')) {
$suggestions->suggestValues(['prod', 'staging', 'dev']);
}
}
}
All built-in Symfony commands got completion too: debug:router, cache:pool:clear, lint:yaml, and about fifteen others. Run bin/console completion bash >> ~/.bashrc and you’re done.
Messenger, now with attributes and batch processing
The #[AsMessageHandler] attribute replaces the old MessageHandlerInterface. Less boilerplate, and you can now configure transport affinity and priority directly on the attribute:
#[AsMessageHandler(fromTransport: 'async', priority: 10)]
class SendWelcomeEmailHandler
{
public function __invoke(UserRegistered $message): void { ... }
}
The other significant addition: BatchHandlerInterface. When you’re inserting a thousand rows, handling messages one by one is wasteful. Batch handlers collect messages and process them in groups. The default batch size is 10, controlled by BatchHandlerTrait::shouldFlush(). The Acknowledger handles individual success and failure within the batch.
reset_on_message: true in the Messenger config resets container services between messages. Previously, a Monolog buffer could fill up across message handling and nobody noticed until production. This prevents that class of statefulness bug without requiring manual cleanup.
The DI container gets more expressive
Three changes that matter in practice.
Union and intersection types now autowire. PHP 8.1 added intersection types, and Symfony 6.0 wires them:
public function __construct(
private NormalizerInterface&DenormalizerInterface $serializer
) {}
This works as long as both interfaces point to the same service through autowiring aliases.
TaggedIterator and TaggedLocator attributes gained defaultPriorityMethod and defaultIndexMethod options. You no longer need YAML to express ordering or indexing for tagged services:
public function __construct(
#[TaggedIterator(tag: 'app.handler', defaultPriorityMethod: 'getPriority')]
private iterable $handlers,
) {}
SubscribedService (the attribute that replaces the implicit magic of ServiceSubscriberTrait) makes lazy service access explicit and typeable:
#[SubscribedService]
private function mailer(): MailerInterface
{
return $this->container->get(__METHOD__);
}
Validation gets three new tools
CssColor validates CSS color values in whatever formats you care about: hex, RGB, HSL, named colors, or any mix. Useful for theme config fields where you want to accept #ff0000 but not red, or vice versa.
#[Assert\CssColor(formats: Assert\CssColor::HEX_LONG)]
private string $brandColor;
Cidr validates CIDR notation for IPv4 and IPv6, with options to pin the version and constrain the netmask range. Infrastructure tools and network config forms finally have a first-class constraint.
The third addition isn’t a new constraint. It’s PHP 8.1 nested attributes making existing compound constraints usable without XML. AtLeastOneOf, Collection, All, Sequentially: all of these previously required annotation workarounds. Now they just work as attributes:
#[Assert\Collection(
fields: [
'email' => new Assert\Email(),
'role' => [new Assert\NotBlank(), new Assert\Choice(['admin', 'user'])],
]
)]
private array $payload;
Serializer, cleaned up
Two things. First, serialization context is now configurable globally instead of being repeated on every serialize() call:
# config/packages/serializer.yaml
serializer:
default_context:
enable_max_depth: true
Second, the COLLECT_DENORMALIZATION_ERRORS option changes how the serializer handles type errors on deserialization. Instead of throwing on the first problem, it collects all of them and surfaces them through PartialDenormalizationException. If you’re writing an API that deserializes request bodies, this is the difference between returning “first field that fails” and “all fields that fail” in a single response.
The string utilities nobody knew they needed
trimPrefix() and trimSuffix() on the UnicodeString / ByteString classes. Not glamorous, but stripping a known prefix with ltrim() is a subtle footgun: it strips characters, not strings. These are correct:
use function Symfony\Component\String\u;
u('file-image-001.png')->trimPrefix('file-'); // 'image-001.png'
u('report.html.twig')->trimSuffix('.twig'); // 'report.html'
Also in this release: NilUlid for zero-value ULIDs, perMonth() and perYear() on RateLimiter for when hourly limits don’t make sense, and appendToFile() in the Filesystem component gained an optional LOCK_EX parameter for concurrent writers.
Debugging the environment
debug:dotenv is a new console command that shows which .env files were loaded and where each value came from. When you have .env, .env.local, .env.test, and .env.test.local all fighting each other and something is wrong, this command tells you exactly which file won. It only shows up when the Dotenv component is in use, which is the case for any standard Symfony app.