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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeAt 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.
Hi! I'm using Symfony 5.2.
I have a notice about "The Easy Way" (
<argument type="tagged" tag="knpu_ipsum_word_provider" />
). I had the following configuration inKnpULoremIpsumExtension::load()
:And the following configuration in "services.xml":
Words from
CustomWordProvider
weren't present on page. So, I still should or add tag definition for provider in "services.yaml" in my main app:... either set tag in
KnpULoremIpsumExtension::load()
:Maybe, I've done something wrong, but only explicit tag definition for
App\Service\CustomWordProvider
worked for me (<i>in case of "easy way"</i>).