Search code examples
phpsymfonyautowiredsymfony-dependency-injection

Get service via class name from iterable - injected tagged services


I am struggling to get a specific service via class name from group of injected tagged services.

Here is an example: I tag all the services that implement DriverInterface as app.driver and bind it to the $drivers variable.

In some other service I need to get all those drivers that are tagged app.driver and instantiate and use only few of them. But what drivers will be needed is dynamic.

services.yml

_defaults:
        autowire: true
        autoconfigure: true
        public: false
        bind:
            $drivers: [!tagged app.driver]

_instanceof:
        DriverInterface:
            tags: ['app.driver']

Some other service:

/**
 * @var iterable
 */
private $drivers;

/**
 * @param iterable $drivers
 */
public function __construct(iterable $drivers) 
{
    $this->drivers = $drivers;
}

public function getDriverByClassName(string $className): DriverInterface
{
    ????????
}

So services that implements DriverInterface are injected to $this->drivers param as iterable result. I can only foreach through them, but then all services will be instantiated.

Is there some other way to inject those services to get a specific service via class name from them without instantiating others?

I know there is a possibility to make those drivers public and use container instead, but I would like to avoid injecting container into services if it's possible to do it some other way.


Solution

  • A ServiceLocator will allow accessing a service by name without instantiating the rest of them. It does take a compiler pass but it's not too hard to setup.

    use Symfony\Component\DependencyInjection\ServiceLocator;
    class DriverLocator extends ServiceLocator
    {
        // Leave empty
    }
    # Some Service
    public function __construct(DriverLocator $driverLocator) 
    {
        $this->driverLocator = $driverLocator;
    }
    
    public function getDriverByClassName(string $className): DriverInterface
    {
        return $this->driverLocator->get($fullyQualifiedClassName);
    }
    

    Now comes the magic:

    # src/Kernel.php
    # Make your kernel a compiler pass
    use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
    class Kernel extends BaseKernel implements CompilerPassInterface {
    ...
    # Dynamically add all drivers to the locator using a compiler pass
    public function process(ContainerBuilder $container)
    {
        $driverIds = [];
        foreach ($container->findTaggedServiceIds('app.driver') as $id => $tags) {
            $driverIds[$id] = new Reference($id);
        }
        $driverLocator = $container->getDefinition(DriverLocator::class);
        $driverLocator->setArguments([$driverIds]);
    }
    

    And presto. It should work assuming you fix any syntax errors or typos I may have introduced.

    And for extra credit, you can auto register your driver classes and get rid of that instanceof entry in your services file.

    # Kernel.php
    protected function build(ContainerBuilder $container)
    {
        $container->registerForAutoconfiguration(DriverInterface::class)
            ->addTag('app.driver');
    }