Plugin System with Tags

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

At this point, the user can control the word provider. But, there's only ever one word provider. That may be fine, but I want to make this more flexible! And, along the way, learn about one of the most important, but complex systems that is commonly used in bundles: the tag & compiler pass system.

First, let's make our mission clear: instead of allowing just one word provider, I want to allow many word providers. I also want other bundles to be able to automatically add new word providers to the system. Basically, I want a word provider plugin system.

Allowing Multiple Word Providers

To get this started, we need to refactor KnpUIpsum: change the first argument to be an array of $wordProviders. Rename the property to $wordProviders, and I'll add some PHPDoc above this to help with auto-completion: this will be an array of WordProviderInterface[].

... lines 1 - 9
class KnpUIpsum
{
/**
* @var WordProviderInterface[]
*/
private $wordProviders;
... lines 16 - 22
public function __construct(array $wordProviders, bool $unicornsAreReal = true, $minSunshine = 3)
{
$this->wordProviders = $wordProviders;
... lines 26 - 27
}
... lines 29 - 227
}

Let's also add a new property called wordList: in a moment, we'll use this to store the final word list, so that we only need to calculate it once.

... lines 1 - 20
private $wordList;
... lines 22 - 229

The big change is down below in the getWordList() method. First, if null === $this->wordList, then we need to loop over all the word providers to create that word list.

Once we've done, that, at the bottom, return $this->wordList.

... lines 1 - 210
private function getWordList(): array
{
if (null === $this->wordList) {
... lines 214 - 223
}
return $this->wordList;
}
... lines 228 - 229

Back in the if, create an empty $words array, then loop over $this->wordProviders as $wordProvider. For each word provider, set $words to an array_merge of the words so far and $wordProvider->getWordList().

... lines 1 - 212
if (null === $this->wordList) {
$words = [];
foreach ($this->wordProviders as $wordProvider) {
$words = array_merge($words, $wordProvider->getWordList());
}
... lines 218 - 223
}
... lines 225 - 229

After, we need a sanity check: if the count($words) <= 1, throw an exception: this class only works when there are at least two words. Finally, set $this->wordList to $words.

... lines 1 - 212
if (null === $this->wordList) {
... lines 214 - 218
if (count($words) <= 1) {
throw new \Exception('Word list must contain at least 2 words, yo!');
}
... line 222
$this->wordList = $words;
}
... lines 225 - 229

Perfect! This class is now just a little bit more flexible. In config/services.xml, instead of passing one word provider, add an <argument with type="collection", them move the word provider argument inside of this.

... lines 1 - 6
<services>
<service id="knpu_lorem_ipsum.knpu_ipsum" class="KnpU\LoremIpsumBundle\KnpUIpsum" public="true">
<argument type="collection">
<argument type="service" id="knpu_lorem_ipsum.word_provider" />
</argument>
</service>
... lines 13 - 22
</services>
... lines 24 - 25

There's no fancy plugin system yet, but things should still work. Find your browser and refresh. Great! Even the article page looks fine.

Tagging the Service

Here's the burning question: how can we improve this system so that our application, or even other bundles, can add new word providers to this collection? The answer... takes a few steps to explain.

First, I want you to pass an empty collection as the first argument. Then, below on the word provider service, change this to use the longer service syntax so that, inside, we can add <tag name="">, and, invent a new tag string. How about: knpu_ipsum_word_provider.

... lines 1 - 7
<service id="knpu_lorem_ipsum.knpu_ipsum" class="KnpU\LoremIpsumBundle\KnpUIpsum" public="true">
<argument type="collection" /> <!-- filled in via a compiler pass -->
</service>
... line 11
<service id="knpu_lorem_ipsum.knpu_word_provider" class="KnpU\LoremIpsumBundle\KnpUWordProvider">
<tag name="knpu_ipsum_word_provider" />
</service>
... lines 15 - 25

If this makes no sense to you, no problem. Because, it will not work yet: when you refresh, big error! At this moment, there are zero word providers.

If you've worked with Symfony for a while, you've probably used tags before. At a high-level, the idea is pretty simple. First, you can attach tags to services... which... initially... does nothing. But then, a bundle author - that's us! - can write some code that finds all services in the container with this tag and dynamically add them to the collection argument!

When this is setup, our application - or even other bundles - can add services, give them this tag, and they will automatically be "plugged" into the system. This is how Twig Extensions, Event Subscribers, Voters, and many other parts of Symfony work.

The Easy Way

So... how do we hook this all up? Well, if your bundle will only need to support Symfony 3.4 or higher, there's a super easy way. Just replace the <argument type="collection"> with <argument type="tagged" tag="knpu_ipsum_word_provider" />. This tells Symfony to find all services with this tag, and pass them as a collection. And... you'd be done!

Tip

You will also need to change the array $wordProviders constructor argument in KnpUIpsum to iterable $wordProviders.

But, if you want to support earlier versions of Symfony, or you want to know how the compiler pass system works, keep watching.

Leave a comment!

  • 2020-05-15 Egor Ushakov

    Thank you, Ryan!

  • 2020-05-14 weaverryan

    Hey Egor Ushakov!

    That's 100% correct! The reason is that the instanceof stuff (just like the defaults) stuff applies to *only* the current file that this code appears in. This was done on purpose: it's a powerful way to make your life easier, but if it affected your *entire* application, crazy things would happen :). You *could* add an instanceof in a bundle that you're creating. But typically, as a best practice, bundle should explicitly require everything: the "shortcuts" are meant to help application developers more than developers of re-usable code.

    Cheers!

  • 2020-05-14 Egor Ushakov

    Hi!
    Am I correct saying that autoconfiguring tags does not work for bundle services since there's a comment in code snippet at the given link like

    # this config only applies to the services created by this file

    ? In my application I have an interface with auto-configured tag in services.yaml. And my bundle service implementing that interface does not have a tag on it until I explicitly add the tag to bundle's services.xml.

  • 2019-10-20 Laurens Mertens

    Thanks Ryan, iterable as typehint indeed works like a charm. I appreciate the quick and clear response, all the bests!

  • 2019-10-20 weaverryan

    Hey Laurens Mertens!

    Hmm. I can't find a change in Symfony that would specifically cause this. However, from checking the code, it looks like this has *always* been the case - even the original blog post about this feature shows using the iterable type-hint, not array: https://symfony.com/blog/ne...

    So, we'll add a note about that! When I created this, I was thinking that you could *simply* use the tagged argument type and make no other changes to your code. But, it looks like the array type-hint *does* need to change to iterable.

    Thanks for bringing this up!

  • 2019-10-19 Laurens Mertens

    Is it possible that now this argument is not an array but an object of type RewindableGenerator?
    <argument type="tagged" tag="knpu_ipsum_word_provider"/>

    That's what I get when I dump $wordProviders in the constructor of class KnpUIpsum. :-/

    It does work when you remove the typehint "array $wordProviders"