Symfony 7.0 landed November 29, 2023, same day as 6.4. The pattern holds: the X.0 release cuts deprecated code and raises the PHP floor. 7.0 requires PHP 8.2 and removes everything that 6.4 flagged as deprecated.

The most visible removal: Doctrine annotations. @Route, @ORM\Column, @Assert - gone. Native PHP attributes have been the recommended approach since Symfony 5.2. 7.0 just makes it official.

:label: Attributes everywhere

The migration from annotations to attributes is mostly mechanical: syntax changes from @ to #[], and the class references move from Doctrine annotation classes to PHP attribute classes:

// before
/** @Route('/users', methods={"GET"}) */

// after
#[Route('/users', methods: ['GET'])]

The real win isn’t just the syntax: attributes are validated by the PHP engine, not a docblock parser. IDEs can resolve them without custom plugins. Static analysis tools understand them natively. No more “it fails silently at runtime because of a typo in a comment.”

:workflow: Workflow with PHP attributes

Workflow event listeners and guards can now be registered via attributes:

#[AsGuard(workflow: 'order', transition: 'ship')]
public function canShip(Event $event): void
{
    if (!$event->getSubject()->isPaymentConfirmed()) {
        $event->setBlocked(true);
    }
}

The workflow profiler, a dedicated panel showing the current marking and available transitions, is a genuinely useful debugging tool if you’re working with complex state machines.

:clock1: DatePoint in the Clock component

DatePoint, the immutable DateTime with strict error handling introduced in 6.4, is now the recommended way to work with dates. Combine it with PHP 8.2’s readonly properties and date value objects in domain code become almost trivially clean:

readonly class Order {
    public function __construct(
        public DatePoint $createdAt,
        public ?DatePoint $shippedAt = null,
    ) {}
}

:wastebasket: What 7.0 removes

The full removal list: Doctrine annotations support, the Templating component bridge, ProxyManager bridge, the Monolog bridge for versions below 3.0, and the Sendinblue transport (replaced by Brevo). PHP 8.0 and 8.1 support also ends. 8.2 is the floor now.

Upgrade from 6.4 with all deprecation notices fixed, and 7.0 is smooth. Skip that step and you’re in for a bad time.

Scheduler and AssetMapper graduate

Two components that shipped as experimental in 6.3 are now stable: Scheduler and AssetMapper. Stable means locked APIs, no more @experimental caveats, and they show up properly in the upgrade guide. You can actually rely on them now.

Scheduler gets #[AsCronTask] and #[AsPeriodicTask] for attribute-based task registration, runtime schedule modification with heap recalculation, FailureEvent, and a --date option on schedule:debug. AssetMapper adds CSS file support in importmap, an outdated command, an audit command, and automatic preloading via WebLink.

#[AsCronTask('0 2 * * *')]
class NightlyReportMessage {}

#[AsPeriodicTask(frequency: '1 hour')]
class HourlyCleanupMessage {}

Service wiring gets two new attributes

#[AutowireLocator] and #[AutowireIterator] landed in 6.4 and graduate to stable in 7.0. They replace the verbose XML/YAML tagged service locator config with something you can just put directly in PHP:

class HandlerRegistry
{
    public function __construct(
        #[AutowireLocator('app.handler', indexAttribute: 'key')]
        private ContainerInterface $handlers,
    ) {}
}

#[Target] also gets smarter: when a service has a named autowiring alias like invoice.lock.factory, you can now write #[Target('invoice')] instead of the full alias name. Less noise when the type already tells you what you want.

Messenger gets more precise failure handling

RejectRedeliveredMessageException tells the worker to not retry a message, which is handy when a message arrives twice because of a transport ack timeout and you need exactly-once semantics. messenger:failed:remove --all clears the entire failure transport in one shot, no loop required. Failed retries can also go directly to the failure transport, bypassing the retry queue entirely.

Multiple Redis Sentinel hosts are now supported in the DSN:

redis-sentinel://host1:26379,host2:26379,host3:26379/mymaster

Console gets signal names and command profiling

SignalMap maps signal integers to their POSIX names. When a worker catches SIGTERM, the log now says SIGTERM instead of 15. Small thing, real improvement. ConsoleTerminateEvent is dispatched even when the process exits via signal, which wasn’t the case before 7.0.

Command profiling lands too: pass --profile to bin/console and the collected data goes straight into the Symfony profiler, browsable from the web UI.

Form: small things that add up

ChoiceType gets a duplicate_preferred_choices option. Set it to false and you stop showing the same option twice when preferred choices overlap with the full list. FormEvent::setData() is deprecated for events where the data is already locked at that point in the lifecycle. The self-closing slash on <input> elements is also gone: <input> is a void element in HTML5 and the slash was technically invalid.

Enum support in forms is a nice one: ChoiceType renders backed enums directly, and translatable enums get their labels through the translator without any custom wiring.

HttpFoundation: small but useful

Response::send() gets a $flush parameter. Pass false to buffer the output without flushing to the client, useful when chaining middleware that needs to inspect the response before it leaves the process.

UriSigner moves from HttpKernel to HttpFoundation, where it belongs semantically. Same class name, different namespace.

Cookies get CHIPS support (Cookies Having Independent Partitioned State), the browser mechanism for cross-site cookies in a first-party partition. Only matters if you build embeddable widgets, but good to know it’s there.

Translation: Phrase provider and tree output

Phrase joins Crowdin and Lokalise as a supported translation provider. Configure it in config/packages/translation.yaml and the translation:push / translation:pull commands handle the sync.

translation:pull gets an --as-tree option that writes translation files in nested YAML rather than flat dot-notation keys. Whether that’s actually better depends entirely on your team.

LocaleSwitcher::runWithLocale() now passes the current locale as an argument to the callback, saving you a getLocale() call inside:

$switcher->runWithLocale('fr', function (string $locale) use ($mailer) {
    $mailer->send($this->buildEmail($locale));
});

A few things in Serializer and DomCrawler

The Serializer’s Context attribute can now target specific classes, so a single DTO can behave differently during (de)serialization depending on which class holds the context. TranslatableNormalizer lands for normalizing objects that implement TranslatableInterface: the translator is called during normalization, not before.

Crawler::attr() gains a $default parameter. Instead of null-checking the return value, pass a fallback:

$src = $crawler->attr('src', '/placeholder.png');

assertAnySelectorText() and assertAnySelectorTextContains() join the DomCrawler assertion set. They pass if at least one matching element satisfies the condition, rather than requiring all of them to match.

HttpClient: HAR responses for testing

MockResponse now accepts HAR (HTTP Archive) files. Record real HTTP interactions in your browser or with a proxy, drop the .har file in your test fixtures, and replay them:

$client = new MockHttpClient(HarFileResponseFactory::createFromFile(__DIR__.'/fixtures/api.har'));

Much better than writing response stubs by hand when you’re dealing with a complex API.