Your Own Events

Dispatch an event object when something happens and let unrelated code react, instead of wiring one service to five others.

Syntaxpublic function dispatch(object $event, ?string $eventName = null): object

Kernel events are not special. The same dispatcher carries events you define, and the point is to stop one class from knowing about every consequence of what it does.

Consider a class that publishes a comment. Without events it ends up holding a mailer, a search indexer, a cache, a counter and a webhook client. Five constructor arguments, five reasons for the file to change, and a test that has to fake all five. Dispatching one event replaces all of it: the publisher announces what happened, and each consequence lives in its own class that listens.

How it works

An event here is a plain PHP object carrying the facts. Name it after something that has already happened, in the past tense, and make the payload read-only unless a listener is meant to change it. Extend Symfony\Contracts\EventDispatcher\Event when listeners should be able to call stopPropagation(); any object can be dispatched without it.

Inject EventDispatcherInterface and call dispatch(). With no second argument, the event's fully qualified class name is the event name, which is why listeners can simply type-hint the class. dispatch() returns the event, so a listener can hand information back.

What trips people up

Listeners run synchronously, inside the same request, before dispatch() returns. An exception thrown in a listener reaches the caller. Dispatching an event is not the same as sending it elsewhere to be handled later.

Example

Example · php
<?php
// src/Service/CommentPublisher.php
declare(strict_types=1);

namespace App\Service;

use App\Event\CommentPublishedEvent;
use App\Model\Comment;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final class CommentPublisher
{
    // The dispatcher is injected, type-hinted on the interface.
    public function __construct(
        private readonly EventDispatcherInterface $dispatcher,
    ) {
    }

    public function publish(Comment $comment): void
    {
        $comment->markPublished();

        // One line in place of a mailer, an indexer and a cache.
        // This class does not know those listeners exist, and adding a
        // sixth consequence later will not reopen this file.
        //
        // No event name is passed, so the name is the class name:
        // App\Event\CommentPublishedEvent
        $this->dispatcher->dispatch(new CommentPublishedEvent($comment));
    }
}

When to use it

  • Send a notification when something is published, without the publishing class knowing about mail.
  • Let a bundle or module react to your application's actions without you editing its code.
  • Shrink a service that has grown six constructor dependencies, one per side effect.
  • Allow one part of the application to override a generated value, such as a slug.

More examples

The event object

The whole event class. It carries facts and nothing else: no services, no behaviour, no knowledge of who listens. Readonly promoted properties mean two listeners cannot fight over the payload, and the second listener cannot be broken by the first. Extending Event is a choice, not a requirement: dispatch() accepts any object, and the base class exists to give listeners the ability to stop the ones behind them. If no listener should ever veto, a plain final class with no parent is enough.

Example · php
<?php
// src/Event/CommentPublishedEvent.php
declare(strict_types=1);

namespace App\Event;

use App\Model\Comment;
use Symfony\Contracts\EventDispatcher\Event;

// Past tense: it reports something that already happened.
// Extending Event adds stopPropagation() and isPropagationStopped().
final class CommentPublishedEvent extends Event
{
    public function __construct(
        // Promoted and readonly: listeners read, they do not rewrite.
        public readonly Comment $comment,
        public readonly \DateTimeImmutable $publishedAt = new \DateTimeImmutable('2026-01-01 09:00:00'),
    ) {
    }
}

A listener for it

This is the whole of one consequence: a file that can be added or deleted without touching CommentPublisher. The attribute needs no event name because the __invoke() type declaration is the registration. Adding a search-index update means adding a second file exactly like this one. Deleting a feature means deleting its listener. That is the practical pay-off of the event: the list of consequences is the list of files in src/EventListener, not a growing constructor.

Example · php
<?php
// src/EventListener/NotifyModeratorsListener.php
declare(strict_types=1);

namespace App\EventListener;

use App\Event\CommentPublishedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
final class NotifyModeratorsListener
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    public function __invoke(CommentPublishedEvent $event): void
    {
        $this->logger->info('Comment published', [
            'comment' => $event->comment->getId(),
            'at' => $event->publishedAt->format(DATE_ATOM),
        ]);
    }
}

An event that collects an answer

The other shape of event: not a notification but a question. The payload is writable, dispatch()'s return value is used, and stopPropagation() makes the first listener to answer the only one that counts. Reach for this when a decision has to be made outside the class that needs it, for example letting a plugin override a generated slug or a price. Keep it rare, because two listeners writing the same field is harder to debug than two listeners reacting to a fact.

Example · php
<?php
// src/Event/SlugProposedEvent.php
declare(strict_types=1);

namespace App\Event;

use Symfony\Contracts\EventDispatcher\Event;

final class SlugProposedEvent extends Event
{
    public function __construct(
        public readonly string $title,
        private string $slug,
    ) {
    }

    public function getSlug(): string
    {
        return $this->slug;
    }

    public function setSlug(string $slug): void
    {
        $this->slug = $slug;
        // Later listeners are skipped: the first answer wins.
        $this->stopPropagation();
    }
}

// In a service, with the dispatcher injected as $this->dispatcher:
//
// $event = $this->dispatcher->dispatch(new SlugProposedEvent($title, $default));
// $slug = $event->getSlug();
//
// dispatch() returns the same object the listeners saw, so whatever the
// first listener set is what the caller reads back.

Discussion

  • Be the first to comment on this lesson.