Modernize Symfony Configs

August 2026 Update

The original post continued with config builder classes. They were deprecated in Symfony 7.4, and the StringExtensionToConfigBuilderRector rule was removed from Rector as well. Those parts are replaced with the SymfonySetList::CONFIGS set.


Symfony configs in YAML are a pile of strings. No IDE autocomplete, no static analysis, and no @deprecated warning when an option is on its way out.

PHP configs give us a way in. Today, we'll show you how to get there without a single manual flip, and how to let Rector clean up the service definitions once you're there.

Symfony security is known for major changes in almost every Symfony version. What worked in the past...

# config/security.yml
security:
    enable_authenticator_manager: true

...can be changed or removed.

This actually does not work in Symfony 7. We want to be warned early, as soon as the change happens, and without running our code. Other PHP projects use @deprecation annotations that highlight deprecated methods, properties, constants, and classes right in code in your favorite IDE.

How about YAML? It does not, so we first have to migrate to PHP.

Move from YAML to PHP

It is not necessary to bother with a manual flip. Use symplify/config-transformer to automate the process instead:

composer require symplify/config-transformer --dev
vendor/bin/config-transformer switch-format config/security.yml

It parses YAML, maps it into PHP format, and prints it out:

# config/security.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->extension('security', [
        'enable_authenticator_manager' => true,
    ]);
};

Job done!

If it's your first PHP fluent config, don't forget to update Kernel to load PHP files too.


How the most common cases look after the switch

Parameters become set() calls:

# config/services.yaml
parameters:
    app.locale: 'en'
# config/services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
    $parameters = $containerConfigurator->parameters();

    $parameters->set('app.locale', 'en');
};

Service defaults and autodiscovery keep the same shape, just in methods:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/*'
        exclude: '../src/{Entity,Kernel.php}'
# config/services.php
return static function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();

    $services->defaults()
        ->autowire()
        ->autoconfigure();

    $services->load('App\\', __DIR__ . '/../src/*')
        ->exclude([__DIR__ . '/../src/Entity', __DIR__ . '/../src/Kernel.php']);
};

A single service with arguments and a tag - note the argument name is kept, so nothing depends on the order:

# config/services.yaml
services:
    App\Service\PriceCalculator:
        arguments:
            $vatRate: 21
        tags: ['app.calculator']
        public: true
# config/services.php
use App\Service\PriceCalculator;

return static function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();

    $services->set(PriceCalculator::class)
        ->arg('$vatRate', 21)
        ->tag('app.calculator')
        ->public();
};

An alias - the @ string reference turns into a class constant:

# config/services.yaml
services:
    App\Contract\Mailer: '@App\Mailer\SmtpMailer'
# config/services.php
use App\Contract\Mailer;
use App\Mailer\SmtpMailer;

return static function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();

    $services->alias(Mailer::class, SmtpMailer::class);
};

Bundle extensions - framework, doctrine, twig, security and the rest - go through extension() with the nesting untouched:

# config/packages/framework.yaml
framework:
    secret: '%env(APP_SECRET)%'
    session:
        handler_id: null
# config/packages/framework.php
return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->extension('framework', [
        'secret' => '%env(APP_SECRET)%',
        'session' => [
            'handler_id' => null,
        ],
    ]);
};

Now the configs live in PHP, where Rector can reach them. That's where the second part starts.


Let Rector clean the service configs

Configs that grew over the years are full of noise - service names duplicated as types, positional args(), tags that autoconfigure handles anyway, or public() repeated on every single service.

Rector has a dedicated set for exactly this - SymfonySetList::CONFIGS:

# rector.php
use Rector\Config\RectorConfig;
use Rector\Symfony\Set\SymfonySetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/config'])
    ->withSets([
        SymfonySetList::CONFIGS,
    ]);

Then run it on your config directory:

vendor/bin/rector process config

Here is what the 7 rules in the set do.


Drop the service name that only repeats the class

MergeServiceNameTypeRector

Service registered under its own type name says the same thing twice:

-$services->set(\App\SomeClass::class, \App\SomeClass::class);
+$services->set(\App\SomeClass::class);

Name the constructor arguments

ServiceArgsToServiceNamedArgRector

Positional args() break silently when a constructor parameter is added or reordered. Named arg() doesn't:

 $services->set(SomeClass::class)
-    ->args(['some_value']);
+    ->arg('$someCtorParameter', 'some_value');

Remove arguments that autowiring already handles

RemoveConstructorAutowireServiceRector

An argument that autowiring resolves on its own is just a line to maintain:

final class SomeClass
{
    public function __construct(private SomeService $someService)
    {
    }
}
 $services->defaults()
     ->autowire();

-$services->set(\App\SomeClass::class)
-    ->arg('$someService', ref(\App\SomeService::class));
+$services->set(\App\SomeClass::class);

Replace manual tags with autoconfigure

ServiceTagsToDefaultsAutoconfigureRector

Tags like console.command, twig.extension, kernel.event_subscriber, monolog.logger and security.voter are handled by autoconfigure():

 $services = $containerConfigurator->services();
+$services->defaults()
+    ->autoconfigure();

-$services->set(SomeCommand::class)
-    ->tag('console.command');
+$services->set(SomeCommand::class);

Say "public" once, not on every service

FromServicePublicToDefaultsPublicRector

The same public() call repeated on every service moves to defaults() once:

 $services = $containerConfigurator->services();
+$services->defaults()->public();

-$services->set(SomeCommand::class)
-    ->public();
-
-$services->set(AnotherCommand::class)
-    ->public();
-
-$services->set(NextCommand::class)
-    ->public();
+$services->set(SomeCommand::class);
+$services->set(AnotherCommand::class);
+$services->set(NextCommand::class);

Turn a list of services into a single autodiscovery load

ServiceSettersToSettersAutodiscoveryRector

A list of set() calls from the same namespace collapses into a single load() glob. Rector finds the shared namespace and the directory for you:

-$services->set(FirstService::class);
-$services->set(SecondService::class);
+$services->load('App\\Services\\', __DIR__ . '/../src/Services');

Rename string service names to class-based ones

ServiceSetStringNameToClassNameRector

Legacy string service names become class-based ones, so $container->get() works by type. This one needs the container XML dump to map names to types:

# rector.php
return RectorConfig::configure()
    ->withSets([SymfonySetList::CONFIGS])
    ->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml');
-$services->set('some_name', App\SomeClass::class);
+$services->set('app\\someclass', App\SomeClass::class);

Run it, review the diff, and your service configs shrink to what actually carries information.


Happy coding!