Symfony 3.3 shipped May 29th. It’s the release that changed how I think about service configuration. In hindsight, it was basically a preview of what 4.0 would make the new default.

:hammer_and_wrench: The autowiring problem

Before 3.3, Symfony’s DI was powerful but verbose. Every service had to be declared explicitly in services.yml with its arguments listed. Autowiring existed since 3.1, but it was opt-in per service and had enough edge cases to bite you. Teams either wrote mountains of YAML or leaned on third-party bundles to cut the noise.

3.3 rewrote the defaults. With autoconfigure: true and autowire: true set once in the defaults section, every class in src/ becomes a service automatically, and its constructor dependencies are resolved by type. What used to take twenty lines of YAML now takes zero:

services:
    _defaults:
        autowire: true
        autoconfigure: true
    App\:
        resource: '../src/'

That single block is the entire service configuration for most apps. The framework discovers services, injects dependencies, and applies tags (command, event subscriber, voter…) based on the interfaces each class implements.

:card_index_dividers: instanceof conditionals

The instanceof keyword in service configuration handles the tagging that previously required explicit declaration:

services:
    _instanceof:
        Symfony\Component\EventDispatcher\EventSubscriberInterface:
            tags: ['kernel.event_subscriber']

Any service implementing EventSubscriberInterface gets the tag automatically. Same for Command, Voter, MessageHandlerInterface. The boilerplate evaporates.

:page_facing_up: Dotenv component

Before 3.3, Symfony had no built-in way to load .env files. The standard answer was a third-party package. The new Dotenv component reads .env and populates $_ENV and $_SERVER, making environment-based configuration a first-class citizen at last.

Service discovery from the filesystem

The resource option ties it all together. Instead of registering every class individually, you point the container at a directory and it scans for PSR-4 classes:

services:
    App\:
        resource: '../src/'
        exclude: '../src/{Entity,Migrations}'

Every class found becomes a service with its FQCN as the service ID. The exclude option handles things like Doctrine entities that you don’t want the container touching. And no, it’s not magic: it’s a filesystem scan at compile time, so the cost is paid once during cache warmup, not per request.

When you need a subset of the container

Service locators solve a specific tension: some services legitimately need lazy access to a variable set of other services, but injecting the full container is an anti-pattern — it hides dependencies and defeats static analysis. The solution is a locator that explicitly declares what it contains.

services:
    App\Handler\HandlerLocator:
        class: Symfony\Component\DependencyInjection\ServiceLocator
        tags: ['container.service_locator']
        arguments:
            -
                App\Command\CreateOrder: '@App\Handler\CreateOrderHandler'
                App\Command\CancelOrder: '@App\Handler\CancelOrderHandler'

    App\Bus\CommandBus:
        arguments: ['@App\Handler\HandlerLocator']

The locator implements PSR-11’s ContainerInterface, so the receiving class type-hints against Psr\Container\ContainerInterface. Services inside it are lazily instantiated: if a given handler never gets called during a request, it never gets built.

And speaking of PSR-11: Symfony 3.3 made its container implement that standard. Which means any library expecting a PSR-11 container now works directly with Symfony’s container, no adapter needed.

Routing got faster

The routing component rewrote how it generates dump files. In an app with 900 routes, URL matching dropped from 7.5ms to 2.5ms per match: a 66% reduction. The optimizations live in the compiled output, not the runtime path, so existing route definitions benefit automatically after a cache clear.

Finding the project root without counting directory separators

Before 3.3, getting the project root meant using the delightfully awkward %kernel.root_dir%/../ pattern, because getRootDir() pointed at the app/ directory. The new getProjectDir() method walks up from the kernel file until it finds composer.json and returns that directory.

// Before
$path = $this->getParameter('kernel.root_dir') . '/../var/data.db';

// After
$path = $this->getParameter('kernel.project_dir') . '/var/data.db';

The corresponding parameter is %kernel.project_dir%. If you deploy without composer.json, you can override the method in your kernel class and return whatever path makes sense.

Flash messages without touching the session object

The old way of iterating flash messages in Twig required reaching through app.session.flashbag, which also forced the session to start whether or not there were any messages. The new app.flashes helper avoids both:

{% for label, messages in app.flashes %}
    {% for message in messages %}
        <div class="flash-{{ label }}">{{ message }}</div>
    {% endfor %}
{% endfor %}

If there are no flash messages, the session never starts. You can also filter by type: app.flashes('error') returns only error messages.

The encode-password command grew a brain

The security:encode-password console command got smarter. Instead of requiring you to pass the user class as an argument, it now lists the configured user classes and lets you pick:

$ bin/console security:encode-password

  For which user class would you like to encode a password?
  [0] App\Entity\User
  [1] App\Entity\AdminUser

It also normalizes encoder configuration to handle edge cases with email-format usernames that the previous version would silently corrupt by replacing @ with underscores. Nice catch.

HTTP/2 push and resource hints

The WebLink component handles the Link HTTP header, which tells browsers (and HTTP/2 proxies) to preload, prefetch, or preconnect to resources before the page even asks for them. It comes as a set of Twig functions:

{{ preload('/fonts/custom.woff2', { as: 'font', crossorigin: true }) }}
{{ prefetch('/api/next-page-data.json') }}
{{ dns_prefetch('https://fonts.googleapis.com') }}

Each call adds a corresponding Link header to the response. For apps behind an HTTP/2-capable proxy, this can trigger server push before the browser has even parsed the HTML. You enable it in config.yml:

framework:
    web_link:
        enabled: true

Deprecations you can actually trust

Container compilation used to generate deprecation warnings that vanished on the next page load because the cached container was already built. 3.3 persists those messages to disk and surfaces them in the web debug toolbar alongside request-phase deprecations. If a class is being deprecated during service compilation, you’ll see it without having to nuke the cache first.

What this meant for 4.0

3.3’s autowiring defaults are exactly what Symfony 4.0 shipped as the new standard project structure. The services.yaml in every new Symfony 4 project is essentially the snippet above. If you had already picked up what 3.3 introduced, 4.0’s “new way” felt familiar rather than foreign.

The direction was clear: less configuration, more convention. Let PHP figure out what to wire together.