Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Tags, Compiler Passes & Other Nerdery

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $8.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Let's review: we gave our service a tag. And now, we want to tell Symfony to find all services in the container with this tag, and pass them as the first argument to our KnpUIpsum service. Like I mentioned in the previous chapter, if you only need to support Symfony 3.4 or higher, there's a shortcut. But if you need to support lower versions or want to geek out with me about compiler passes, well, you're in luck!

First question: how can we find all services that have the knpu_ipsum_word_provider tag? If you look in the extension class, you might think that we could do some magic here with the $container variable. And... yea! It even has a method called findTaggedServiceIds()!

But... you actually can't do this logic here. Why? Well, when this method is called, not all of the other bundles and extensions have been loaded yet. So if you tried to find all the services with a certain tag, some of the services might not be in the container yet. And actually, you can't even get that far: the ContainerBuilder is empty at the beginning of this method: it doesn't contain any of the services from any other bundles. Symfony passes us an empty container builder, and then merges it into the real one later.

Compiler Pass

The correct place for any logic that needs to operate on the entire container, is a compiler pass. In the DependencyInjection directory - though it doesn't technically matter where this class goes - create a Compiler directory then a new class called WordProviderCompilerPass. Make this, implement a CompilerPassInterface, and then go to the Code -> Generate menu - or Command + N on a Mac - click "Implement Methods" and select process().

... lines 1 - 7
class WordProviderCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
}
}

A compiler pass also receives a ContainerBuilder argument. But, instead of being empty, this is full of all of the services from all of the bundles. That means that we can say foreach ($container->findTaggedServiceIds(), pass this the tag we're using: knpu_ipsum_word_provider, and say as $id => $tags.

... lines 1 - 9
public function process(ContainerBuilder $container)
{
foreach ($container->findTaggedServiceIds('knpu_ipsum_word_provider') as $id => $tags) {
... line 13
}
... line 15
}

This is a little confusing: the $id key is the service ID that was tagged. Then, $tags is an array with extra information about the tag. Sometimes, a tag can have other attributes, like priority. You can also tag the same service with the same tag, multiple times.

Anyways, we don't need that info: let's just var_dump($id) to see if it works, then die.

... lines 1 - 11
foreach ($container->findTaggedServiceIds('knpu_ipsum_word_provider') as $id => $tags) {
var_dump($id);
}
die;
... lines 16 - 17

Registering the Compiler Pass

To tell Symfony about the compiler pass, open your bundle class. Here, go back to the Code -> Generate menu - or Command + N on a Mac - choose "Override Methods" and select build(). You don't need to call the parent build() method: it's empty. All we need here is $container->addCompilerPass(new WordProviderCompilerPass()).

... lines 1 - 9
class KnpULoremIpsumBundle extends Bundle
{
public function build(ContainerBuilder $container)
{
$container->addCompilerPass(new WordProviderCompilerPass());
}
... lines 16 - 27
}

There are different types of compiler passes, which determine when they are executed relative to other passes. And, there's also a priority. But unless you're doing something really fancy, the standard type and priority work fine.

Thanks to this line, whenever the container is built, it should hit our die statement. Let's move over to the browser and, refresh!

Yes! There is the one service that has the tag.

And now... it's easy! The code in a compiler pass looks a lot like the code in an extension class. At the top, add $definition = $container->getDefinition('knpu_lorem_ipsum.knpu_ipsum').

Ultimately, we need to modify this services's first argument. Create an empty $references array. And, in the foreach, just add stuff to it: $references[] = new Reference() and pass in the $id.

Finish this with $definition->setArgument(), pass it 0 for the first argument, and the array of reference objects.

... lines 1 - 10
public function process(ContainerBuilder $container)
{
$definition = $container->getDefinition('knpu_lorem_ipsum.knpu_ipsum');
$references = [];
foreach ($container->findTaggedServiceIds('knpu_ipsum_word_provider') as $id => $tags) {
$references[] = new Reference($id);
}
$definition->setArgument(0, $references);
}

We're done! Go back to our browser and try it! Woohoo! We're now passing an array of all of the word provider services into the KnpUIpsum class.... which... yea, is just one right now.

Cleanup the Old Configuration

With this in place, we can remove our old config option. In the Configuration class, delete the word_provider option. And in the extension class, remove the code that reads this.

... lines 1 - 10
class KnpULoremIpsumExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.xml');
$configuration = $this->getConfiguration($configs, $container);
$config = $this->processConfiguration($configuration, $configs);
$definition = $container->getDefinition('knpu_lorem_ipsum.knpu_ipsum');
$definition->setArgument(1, $config['unicorns_are_real']);
$definition->setArgument(2, $config['min_sunshine']);
}
... lines 25 - 29
}

Tagging the CustomWordProvider

Next, move over to the application code, and in config/packages/knpu_lorem_ipsum.yaml, yep, take out the word_provider key.

knpu_lorem_ipsum:
min_sunshine: 5

If you refresh now... it's going to work. But, not surprisingly, the word "beach" will not appear in the text. Remember: "beach" is the word that we're adding with our CustomWordProvider. This class is not being used. And... that make sense! We haven't tagged this service with anything, so our bundle doesn't know to use it.

Before we do that, now that there are multiple providers, I don't need to extend the core provider anymore. Implement the WordProviderInterface directly. Then, just return an array with the one word: beach.

... lines 1 - 6
class CustomWordProvider implements WordProviderInterface
{
public function getWordList(): array
{
return ['beach'];
}
}

To tag the service, open config/services.yaml. This class is automatically registered as a service. But to give it a tag, we need to override that: App\Service\CustomWordProvider, and, below, tags: [knpu_ipsum_word_provider].

... lines 1 - 5
services:
... lines 7 - 37
App\Service\CustomWordProvider:
tags: ['knpu_ipsum_word_provider']

Let's try it! Refresh! Yes! It's alive!

Setting up Autoconfiguration

But... there's something that's bothering me. Most of the time in Symfony, you don't need to manually configure the tag. For example, earlier, when we created an event subscriber, we did not need to give it the kernel.event_subscriber tag. Instead, Symfony was smart enough to see that our class implemented EventSubscriberInterface, and so it added that tag for us automatically.

So... what's the difference? Why can't the tag be automatically added in this situation? Well... it can! But we need to set this up in our bundle. Open the extension class, go anywhere in the load() method, and add $container->registerForAutoconfiguration(WordProviderInterface::class). The feature that automatically adds tags is called autoconfiguration, and this method returns a "template" Definition object that we can modify. Use ->addTag('knpu_ipsum_word_provider').

... lines 1 - 11
class KnpULoremIpsumExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
... lines 16 - 25
$container->registerForAutoconfiguration(WordProviderInterface::class)
->addTag('knpu_ipsum_word_provider');
}
... lines 29 - 33
}

Cool, right? Back in our app code, remove the service entirely. And now, try it! Hmm, no beach the first time but on the second refresh... we got it!

We now have a true word provider plugin system. And creating a custom word provider is as easy as creating a class that implements WordProviderInterface.

Next, let's finally put our library up on Packagist!

Leave a comment!

12
Login or Register to join the conversation

What is the difference between adding this code `$container->registerForAutoconfiguration(WordProviderInterface::class)->addTag('knpu_ipsum_word_provider');` inside the DI extension KnpULoremIpsumExtension::build or inside LoremIpsumBundle::build ! is there a recommanded place ?

Reply

Yo ahmedbhs!

Great question! My instinct was that it makes no difference, but I actually didn't know the answer to this one! Fortunately the code holds the secret - and here it is: https://github.com/symfony/...

As you can see, the "extension" class is called first on each bundle and *then* the build() method. But that doesn't make any difference. It is not until after BOTH of these that the "compiler passes" are executed, and it is a compiler pass that ultimately processes the results from registerForAutoconfiguration(). So, both spots are "early enough" and it should make zero difference :). I have seen people (and I don't disagree) sometimes skip creating an extension class entirely and instead just put things in their bundle's build() method for simplicity.

Cheers!

Reply
Boolean T. Avatar
Boolean T. Avatar Boolean T. | posted 2 years ago | edited

Hi, I have a question :)
Everything works OK: I see words from even two custom words providers, created by me in the main app, <b>and</b> words from KnpUWordProvider. But what if I want users of my bundle totally exclude words provided by KnpUWordProvider? Before tags system implementation it was easy: in custom providers we could just <i>not to call</i> $words = parent::getWordList();. But after tags implementation KnpUWordProvider is also tagged in bundle's "services.xml", so it looks like no way for bundle users to exclude words, which KnpUWordProvider provides, from final words set. At least, it looks no way for me, 'cause I'm noobie in Symfony bundles/container/other stuff :) Maybe, it could be possible for bundle's user to call ContainerBuilder::removeDefinition() somewhere, but I'm not sure that this is a good idea. Need an advice.

Reply

Hey @Boolean_Type!

cause I'm noobie in Symfony bundles/container/other stuff

Hmm, for a "noobie" you're asking some pretty good questions on some pretty advanced stuff ;).

So, you're correct that there's no way to do this directly with the tags system. And you're also correct that the user could do something crazy with a compiler pass and ContainerBuilder::removeDefinition but we don't want them to do that :).

The "tags" system is really a way to allow end-users to add more stuff to our system. In this project, we've done this but (as you know) we've also decided to always add our own KnpUWordProvider. Suppose we release the bundle like this, and for 6 months, everyone is happy. Suddenly, we get a feature request where someone wants to not include this (this exactly what you're asking for).

The fix is to make our bundle more flexible by adding more configuration. Here's how it would look:

1) Add some new option in our Configuration class so that users can have some config like this:


knpu_lorem_ipsum:
    default_provider: false

You would default this value to "true" to keep the current behavior by default.

2) Then, remove the knpu_ipsum_word_provider tag in our bundle's XML file for the KnpUWordProvider service. At this moment, the class would never have that tag.

3) In the KnpULoremIpsumExtension class, use the new default_provider config. If it IS set to true (and only if it is set to true), manually find the knpu_lorem_ipsum.knpu_word_provider service and add the tag.

That's it! So you're basically adding a new option that allows users to control whether or not this tag is there. The cool thing is that your users don't even need to understand what a "tag" is or that you're using it (unless they want to add their own custom providers). A user just sees that they can set default_provider: false to not include the default words. They don't need to care how it works internally.

Let me know if that helps!

Cheers!

1 Reply
Boolean T. Avatar
Boolean T. Avatar Boolean T. | weaverryan | posted 2 years ago | edited

Thank you so much, @weaverryan !
I've tried your proposition - it works :)

<i>knpu_lorem_ipsum.yaml:</i>


knpu_lorem_ipsum:
    min_sunshine: 10
    use_default_provider: true

<i>services.xml:</i>


...
<service id="knpu_lorem_ipsum.knpu_word_provider" class="KnpU\LoremIpsumBundle\KnpUWordProvider"/>
...

<i>KnpU\LoremIpsumBundle\DependencyInjection\Configuration:</i>


...
->booleanNode('use_default_provider')
        ->defaultTrue()
        ->info('Do you want to use the default word provider?')
        ->end()
...

<i>KnpU\LoremIpsumBundle\DependencyInjection\KnpULoremIpsumExtension::load():</i>


...
if ($config['use_default_provider']) {
         $knpuWordProviderDefinition = $container->getDefinition('knpu_lorem_ipsum.knpu_word_provider');
         $knpuWordProviderDefinition->addTag('knpu_ipsum_word_provider');
}
...

I've also tried this in KnpULoremIpsumExtension::load() instead of block above:


if ($config['use_default_provider']) {         
        $class = $knpuWordProviderDefinition->getClass();
        $container
            ->registerForAutoconfiguration($class)
            ->addTag('knpu_ipsum_word_provider')
         ;
}

But for some reason uknown for me it didn't work.

Reply

Woohoo! Nice work! You did everything perfectly.

That last block of code didn’t work only because you were... sort of doing something different.... but close. That code said “please find all services that match the class KnpUWordProvider and automatically tag them with knpu_ipsum_word_provider”. That sounds like it should work, but it will only apply to services that have “autoconfigure true”, which our bundle’s services don’t. And that’s ok and on purpose :). It’s better for our bundle to do things explicitly, which is what you did by specifically adding the tag to the service.

Cheers!

1 Reply
Default user avatar
Default user avatar ash-m | posted 2 years ago | edited

I sometimes find that this doesn't always automatically tag implemented classes. For some reason, I seem to have no problem with any of my Repository interfaces (that have nothing to do with Doctrine), but I end up having to tag stuff in the application configuration. I can see when I use findTaggedServiceIds() the array is empty until I manually tag it. Is there a good way to debug this? I was having a problem with a Uuid ParamConverter but I was able to track it down by dump/dying in the Manager class. Debugging this is a bit harder because everything seems a bit obfuscated by the container cache. (Besides that, I don't really know how my <i>successful</i> auto tags are ending up in there, so there's no convenient class to just check if my implementation is getting scanned or what not.)

As a second, unrelated question, is it possible to pass configuration into a class you inevitably wiring this way, or does that not make any sense?

Reply

Hey ash-m!

Sorry for my slow reply!

Ok, so this entire feature is powered by "autoconfiguration". "Autoconfigure" is an option that you can set on each individual service. If "autoconfigure" is enabled for a service, then any matching "autoconfiguration rules" (i.e. the thing we add with registerForAutoconfiguration()) are applied to that service.

So, the first thing we should look at is: is autoconfiguration enabled for your services? The autoconfigure option is disabled by default on services. However, in practice, it's enabled in applications thanks to the default services.yaml file: https://github.com/symfony/recipes/blob/fb10d2ac4cf54486a1b171685f4a3d6311196938/symfony/framework-bundle/4.4/config/services.yaml#L8-L12

But if you're building a bundle, then you might have this setting. And, in general, like autowiring, the "best practice" is for bundles to not rely on autoconfiguration. Basically, if your bundle needs something to have a tag, you should configure it manually. But if you would like to make it easier for "end-users" to "auto-tag" their services (which is a good thing!), then you would call registerForAutoconfiguration(). So basically, you add this autoconfiguration rule for your end users... but then we typically don't actually rely on it in the bundle (though, there is no rule against that).

Let me know if this helps... or if i'm way off ;).

Cheers!

Reply
Default user avatar
Default user avatar ash-m | weaverryan | posted 2 years ago | edited

This helps, but doesn't explain why some services autowire and others don't. But first, to clarify, is registerForAutoConfiguration() not best practice?

Second, perhaps there is some tagging magic happening with Doctrine, but I will have an unimplemented repository interface (eg FooRepository) in my bundle and I'll use registerForAutoConfiguration() on it and the App's implementation (eg InMemoryFooRepository) will appear in the CompilerPass. But some (most*) other services do not show up until I explicitly add tags in the App's services.yaml file.

Most of these services do not have default implementations, so they're not defined in the bundle's services.xml (it's expected that the developer implement the interfaces to use the bundle). Maybe there is some relationship I don't understand between services.xml and the CompilerPass? If it's <i>better</i> to tag stuff in the App's services.yaml, I'm okay with that, but it doesn't seem to be necessary for any of the other bundles I've used.

Note: as far as my unrelated question, I decided to map all configuration variables to <b>parameters</b> in the Extension class, and then use those in the CompilerPass for the setArgument() calls. Is this okay? Is there a better way?

Reply

Hey ash-m!

But first, to clarify, is registerForAutoConfiguration() not best practice?

This absolutely IS a best-practice. This allows users of your bundle to have their service's auto-tagged. What's not a best practice is to rely on this in your own bundle. Instead, you should manually/explicitly tag your services.

but I will have an unimplemented repository interface (eg FooRepository) in my bundle and I'll use registerForAutoConfiguration() on it and the App's implementation (eg InMemoryFooRepository) will appear in the CompilerPass.

Um, hmm. I think I'll need to see some code. But also, I would run:


php bin/console debug:container the_service_id_of_a_service_that_is_not_automatically_getting_the_tag

And then see what the "Autoconfigured" option says - see if it says "yes" or "no". If it says "no", then we know that the problem is that autoconfiguration is not enabled for this service, and so that's why it isn't automatically getting the tag.

Note: as far as my unrelated question, I decided to map all configuration variables to parameters in the Extension class, and then use those in the CompilerPass for the setArgument() calls. Is this okay? Is there a better way?

Yes, this is ok... it's a great way (actually) to "store" that config so that you can read it in your compiler pass :).

Cheers!

Reply
Default user avatar
Default user avatar ash-m | weaverryan | posted 2 years ago | edited

So, just to clarify, stuff ending in Repository seem to (mostly) work as expected (except, for some reason when I inject them in listeners instead of handlers, but that probably has something to do with what's being defined in services.xml... ¯_(ツ)_/¯... ) but it looks like the problem is mostly with <i>factories</i> which I usually define next to the thing they create. So an App\Entity\ConcreteFooFactory will implement Ashm\ALib\Foo\FooFactory and of course that interface will be tagged and registered for auto-configuration in the bundle's extension class.

When I debug like you suggest (without removing the tag from services.yaml, because otherwise it breaks saying that a handler depends on a non-existent service), then it indeed shows that the class is autoconfigured <b>and</b> autowired (and of course tagged with the explicit tag).

<s>I am just now realizing that the services that fail to get tagged in the Compiler without being explicit are all in the library I abstracted (which the bundle requires).</s> (edit: actually this is inconsistent as there is also a factory interface in the bundle whose implementation requires a tag in the App... I should probably think of a way to fix that... )

To further clarify, when I say, "it doesn't show up", if I remove the tag, from services.yaml, like I mentioned, the app will crash, but if I var_dump($container->findTaggedServivceIds($tag)); die() (where $tag is the tag defined in the extension) I will see an empty array. If I leave the tag alone and var_dump, then I see the App\Entity\ConcreteFooFactory.

Any input would be great. Thank you.

Reply

Hey ash-m!

Any chance you'd be able to share some code? I could probably spot the problem, but you've got a complex enough setup that I can't quite picture it. It sounds like you are doing most everything correctly, which is why I think there may be some minor problem or minor "lost in translation" tweak that you need :).

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built using Symfony 4, but most of the concepts apply fine to Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "doctrine/annotations": "^1.8", // v1.8.0
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knpuniversity/lorem-ipsum-bundle": "*@dev", // dev-master
        "nexylan/slack-bundle": "^2.0,<2.2", // v2.0.1
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.1.6
        "symfony/asset": "^4.0", // v4.0.6
        "symfony/console": "^4.0", // v4.0.6
        "symfony/flex": "^1.0", // v1.18.7
        "symfony/framework-bundle": "^4.0", // v4.0.6
        "symfony/lts": "^4@dev", // dev-master
        "symfony/twig-bundle": "^4.0", // v4.0.6
        "symfony/web-server-bundle": "^4.0", // v4.0.6
        "symfony/yaml": "^4.0", // v4.0.6
        "weaverryan_test/lorem-ipsum-bundle": "^1.0" // v1.0.0
    },
    "require-dev": {
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.4
        "sensiolabs/security-checker": "^4.1", // v4.1.8
        "symfony/debug-bundle": "^3.3|^4.0", // v4.0.6
        "symfony/dotenv": "^4.0", // v4.0.6
        "symfony/maker-bundle": "^1.0", // v1.1.1
        "symfony/monolog-bundle": "^3.0", // v3.2.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.3
        "symfony/stopwatch": "^3.3|^4.0", // v4.0.6
        "symfony/var-dumper": "^3.3|^4.0", // v4.0.6
        "symfony/web-profiler-bundle": "^3.3|^4.0" // v4.0.6
    }
}
userVoice