Symfony 3.4 LTS: the bridge you actually want to cross
Symfony 3.4 and 4.0 were released the same day: November 30, 2017. That’s not a coincidence, it’s the strategy.
3.4 is not a feature release. It ships with exactly the same features as 3.3, plus every deprecation warning that 4.0 will enforce. Its whole purpose is to be the migration tool: upgrade from 3.3 to 3.4, fix what’s in your logs, then step to 4.0 cleanly.
Why LTS releases matter in Symfony’s model
Symfony releases a new minor version every six months. That pace would be brutal for production apps to follow, so the project designates every fourth minor as an LTS: three years of bug fixes, four of security fixes. Which means teams can target 3.4 and mostly stop thinking about upgrades for a while.
3.4 is the last LTS of the 3.x line. If you’re still on 2.x or early 3.x, this is your landing zone.
The deprecation layer
Every feature that 4.0 removes is deprecated in 3.4. Run your app on 3.4 with deprecation notices enabled and your logs become a to-do list. The common ones:
- Services without explicit visibility (public/private) generate warnings — 4.0 makes all services private by default
-
ControllerTraitis deprecated in favor ofAbstractController - The old security authenticator interfaces are marked for removal
- YAML-only service configuration without autowiring annotations triggers warnings
The intended workflow: upgrade to 3.4, run the test suite with deprecation notices as errors (SYMFONY_DEPRECATIONS_HELPER=max[self]=0 in PHPUnit), fix everything that fails. After that, the upgrade to 4.0 is basically mechanical.
The support window
3.4 LTS receives bug fixes until November 2020 and security fixes until November 2021. That’s a comfortable runway for apps that can’t follow every release. The cost: staying on the 3.x architecture, with no Flex, no micro-framework structure, no zero-config autowiring by default.
The bridge is there. Whether and when you cross it is a business decision, not a technical one.
Services go private
3.4 flipped the default visibility of services from public to private. Before this, $container->get('app.my_service') was perfectly normal code. After this, it’s an anti-pattern that generates a deprecation warning in 3.4 and breaks entirely in 4.0.
The reasoning is simple: fetching services directly from the container hides dependencies and defeats static analysis. If you inject through the constructor, the container can optimize the graph, tree-shake unused services, and catch mistakes at compile time. If you pull them at runtime, it can’t.
For apps already using autowiring, the migration is usually small. The sticky point is controllers that extend Controller and call $this->get('something'). The fix is switching to AbstractController, which provides the same shortcuts but through lazy service locators instead of raw container access.
For services that genuinely need to be public (accessed from legacy code or functional tests), mark them explicitly:
services:
App\Service\LegacyAdapter:
public: true
Binding scalar arguments once
A classic friction point with autowiring: scalar constructor arguments. If ten services all need $projectDir, you had to configure each one individually. The bind key under _defaults fixes that:
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$projectDir: '%kernel.project_dir%'
$mailerDsn: '%env(MAILER_DSN)%'
Psr\Log\LoggerInterface $auditLogger: '@monolog.logger.audit'
Any service with a constructor parameter named $projectDir gets the bound value automatically. You can also bind by type-hint, which handles the common case where multiple logger channels exist and you need a specific one. Bindings in _defaults apply to all services in the file; you can override per-service if needed.
Injecting tagged services without a compiler pass
Before 3.4, collecting all services with a given tag meant writing a compiler pass. Now there’s a YAML shorthand:
services:
App\Chain\TransformerChain:
arguments:
$transformers: !tagged app.transformer
class TransformerChain
{
public function __construct(private iterable $transformers) {}
}
The !tagged notation creates an IteratorArgument: services are lazily instantiated as you iterate, so unused transformers never get built. For ordering, add a priority attribute to the tag definition on each service.
A logger that ships with the framework
No Monolog? No problem. Symfony 3.4 includes a PSR-3 logger that writes to php://stderr by default. Autowire it with Psr\Log\LoggerInterface:
use Psr\Log\LoggerInterface;
class MyService
{
public function __construct(private LoggerInterface $logger) {}
public function doSomething(): void
{
$this->logger->warning('Something questionable happened', ['context' => 'here']);
}
}
The default minimum level is warning. The target is container and Kubernetes workloads where stderr is the natural log sink. It’s deliberately minimal: no handlers, no processors, no channels. When you need those, install Monolog.
Guard authenticators got a supports() method
The Guard component’s getCredentials() method was pulling double duty: deciding whether the authenticator should handle the request, and extracting the credentials. Returning null was the signal to skip. That made the contract messy.
3.4 added supports() to separate those concerns:
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
public function supports(Request $request): bool
{
return $request->headers->has('X-API-TOKEN');
}
public function getCredentials(Request $request): array
{
// Only called when supports() returns true.
// Must always return credentials now.
return ['token' => $request->headers->get('X-API-TOKEN')];
}
}
The old GuardAuthenticatorInterface is deprecated. The practical benefit: base classes can implement shared getUser() and checkCredentials() logic, while subclasses only override supports() and getCredentials(). One responsibility each.
Two new debug commands
debug:autowiring replaces the old debug:container --types for discovering which type-hints work with autowiring:
$ bin/console debug:autowiring log
Autowirable Services
====================
Psr\Log\LoggerInterface
alias to monolog.logger
Psr\Log\LoggerInterface $auditLogger
alias to monolog.logger.audit
Pass a keyword to filter. No more guessing whether it’s LoggerInterface or Logger.
debug:form gives you the same introspection capability for form types:
$ bin/console debug:form App\Form\OrderType label_attr
Option: label_attr
Required: false
Default: []
Allowed types: array
Without arguments it lists all registered form types, extensions, and guessers. With a type name and option name it shows every constraint on that option. Before this, you either read the source or trial-and-errored your way through.
Sessions got stricter by default
3.4 implements PHP 7.0’s SessionUpdateTimestampHandlerInterface, which brings two things: lazy session writes (only written when data actually changed) and strict session ID validation (IDs that don’t exist in the store are rejected rather than silently created, which blocks a class of session fixation attacks).
The old WriteCheckSessionHandler, NativeSessionHandler, and NativeProxy classes are deprecated. The MemcacheSessionHandler (note: not Memcached) is gone too, since the underlying PECL extension stopped receiving PHP 7 updates.
Twig form themes can now be scoped
Global form themes apply to every form in the app. If one form needs a completely different look, you had no clean way to opt out. The only keyword handles that:
{% form_theme orderForm with ['form/order_layout.html.twig'] only %}
The only keyword disables all global themes for that form, including the base form_div_layout.html.twig. Your custom theme then needs to either provide all the blocks it uses, or explicitly pull them in with {% use 'form_div_layout.html.twig' %}.
Overriding bundle templates without infinite loops
Overriding a bundle template that you also need to extend used to cause a circular reference error. Override @TwigBundle/Exception/error404.html.twig and also try to inherit from it? The old namespace resolution would follow your override and loop forever.
3.4 introduced the @! prefix to explicitly reference the original bundle template, bypassing any overrides:
{# templates/bundles/TwigBundle/Exception/error404.html.twig #}
{% extends '@!Twig/Exception/error404.html.twig' %}
{% block title %}Page not found{% endblock %}
@TwigBundle resolves to your override if one exists. @!TwigBundle always resolves to the original. Override-and-extend, without the gymnastics.