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 !
Symfony
Formez-vous à Symfony, l’un des frameworks Web PHP les complet au monde
Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
Les site e-commerces font face à de nombreuses problématiques : gestion de fort trafic, recherche parmi des milliers de références etc. JoliCode a accompagné l’équipe Smallable dans le choix des solutions pouvant répondre à ces enjeux : utilisation de briques asynchrones, conception d’index Elasticsearch pérenne.
Afin de soutenir le développement de son trafic, Qobuz a fait appel à JoliCode afin d’optimiser l’infrastructure technique du site et les échanges d’informations entre les composants de la plateforme. Suite à la mise en place de solution favorisant l’asynchronicité et la performance Web côté serveur, nous avons outillé la recherche de performance et…
Nous avons travaillé en étroite collaboration avec Cerfrance pour améliorer la qualité de leurs projets PHP et Symfony, tout en renforçant les compétences de leur équipe. Notre intervention a consisté à mettre en place une intégration continue (CI), à coacher l’équipe pour l’ajout de fixtures et de tests, à dockeriser l’application, et à installer…