Symfony @required - Avoid the Temptation and Use it Right

Symfony 3 introduced a @required annotation (now also an attribute) that allows injecting more services via the setter method apart constructor. At the time, it was good.

The goal was to solve circular dependencies: when A needs B, B needs C, and C needs A.

But more often than not, I see PHP projects where it got completely out of hand.

How to use it right?

"Fire is a good servant,
but a bad master."

The official documentation is not very verbose about how not to use it, so this is my attempt to fill in the missing piece. Similar to static methods, it's easy to use everywhere instantly, but it's very hard to revert the change to clean constructor injection.


3 Temptations to Avoid

1. @Required is not "a way to get a service"

This annotation was introduced in times of static containers, where we could get a service by using a global container,e. g. $this->get(ServiceWeNeed::class).

Let's replace that, shall we?

final class HomepageController
{
    private ProductRepository $productRepository;

    /**
     * @required
     */
    public function setProductRepository(ProductRepository $productRepository)
    {
        $this->productRepository = $productRepository;
    }

    // ...
}

👎


This is a bit too verbose and ugly. But it's just one step from the second temptation.


2. @required is not for "Handy Traits"

If we combine 2 cool features in the way nobody expected them to use, like an ice-cream and a hamburger, we'll get the following:

trait ProductRepositoryTrait
{
    private ProductRepository $productRepository;

    /**
     * @required
     */
    public function setProductRepository(ProductRepository $productRepository)
    {
        $this->productRepository = $productRepository;
    }

    // ...
}

Now we can finally have a 1-line solution to inject any service anywhere:

final class HomepageController
{
    use ProductRepositoryTrait;

    public function home()
    {
        $products = $this->productRepository->fetchAll();
        // ...
    }
}
final class ProductController
{
    use ProductRepositoryTrait;

    public function list()
    {
        $products = $this->productRepository->fetchAll();
        // ...
    }
}

So neat, right? This was especially tempting before PHP 8.0 came with promoted properties.

👎


I've seen this in 3 projects recently and it makes any changes very slow and sticky.

The original idea of trait was to re-use shared and diverse logic in value objects/entities, to avoid bloated abstract classes.

If we want to use a service anywhere, we inject it via the constructor.


This setter method also opens the possibility to override service from the outside. You thought this was the only ProductRepository service instance in the whole project? It could be, or maybe not. We're only one tiny step away from the next temptation.


3. @required is not to make mocking and tests easier

Last but not least, these setters allow anyone to replace service in tests on the fly:

final class HomepageControllerTest extends TestCase
{
    public function test(): void
    {
        $homepageController = self::$container->get(HomepageController::class);

        $productRepositoryMock = $this->createMock(ProductRepository::class);
        $productRepositoryMock->expect('find')->willReturn('...');

        $homepageController->setProductRepository($productRepositoryMock);
    }
}

So easy, so tempting, right?

👎


This is also wrong, as we have just turned our dependency injection paradigm into setter injection.

Next time any other developer in our team will need to mock a service, they will create service setters everywhere.


Forget all previous examples... so how to use the @required correctly?


Three Ways to Use it Right

If you can, always use constructor injection:

final readonly class HomepageController
{
    public function __construct(
        private ProductRepository $productRepository
    ) {
    }
}

Clean and reliable. We can build trust in our codebase, as it has a single ProductRepository instance.


The @required annotation should be the last solution, if there is no better way to inject a service.


1. Prevent Circular Dependency

As stated, the original idea that sparked this feature was to prevent circular dependencies. This could happen if there are complex service structures, e.g. PriceResolved service that depends on 10 different PriceModifier implementations that depend mutually on each other.

👍


Rule of thumb: If the Symfony container gives us a "circular dependency" exception, and it's not easy to handle this in the main service by using ->set($this) on foreach loop, we use @required.


2. Prevent Dependency hell with Abstract Class

Let's say we have an abstract controller with a couple of services useful in the controller itself and all its children:

abstract AbstractProductController
{
    public function __construct(
        private Logger $logger,
        private Security $security,
    ) {
    }

    // ...
}

Then we extend this controller and add one more dependency to its own:

final class RestProductController extends AbstractController
{
    public function __construct(
        private EntitySerializer $entitySerializer,
        Logger $logger,
        Security $security,
    ) {
        parent::__construct($logger, $security);
    }
}

All this is just to get EntitySerializer here. Now imagine parent __construct() of AbstractController will change. We have to update all its children.


This is where @required becomes useful. It is a little more verbose, but only in single parent class. The rest of the children will become cleaner:

abstract AbstractProductController
{
    private Logger $logger;

    private Security $security;

    /**
     * @required
     */
    public function autowireAbstractProductController(
        Logger $logger,
        Security $security,
    ) {
        $this->logger = $logger;
        $this->security = $security;
    }

    // ...
}
final class RestProductController extends AbstractController
{
    public function __construct(
        private EntitySerializer $entitySerializer,
    ) {
    }
}

👍


Rule of thumb: If we use an abstract class with a couple of services, and we need to add one more service to its children, we use @required.


3. Make the autowire method single and unique

Avoid using multiple @required methods in a single class:

abstract class AbstractController
{
    // ...

    /**
     * @required
     */
    public function setLogger(Logger $logger)
    {
        $this->logger = $logger;
    }

    public function setSecurity(Security $security)
    {
        $this->security = $security;
    }
}

It might lead to a forgotten @required annotation above one of the methods (see the 2nd one), or even mutual override by a slightly different type. We've seen both bugs.


Use a single autowire method to be safe:

abstract class AbstractController
{
    /**
     * @required
     */
    public function autowireAbstractController(...)
    {
        // ...
    }
}

👍


Rule of the thumb: Use single autowire method and name it autowire + name of the calls. It prevents the autowire() method override bugs in case of multiple inheritance.


How to spot all these problems?

That's a lot of tiny code smells to worry about, right? Are you curious about your project @required health check?

PHPStan to the rescue - check these custom PHPStan rules that watch our back on our projects:


Add them to your phpstan.neon and see what they found.


Happy coding!




Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!