Tuesday, 29 July 2025

Quick look at event-driven programming in Symfony

G'day:

First up: this is not a very comprehensive look at this functionality. I just wanted to dip into it to see how it could work, and contextualise the docs.

I had a look at Symfony's messaging system in Using Symfony's Messaging system to separate the request for work to be done and doing the actual work which leverages Doctrine's entity events to send messages to other PHP apps via Symfony's messaging and RabbitMQ as the message bus's transport layer.

This time around I want to look at more generic events: not just stuff that Doctrine fires on entity activity, but on ad-hoc events I fire in my own code. This again is in an effort to get application code to focus on the one immediate job at hand, and easily delegate multiple-step processing to other systems / handlers.

Symfony's event handling is done via its EventDispatcher Component.

Installation is easy:

docker exec php-web composer require symfony/event-dispatcher:7.3.*

There is no other config. One just needs the library installed.

Symfony's DI container now has an EventDispatcher ready to be wired in to one's code, eg:

// src/Controller/StudentController.php

class StudentController extends AbstractController
{
    public function __construct(
        private readonly EventDispatcherInterface $eventDispatcher
    ) {
    }

Once one has that, one can dispatch events with it:

// src/Controller/StudentController.php

#[Route('/courses/{id}/students/add', name: 'student_add')]
public function add(/* [...] */): Response {

    // [...]

    if ($form->isSubmitted() && $form->isValid()) {
        // [...]

        $this->eventDispatcher->dispatch(new StudentRequestEvent($request, $student));

        return $this->redirectToRoute('course_view', ['id' => $course->getId()]);
    }
    // [...]

The object one passes to the dispatcher is arbitrary, and used for a) getting some data to the handler; b) type-checking which event a handler is for:

// src/Event/StudentRequestEvent.php

class StudentRequestEvent
{
    public function __construct(
        private readonly Request $request,
        private readonly Student $student
    ) { }

    public function getRequest(): Request
    {
        return $this->request;
    }

    public function getStudent(): Student
    {
        return $this->student;
    }
}

One can see here how the handler method looks for methods being handed a specific event class:

// src/EventListener/StudentProfileChangeListener.php
class StudentProfileChangeListener
{
    public function __construct(
        private readonly LoggerInterface $eventsLogger
    )
    { }

    #[AsEventListener()]
    public function validateProfileChange(StudentRequestEvent $event): void
    {
        $request = $event->getRequest();
        $route = $request->attributes->get('_route');

        if (!in_array($route, ['student_add', 'student_edit'])) {
            $this->eventsLogger->notice("validateProfileChange skipped for route [$route]");
            return;
        }
        $student = $event->getStudent();
        $this->eventsLogger->info(
            sprintf('Validate profile change: %s', $student->getFullName()),
            ['student' => $student]
        );
    }
}

The important bits are the #[AsEventListener()] attribute on the method, and that the method expects a StudentRequestEvent.

Here the "validateProfileChange" handling is just faked: I'm logging some telemetry so I can see what happens when the events fire & get handled.

In this case I have this event being fired in each of the add / edit / delete controller methods, and have the handler above listening for student_add and student_edit events; and another sendStudentWelcomePack handler which only listens for student_add (the code is much the same, so I won't repeat it). student_delete does not have anything handling it. Well: the handlers fire, but they exit-early.

If I add / edit / delete a student, we can see the log entries coming through, indicating which handlers fired, etc:

# after I submit the add form
[2025-07-29T15:53:26.607638+01:00] events.INFO: Validate profile change: Jillian Marmoset {"student":{"App\\Entity\\Student":{"email":"jillian.marmoset@example.com","fullName":"Jillian Marmoset","dateOfBirth":"2011-03-24","gender":"female","enrolmentYear":2016,"status":"Active"}}} []
[2025-07-29T15:53:26.607994+01:00] events.INFO: Send Welcome Pack to Jillian Marmoset {"student":{"App\\Entity\\Student":{"email":"jillian.marmoset@example.com","fullName":"Jillian Marmoset","dateOfBirth":"2011-03-24","gender":"female","enrolmentYear":2016,"status":"Active"}}} []



# after I submit the edit form
[2025-07-29T15:53:53.306371+01:00] events.INFO: Validate profile change: Gillian Marmoset {"student":{"App\\Entity\\Student":{"email":"gillian.marmoset@example.com","fullName":"Gillian Marmoset","dateOfBirth":"2011-03-24","gender":"female","enrolmentYear":2016,"status":"Active"}}} []
[2025-07-29T15:53:53.306733+01:00] events.NOTICE: sendStudentWelcomePack skipped for route [student_edit] [] []



# after I confirm the delete
[2025-07-29T15:54:06.827831+01:00] events.NOTICE: validateProfileChange skipped for route [student_delete] [] []
[2025-07-29T15:54:06.828087+01:00] events.NOTICE: sendStudentWelcomePack skipped for route [student_delete] [] []

Those logs are pointless, but in the real world, if an event is dispatched, any number of handlers can listen for them. And the controller method that dispatches the event doesn't need to know about any of them. We can also leverage the async message bus in the event handlers too, to farm processing off to completely different app instances. I can see how this will be very useful in the future…

As all this was far easier than I expected, this is a pretty short article. But I know how Symfony events can facilitate more event-driven programming now, and help keep my code more on-point, simple, and tidy.

Righto.

--
Adam