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 SubscribeWhat if a user wants to change the behavior of our controller? Symfony does have a way to override controllers from a bundle... but not if that controller is registered as a service, like our controller. Well, ok, thanks to Symfony's incredible container, there is always a way to override a service. But let's not make our users do crazy things! If someone wants to tweak how our controller behaves, let's make it easy!
How? By dispatching a custom event. Ready for our new goal? I want to allow a user to change the data that we return from our API endpoint. Specifically, we're going to add a third key to the JSON array from our app.
The first step to dispatching an event is to create an event class. Create a new Event
directory with a PHP class inside: call it FilterApiResponseEvent
. I just made that up.
Make this extend a core Event
class from Symfony. When you dispatch an event, you have the opportunity to pass an Event object to any listeners. To be as awesome as possible, you'll want to make sure that object contains as much useful information as you can.
Tip
Starting from Symfony 4.4, you should use the Event
class from Symfony\Contracts\EventDispatcher
:
If you want to know more about this: https://github.com/symfony/event-dispatcher/blob/4.4/Event.php
... lines 1 - 6 | |
class FilterApiResponseEvent extends Event | |
{ | |
... lines 9 - 24 | |
} |
In this case, a listener might want to access the data that we're about to turn into JSON. Cool! Add public function __construct()
with an array $data
argument. I'll press Alt+Enter and choose "Initialize Fields" to create a data property and set it.
... lines 1 - 6 | |
class FilterApiResponseEvent extends Event | |
{ | |
private $data; | |
public function __construct(array $data) | |
{ | |
$this->data = $data; | |
} | |
... lines 15 - 24 | |
} |
Then, we need a way for the listeners to access this. And, we also want any listeners to be able to set this. Go back to the Code -> Generate menu, or Command + N on a Mac, choose "Getter and Setters" and select data
.
... lines 1 - 15 | |
public function getData(): array | |
{ | |
return $this->data; | |
} | |
public function setData(array $data) | |
{ | |
$this->data = $data; | |
} | |
... lines 25 - 26 |
It's ready!
Head to your controller: this is where we'll dispatch that event. First, set the data to a $data
variable and then create the event object: $event = new FilterApiResponseEvent()
passing it the data.
... lines 1 - 9 | |
class IpsumApiController extends AbstractController | |
{ | |
... lines 12 - 21 | |
public function index() | |
{ | |
... lines 24 - 28 | |
$event = new FilterApiResponseEvent($data); | |
... lines 30 - 32 | |
} | |
} |
I'm not going to dispatch the event quite yet, but at the end, pass $event->getData()
to the json
method.
... lines 1 - 21 | |
public function index() | |
{ | |
... lines 24 - 31 | |
return $this->json($event->getData()); | |
} | |
... lines 34 - 35 |
To dispatch the event, we need... um... the event dispatcher! And of course, we're going to pass this in as an argument: EventDispatcherInterface $eventDispatcher
. Press Alt+enter and select "Initialize Fields" to add that as a property and set it in the constructor.
... lines 1 - 13 | |
private $eventDispatcher; | |
... line 15 | |
public function __construct(KnpUIpsum $knpUIpsum, EventDispatcherInterface $eventDispatcher) | |
{ | |
... line 18 | |
$this->eventDispatcher = $eventDispatcher; | |
} | |
... lines 21 - 35 |
As soon as we do this, we need to also open services.xml
and pass a second argument: type="service"
and id="event_dispatcher"
.
... lines 1 - 6 | |
<services> | |
... lines 8 - 13 | |
<service id="knpu_lorem_ipsum.controller.ipsum_api_controller" class="KnpU\LoremIpsumBundle\Controller\IpsumApiController" public="true"> | |
... line 15 | |
<argument type="service" id="event_dispatcher" /> | |
</service> | |
... lines 18 - 20 | |
</services> | |
... lines 22 - 23 |
Back in the controller, right after you create the event, dispatch it: $this->eventDispatcher->dispatch()
. The first argument is the event name and we can actually dream up whatever name we want. Let's use: knpu_lorem_ipsum.filter_api
. For the second argument, pass the event.
... lines 1 - 9 | |
class IpsumApiController extends AbstractController | |
{ | |
... lines 12 - 21 | |
public function index() | |
{ | |
... lines 24 - 29 | |
$this->eventDispatcher->dispatch('knpu_lorem_ipsum.filter_api', $event); | |
... lines 31 - 32 | |
} | |
} |
Tip
Starting in Symfony 4.4, you only need to pass the $event
argument:
$this->eventDispatcher->dispatch($event);
Then, instead of knpu_lorem_ipsum.filter_api
, the event name becomes the event class:
in our case FilterApiResponseEvent::class
.
And... yea, that's it! I mean, we haven't tested it yet, but this should work: our users have a new hook point.
But actually there's a small surprise. Find your terminal and re-run all the tests:
./vendor/bin/simple-phpunit
They fail! Check this out: it says that our controller service has a dependency on a non-existent service event_dispatcher
. But, the service id is event_dispatcher
- that's not a typo! The problem is that the event_dispatcher
service - like many services - comes from FrameworkBundle
.
Open up the test that's failing: FunctionalTest
. Inside, we're testing with a kernel that does not include FrameworkBundle! We did this on purpose: FrameworkBundle is an optional dependency.
Let me say it a different way: one of our services depends on another service that may or may not exist. Since we want our bundle to work without FrameworkBundle, we need to make the event_dispatcher
service optional. To do that, add an on-invalid
attribute set to null
.
... lines 1 - 13 | |
<service id="knpu_lorem_ipsum.controller.ipsum_api_controller" class="KnpU\LoremIpsumBundle\Controller\IpsumApiController" public="true"> | |
... line 15 | |
<argument type="service" id="event_dispatcher" on-invalid="null" /> | |
</service> | |
... lines 18 - 23 |
Thanks to this, if the event_dispatcher
service doesn't exist, instead of an error, it'll just pass null
. That means, we need to make that argument optional, with = null
, or by adding a ?
before the type-hint.
... lines 1 - 9 | |
class IpsumApiController extends AbstractController | |
{ | |
... lines 12 - 15 | |
public function __construct(KnpUIpsum $knpUIpsum, EventDispatcherInterface $eventDispatcher = null) | |
{ | |
... lines 18 - 19 | |
} | |
... lines 21 - 35 | |
} |
Inside the action, be sure to code defensively: if there is an event dispatcher, do our magic.
... lines 1 - 21 | |
public function index() | |
{ | |
... lines 24 - 29 | |
if ($this->eventDispatcher) { | |
$this->eventDispatcher->dispatch('knpu_lorem_ipsum.filter_api', $event); | |
} | |
... lines 33 - 34 | |
} | |
... lines 36 - 37 |
Try the tests again:
./vendor/bin/simple-phpunit
Aw yea! Next, let's make our event easier to use by documenting it with an event constants class. Then... let's make sure it works!
Hi there!,
what would be a nice backend mechanism to load in a twig template header.html.twig a doctrine result based on a user id ??,
for example a counter of "notices" for that user, taking in account that the header is part of the base template and it is loaded every time any other template is requested.
Currently i am repeating the query in every controller action and returning it to twig for it to be loaded in the header template.
`return $this->render('my_template.html.twig', [
'unread_notices' => $noticesUserRepository->findBy(['user' => $this->getUser()]),
]);`
Is there a better backend way?
Thanks
Hey Omar,
Of course there are a lot of ways to do it, Let's start from Twig Extension, you can make a TwigFunction which you will use on base template.
Or you can make a sub-request to get part of template you need for notices. This way can also be useful if you want some ajax to update this notices.
Cheers
When i run bin/console debug:event-dispatcher
the event in the list
"knpu_lorem_ipsum.filter_api" event
------- ------------------------------------------------------------------- ----------
Order Callable Priority
------- ------------------------------------------------------------------- ----------
#1 App\EventSubscriber\AddMessageToIpsumApiSubscriber::onFilterApi() 0
I don't understand how symfony recognised the event "knpu lorem_ipsum.filter api" without executing the IpsumApiController::index function ?
I ask that because the event is only dispatched(created) in the controller function...
Hey Helmi
Good question :) That happens because of Symfony auto-configure feature. It basically scan your project and detects any class which implements the Symfony\Component\EventDispatcher\EventSubscriberInterface
Cheers!
Great course. I've been waiting for it for a long time :-)
Hi. What if I want to use a service like Translations. Where I should use the constructor and how because the constructor have parameters and I have to load the catalogs (https://symfony.com/doc/cur...
Another question. Can I use this service as optional? this make sense?
Hey Juan,
Thank you! :)
Not sure I understand your first question, what is your problem exactly? Since Translator is already a declared service.
You can make your service arguments optional, see this docs: https://symfony.com/doc/cur...
I hope this helps.
Cheers!
I have a bundle in which the services (commands) are registered dynamically depending on the configurations of the bundle
If you set command 1 then that command is created. I do this in my extension in this way
$configuration = $this->getConfiguration($configs, $container);
$config = $this->processConfiguration($configuration, $configs);
if (isset($config['search_in_code'])) {
$searchInCodeCommand = $container->register('micayael_commands.command.search_in_code', SearchInCodeCommand::class);
$searchInCodeCommand->setArguments([
new Reference('translator.data_collector'), <-- Here I inject de translator
$config['search_in_code'],
]);
$searchInCodeCommand->addTag('console.command');
}
To be able to perform tests in the development environment I create an application assigning the default settings and building the translator
-- bin/console
#!/usr/bin/env php
<?php
use Micayael\CommandsBundle\Command\SearchInCodeCommand;
use Micayael\CommandsBundle\Kernel;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Translation\Loader\YamlFileLoader;
require __DIR__ . '/../vendor/autoload.php';
$input = new ArgvInput();
$kernel = new Kernel('dev');
$kernel->boot();
$translator = new Translator('en'); <-- Translator
$translator->addLoader('yaml', new YamlFileLoader());
$translator->addResource('yaml', __DIR__.'/../src/Resources/translations/messages.es.yaml', 'es');
$translator->addResource('yaml', __DIR__.'/../src/Resources/translations/messages.en.yaml', 'en');
$translator->setFallbackLocales(['en']);
$application = new Application();
$application->add(new SearchInCodeCommand(
$translator,
[
'default_option' => 'php',
'app' => [
'php' => [
'php' => ['src']
]
],
'vendors' => [],
]
));
$application->run($input);
Would there be any way to inject the translator without having to create it here? Maybe through the Kernel
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Translation\Loader\YamlFileLoader;
use Symfony\Component\Translation\Translator;
class Kernel extends BaseKernel
{
public function __construct(string $environment)
{
parent::__construct($environment, true);
}
public function registerBundles()
{
return [
new MicayaelCommandsBundle(),
];
}
public function registerContainerConfiguration(LoaderInterface $loader)
{
}
}
Hey micayael!
Ah, ok! I think this makes sense now! Well, at least mostly - I may have a few other questions ;).
Your extension looks good :). Though, the translator service id should be translator
(unless you actually do what the "data collector" for some reason). Also, if you've defined your search_in_code key in your Configuration class, then you don't need to use isset - the key will always be there. You can just use if ($config['search_in_code'])
. But those are minor details.
It sounds to me like your real question is around testing your bundle. Is that correct? Do you want to write some automated tests for the bundle itself? Or do you simply want to be able to "play" with your bundle by writing some "dummy" code?
Usually, if you're in a Symfony application (e.g. with a Kernel class), then you already have a bin/console
file. And this file already creates your Kernel correctly... which takes care of creating the container... which handles instantiating your command class directly (with the correct arguments). So yes, you should not need to do all the work of creating the SearchInCodeCommand & Translator objects directly: we have already configured the container to be able to do that.
So, tell me a bit more about exactly what you are trying to do with this "testing in the development environment". We can definitely figure it out :).
Cheers!
Yes, I want to be able to run the commands to do some tests on dev environment.
1. About using "data collector" service: I run bin/console debug:autowiring and I did not see a "translator" service just these:
Symfony\Component\Translation\Extractor\ExtractorInterface
alias to translation.extractor
Symfony\Component\Translation\Reader\TranslationReaderInterface
alias to translation.reader
Symfony\Component\Translation\TranslatorInterface
alias to translator.data_collector
Symfony\Component\Translation\Writer\TranslationWriterInterface
alias to translation.writer
Symfony\Component\Validator\Validator\ValidatorInterface
But actually "translator" works. I don't know why but works jaja
2. I am not using symfony skeleton for my bundle. I create my bin/console and Kernel class based in http://symfony.com/doc/curr... and your tutorial, and note that Symfony\Component\Console\Application dosen't have a constructor with kernel argument.
I don't know how to get the translator service because I think I don't have a container full of services into this console script right now. make sense? Do you know how to get the service here?
Maybe I found the solution but I would like your opinion. I have to require symfony/framework-bundle --dev
Into my Kernel class I use MicroKernelTrait and I registered "FrameworkBundle" like this:
public function registerBundles()
{
return [
new MicayaelCommandsBundle(),
new FrameworkBundle(),
];
}
And that's it!! My bin/console script looks like this:
<?php
use Micayael\CommandsBundle\Command\SearchInCodeCommand;
use Micayael\CommandsBundle\Kernel;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
require __DIR__ . '/../vendor/autoload.php';
$input = new ArgvInput();
$kernel = new Kernel('dev');
$kernel->boot();
$application = new Application();
$application->add(new SearchInCodeCommand(
$kernel->getContainer()->get('translator'), <------------- inject the translator service.
[
'default_option' => 'php',
'app' => [
'php' => [
'php' => ['src']
]
],
'vendors' => [],
]
));
$application->run($input);
Could you explain why the container did not have the service before and now it has registered the service? and what other services would behave this way?
Thanks for your help
PS: I don't know how use well format code here. Could you tell me how to write it correctly?
Hey Juan!
Yea, the formatting is annoying in Disqus - you need to wrap everything in pre
and code
tags. I've updated your previous message with these :)
You did a good job with your solution :). But, there is a lot to talk about! Or really, I still have some questions. So, I understand that you are creating a bundle - MicayaelCommandsBundle. And, I assume this is a bundle that you will share between multiple projects, or even as an open source bundle? Is that correct? If so, then your bundle doesn't really need a bin/console command. The applications that use your bundle will have their own bin/console commands. And you will design your bundle so that your command is usable from their bin/console.
So, in your situation, are you creating a bin/console file that lives inside the bundle? And if so, why? I won't say anything more for now, because I don't want to say anything wrong if I'm misunderstanding what you're trying to do :). But, I will say that you did a good job booting your own Kernel class and creating your own bin/console file. But, I'm not convinced yet that you should really need/want to do this. Let me know!
Cheers!
Hi Ryan. Yes, of course, you are right. I now the final project will have a bin/console and yes, this bundle is an open source project to be used in several projects.
I create a bin/console script because I want to test the commands in a test environment (https://symfony.com/doc/cur...
Let me try to explain my situation with another example. This articule talks about dispaching an event and you let this functionality as optional. Let's pretend you want the event_dispacher service as required dependency. You will require symfony/event-dispatcher without --dev option. But event dispatcher is not a bundle (I think) and and I don't have a configuration key to configure it (https://github.com/symfony/.... So, I don't understand how event_dispatcher injects into the container to be used. Maybe with FrameworkBundle?
I have this problem with translation component. I want to configure message commands in multiple languages and of course I only need this works in the final project (that will be requiring my bundle), but, I want to test this functionality in dev and test environment, so I don't know what is the correct way to use this service in these environments.
As you say, the main objective of a bundle is to inject services into your project but, some symfony components like translation are not really bundle (again, I think) so I don't now how get those from the container. Thats because I think to require framework-bundle into my dev dependencies and I think the framework bundle injects the translation into the container but I don't really know if this is true.
I try to avoid the framework bundle like you say in your articles, thats because I try to understand how it works and how to use this services and require it just in --dev dependecies
Thanks very much for your patience
Hey micayael!
Sorry for the slow reply - just got back from vacation :). And yes, this stuff can be very confusing. So, I'll do my best to explain.
When the user uses your bundle, they will instantiate your Bundle class into their Kernel class. The MOST important result of this is that, when their container is being built, your DependencyInjection/<BundleName>Extension class will be called (I mean, the process function in that class will be called). This is your opportunity to add new services to their container. If you register a service and give it the console.command tag, then it will become a Command in the user's container (and so, it will be available in the user's bin/console script). If, in the services.yml (or services.xml, whatever you decide to call it) of your bundle, you specify that your command has an`
<argument type="service" id="translator" />`
, then the user's container will know to pass their translator
service into your command service. Why/how do we know that they have a translator service available? Well, we don't. The Translator classes obviously come from the symfony/translation component. But, the services are actually provided by FrameworkBundle. Like most components, FrameworkBundle automatically enables the translator
service IF it detects that the symfony/translation component is installed. So, IF your bundle put symfony/translation into its "require" key, then you can effectively assume that the user will have the translator component. If it is an optional dependency, then it should go into require-dev. In that case, you cannot* assume that the translator component will be there, and you should make it an optional dependency on your service and code accordingly.
But, I have 1 more important point. Your bundle should not (though, it's not a HUGE deal) require FrameworkBundle (as you already know). But... in my note above, I am talk about how the FrameworkBundle is responsible for supplying the translator service. So, this may seem confusing. Basically, you should write your bundle assuming that the user IS using FrameworkBundle - i.e. that all the normal services provided by that bundle are available. But, you simply should not actually require it in composer.json. This helps one VERY edge case: the small group of advanced users who choose not to use FrameworkBundle, can still use your bundle. Those users are very aware of the things that the FrameworkBundle does (like provide the translator) and so will be able to figure out what to do to make their app work with your code. So, do not actually require FrameworkBundle (it's ok to put it in require-dev of course), but DO assume that the user has it.
Ok, FINALLY, let's talk about testing your command! When your bundle is used by an application, the application creates the container and your bundle adds services to it. But, when you're testing your bundle, there is no container: you just have a bundle. IF you want to test your bundle, you basically need to create a "fake" container and instantiate your bundle (and usually FrameworkBundle). As you already know, we do this EXACT thing in this tutorial - we use it in our integration & functional tests. This is how your should test your bin/console commands - via a PHPUnit script where you boot a container. You should not actually need to create your own bin/console script. In the link you posted - https://symfony.com/doc/current/console.html#testing-commands - there is no bin/console script here either.
I think what is confusing is that the test requires you to say new CreateUserCommand()
. That IS confusing - even for me. You should not need to do this... in theory... because your command is already registered as a service in the container. So, that is puzzling for me - the docs may even be out of date (this line may not be needed). However, if it IS needed, then it's really is a workaround for some shortcoming of testing commands. In other words: DO instantiate it directly, and pass in the translator manually. It isn't ideal to need to do this, but it's fine - something like this:
$application->add(new CreateUserCommand($kernel->getContainer()->get('translator'));
I'm going to look into the docs - they may be wrong, and they are at least confusing ;).
I hope this helps! Cheers!
Yep, just an update: you should NOT need the new CreateUserCommand()
line in your test: you do NOT need to instantiate your command - it is already registered inside the container/kernel. I just removed that line and the test still works just fine. Updating the docs now :) https://github.com/symfony/symfony-docs/pull/10154
// 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
}
}
Fixes for Symfony 5:
- Symfony\Component\EventDispatcher\Event is now Symfony\Contracts\EventDispatcher\Event
- Symfony\Component\EventDispatcher\EventDispatcher::dispatch() now takes arguments in reverse order; I presume to allow the event name to be optional.