4min.

How TaggedLocator Can Help You Design Better Symfony Application

One of the features I love the most in Symfony is the TaggedLocator. It seems to not be well known and I believe it deserves more visibility! That’s why I want to explain how it works.

I often see blog posts about ServiceSubscriberInterface and how to use it. In my humble opinion, almost all use cases should be replaced by the TaggedLocator feature. I think it’s a better solution because it’s more explicit and easier to use as there is no need to maintain a map between a key and a service everywhere you need it. Less code == less bugs!

However ServiceSubscriberInterface is useful when all the objects in the ServiceLocator are not of the same classes. And in my opinion, this is very rare.

Section intitulée how-to-use-a-taggedlocatorHow to use a TaggedLocator?

How would you develop this feature?

A user can export some data in different formats (CSV, JSON, XML, YAML, etc).

We can create a class for each format: CsvExporter, JsonExporter, and so on. We’ll also create an interface:

namespace App\Exporter;

interface ExporterInterface
{
    public function export(): string;
}

All exporters will implement this interface.

Then, we need a controller:

class ExportController extends AbstractController
{
    #[Route("/export/{format}")]
    public function export(string $format): Response
    {
        // $exporter = ...

        return new Response($exporter->export($data));
    }
}

Ok, we have the boilerplate code. Now, we need to find the right exporter for the given format. How can we achieve that?

We’ll first tag all services implementing ExporterInterface:

namespace App\Exporter;

#[AutoconfigureTag()]
interface ExporterInterface

With the AutoconfigureTag attribute, all exporters will be tagged with App\Exporter\ExporterInterface.

Then, we’ll create a service locator that will return the right exporter for the given format:

class ExportController extends AbstractController
{
    public function __construct(
        #[TaggedLocator(ExporterInterface::class)]
        private ServiceLocator $exporters,
    ) {
    }
}

And we can update the export method:

class ExportController extends AbstractController
{
    #[Route("/export/{format}")]
    public function export(string $format): Response
    {
        if (!$this->exporters->has($format)) {
            throw new NotFoundHttpException('Format not supported.');
        }

        $exporter = $this->exporters->get($format);

        return new Response($exporter->export());
    }

Easy, isn’t it? … but there is something wrong with this. The format must be the FQCN of the exporter (eg: App\Exporter\JsonExport). It’s not very user-friendly and it exposes internal plumbing. We can do better!

We’ll update the interface, to add another method that returns the format:

namespace App\Exporter;

interface ExporterInterface
{
    public static function getFormat(): string;
}

Then, we’ll configure the service locator to use this method:

class ExportController extends AbstractController
{
    public function __construct(
        #[TaggedLocator(ExporterInterface::class, defaultIndexMethod: 'getFormat')]
        private ServiceLocator $exporters,
    ) {
    }
}

It finally works as expected. When a user requests /export/json, the JsonExporter will be used.

Section intitulée how-does-it-work-internallyHow does it work internally?

In a first compiler pass, Symfony will tag all services implementing ExporterInterface with App\Exporter\ExporterInterface.

Then, in another compiler pass, it will create a service locator that will be injected in the controller. This service locator will be a ServiceLocator instance, which is an implementation of ContainerInterface. This service locator will be configured with the tagged services.

Section intitulée side-note-on-code-taggediterator-codeSide note on TaggedIterator

If you don’t care about the mapping key => service, but you always need all services, you can use the TaggedIterator attribute. It will return an iterator of all tagged services.

    public function __construct(
        #[TaggedIterator(ExporterInterface::class)]
        private iterable $exporters,
    ) {
    }

    #[Route("/export")]
    public function export(): Response
    {
        foreach ($this->exporters as $exporter) {
            // Do something with this 🙈
            $exporter->export();
        }
    }

Section intitulée conclusionConclusion

I really love this feature because when you need to add support for another format, you just need to create a new service. You don’t need to update the controller, you don’t need to update YAML files. Just one class and that’s it! (And maybe a test, but that’s another story 😁).

You may wonder if this is performant, since the locator might contain a lot of services. The answer is yes, it is. In this example, the exporter is instantiated only when requested. In other words, the service is lazy-loaded.

Cherry on top: since we use the interface FQCN as a tag name, it’s really easy to figure out what lives in the ServiceLocator!


Find the full code here

Section intitulée the-interfaceThe interface

namespace App\Exporter;

#[AutoconfigureTag()]
interface ExporterInterface
{
    public function export(): string;

    public static function getFormat(): string;
}

Section intitulée an-exporterAn exporter

namespace App\Exporter;

class JsonExporter implements ExporterInterface
{
    public function export(): string
    {
        return json_encode("...");
    }

    public static function getFormat(): string
    {
        return 'json';
    }
}

Section intitulée the-controllerThe controller

class ExportController extends AbstractController
{
    public function __construct(
        #[TaggedLocator(ExporterInterface::class, defaultIndexMethod: 'getFormat')]
        private ServiceLocator $exporters,
    ) {
    }

    #[Route("/export/{format}")]
    public function export(string $format): Response
    {
        if (!$this->exporters->has($format)) {
            throw new NotFoundHttpException('Format not supported.');
        }

        $exporter = $this->exporters->get($format);

        return new Response($exporter->export());
    }

}

Commentaires et discussions

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise