Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feature #33851 [EventDispatcher] Allow to omit the event name when re…
…gistering listeners (derrabus)

This PR was merged into the 4.4 branch.

Discussion
----------

[EventDispatcher] Allow to omit the event name when registering listeners

| Q             | A
| ------------- | ---
| Branch?       | 4.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | #33453 (kind of)
| License       | MIT
| Doc PR        | TODO

After #30801 and #33485, this is another attempt at taking advantage of FQCN events for simplifying the registration of event listeners by inferring the event name from the parameter type declaration of the listener. This is my last attempt, I promise. 🙈

This time, I'd like to make the `event` attribute of the `kernel.event_listener` tag optional. This would allow us to build listeners like the following one without adding any attributes to the `kernel.event_listener` tag.

```php
namespace App\EventListener;

final class MyRequestListener
{
    public function __invoke(RequestEvent $event): void
    {
        // do something
    }
}
```

This in turn allows us to register a whole namespace of such listeners without having to configure each listener individually:

```YAML
services:
    App\EventListener\:
        resource: ../src/EventListener/*
        tags: [kernel.event_listener]
```

Commits
-------

6f32584 [EventDispatcher] Allow to omit the event name when registering listeners.
  • Loading branch information
nicolas-grekas committed Oct 9, 2019
2 parents fbf55c2 + 6f32584 commit 0094884
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/Symfony/Component/EventDispatcher/CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
-----

* `AddEventAliasesPass` has been added, allowing applications and bundles to extend the event alias mapping used by `RegisterListenersPass`.
* Made the `event` attribute of the `kernel.event_listener` tag optional for FQCN events.

4.3.0
-----
Expand Down
Expand Up @@ -16,8 +16,10 @@
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\Event as LegacyEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\EventDispatcher\Event;

/**
* Compiler pass to register tagged services for an event dispatcher.
Expand Down Expand Up @@ -67,8 +69,14 @@ public function process(ContainerBuilder $container)
$priority = isset($event['priority']) ? $event['priority'] : 0;

if (!isset($event['event'])) {
throw new InvalidArgumentException(sprintf('Service "%s" must define the "event" attribute on "%s" tags.', $id, $this->listenerTag));
if ($container->getDefinition($id)->hasTag($this->subscriberTag)) {
continue;
}

$event['method'] = $event['method'] ?? '__invoke';
$event['event'] = $this->getEventFromTypeDeclaration($container, $id, $event['method']);
}

$event['event'] = $aliases[$event['event']] ?? $event['event'];

if (!isset($event['method'])) {
Expand Down Expand Up @@ -122,6 +130,24 @@ public function process(ContainerBuilder $container)
ExtractingEventDispatcher::$aliases = [];
}
}

private function getEventFromTypeDeclaration(ContainerBuilder $container, string $id, string $method): string
{
if (
null === ($class = $container->getDefinition($id)->getClass())
|| !($r = $container->getReflectionClass($class, false))
|| !$r->hasMethod($method)
|| 1 > ($m = $r->getMethod($method))->getNumberOfParameters()
|| !($type = $m->getParameters()[0]->getType())
|| $type->isBuiltin()
|| Event::class === ($name = $type->getName())
|| LegacyEvent::class === $name
) {
throw new InvalidArgumentException(sprintf('Service "%s" must define the "event" attribute on "%s" tags.', $id, $this->listenerTag));
}

return $name;
}
}

/**
Expand Down
Expand Up @@ -14,6 +14,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\DependencyInjection\AddEventAliasesPass;
use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass;
Expand Down Expand Up @@ -244,6 +245,116 @@ public function testAliasedEventListener(): void
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}

public function testOmitEventNameOnTypedListener(): void
{
$container = new ContainerBuilder();
$container->setParameter('event_dispatcher.event_aliases', [AliasedEvent::class => 'aliased_event']);
$container->register('foo', TypedListener::class)->addTag('kernel.event_listener', ['method' => 'onEvent']);
$container->register('bar', TypedListener::class)->addTag('kernel.event_listener');
$container->register('event_dispatcher');

$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);

$definition = $container->getDefinition('event_dispatcher');
$expectedCalls = [
[
'addListener',
[
CustomEvent::class,
[new ServiceClosureArgument(new Reference('foo')), 'onEvent'],
0,
],
],
[
'addListener',
[
'aliased_event',
[new ServiceClosureArgument(new Reference('bar')), '__invoke'],
0,
],
],
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}

public function testOmitEventNameOnUntypedListener(): void
{
$container = new ContainerBuilder();
$container->register('foo', InvokableListenerService::class)->addTag('kernel.event_listener', ['method' => 'onEvent']);
$container->register('event_dispatcher');

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Service "foo" must define the "event" attribute on "kernel.event_listener" tags.');

$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
}

public function testOmitEventNameAndMethodOnUntypedListener(): void
{
$container = new ContainerBuilder();
$container->register('foo', InvokableListenerService::class)->addTag('kernel.event_listener');
$container->register('event_dispatcher');

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Service "foo" must define the "event" attribute on "kernel.event_listener" tags.');

$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
}

/**
* @requires PHP 7.2
*/
public function testOmitEventNameAndMethodOnGenericListener(): void
{
$container = new ContainerBuilder();
$container->register('foo', GenericListener::class)->addTag('kernel.event_listener');
$container->register('event_dispatcher');

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Service "foo" must define the "event" attribute on "kernel.event_listener" tags.');

$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);
}

public function testOmitEventNameOnSubscriber(): void
{
$container = new ContainerBuilder();
$container->register('subscriber', IncompleteSubscriber::class)
->addTag('kernel.event_subscriber')
->addTag('kernel.event_listener')
->addTag('kernel.event_listener', ['event' => 'bar', 'method' => 'onBar'])
;
$container->register('event_dispatcher');

$registerListenersPass = new RegisterListenersPass();
$registerListenersPass->process($container);

$definition = $container->getDefinition('event_dispatcher');
$expectedCalls = [
[
'addListener',
[
'bar',
[new ServiceClosureArgument(new Reference('subscriber')), 'onBar'],
0,
],
],
[
'addListener',
[
'foo',
[new ServiceClosureArgument(new Reference('subscriber')), 'onFoo'],
0,
],
],
];
$this->assertEquals($expectedCalls, $definition->getMethodCalls());
}
}

class SubscriberService implements EventSubscriberInterface
Expand Down Expand Up @@ -285,3 +396,43 @@ final class AliasedEvent
final class CustomEvent
{
}

final class TypedListener
{
public function __invoke(AliasedEvent $event): void
{
}

public function onEvent(CustomEvent $event): void
{
}
}

final class GenericListener
{
public function __invoke(object $event): void
{
}
}

final class IncompleteSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'foo' => 'onFoo',
];
}

public function onFoo(): void
{
}

public function onBar(): void
{
}

public function __invoke(CustomEvent $event): void
{
}
}

0 comments on commit 0094884

Please sign in to comment.