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.
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.
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.
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.
Hey @Boolean_Type!
Hmm, let's see. So first, as you know, one way or another we must to add the knpu_ipsum_word_provider
tag to the CustomWordProvider
service. But, there are two ways to do this... and you listed both of them :). Either you can add the tag directly, or you can register the interface it implements for autoconfiguration. How you add the tag doesn't matter - it will work the same both ways. We actually do the registerForAutoconfiguration()
thing - we do it in the next chapter: https://symfonycasts.com/screencast/symfony-bundle/tags-compiler-pass#codeblock-1ed6575410
Cheers!
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.
Hey erop!
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!
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"
Hey Laurens M.!
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/new-in-symfony-3-4-simpler-injection-of-tagged-services
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!
Thanks Ryan, iterable as typehint indeed works like a charm. I appreciate the quick and clear response, all the bests!
// 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
}
}
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>).