Say you’re building an application that has users. Those users can register themselves. You create a controller, perhaps using Symfony Forms, to get the data of the new user and store it in the database. After that, you might want to add secondary flows. For example to notify both the new user and the admins that the registration was successful.

Step 1: Everything in one spot

At first, I usually put everything that needs to happen into the controller. This way, I can verify that everything works as intended and I always have a working solution to fall back to if my refactoring breaks something. The result usually ends up a little like this:

<?php
declare(strict_types=1);

namespace App\Users\Registration;

final class Register
{
    /* ... */

    public function __invoke(Request $request): Response
    {
        // Form or data handling from the request
        $user = new User();
        /* ... */

        // Save the new user
        /* ... */

        // Secondary flow: Notify the user
        $email = (new Email())
            /* ... */
            ->to($user->getEmail())
            ->subject('Welcome to our application!')
            ->text('Some info about our services here!')
            ->html('<p>Some fancier info about our services here!</p>');
        $this->mailer->send($email);

        // Secondary flow: Notify the admins
        $email = (new Email())
            /* ... */
            ->subject('New user!')
            ->text('Some info about the newly registered user here!')
            ->html('<p>Some fancier info about the newly registered user here!</p>');
        $this->mailer->send($email);

        // Create a response
        return new Response(/* ... */);
    }
}

Step 2: Moving secondary steps to separate classes

This approach quickly becomes very cumbersome as the controller will start to grow out of control (no pun intended) when inevitably you end up adding more responsibility to it. Moving the secondary flow to separate methods cleans up the __invoke() method, but it still leaves a lot of code and dependencies in the controller.

What I usually do next is move the secondary stuff, which is everything that’s not needed for the core, most important flow (in this case: filling a user object and storing it in the database), to separate classes:

<?php
declare(strict_types=1);

namespace App\Users\Registration;

final class NotifyUser
{
    /* ... */

    public function afterUserRegistrationSuccessful(User $user): void
    {
        $email = (new Email())
            /* ... */
            ->to($user->getEmail())
            ->subject('Welcome to our application!')
            ->text('Some info about our services here!')
            ->html('<p>Some fancier info about our services here!</p>');
        $this->mailer->send($email);
    }
}

I then inject these classes into the controller through Dependency Injection:

<?php
declare(strict_types=1);

namespace App\Users\Registration;

final class Register
{
    /**
     * @var NotifyUser
     */
    private $notifyUser;

    /**
     * @var NotifyAdmins
     */
    private $notifyAdmins;

    public function __construct(/* ... */ NotifyUser $notifyUser, NotifyAdmins $notifyAdmins)
    {
        $this->notifyUser = $notifyUser;
        $this->notifyAdmins = $notifyAdmins;
    }

    public function __invoke(Request $request): Response
    {
        // Form or data handling from the request
        $user = new User();
        /* ... */

        // Save the new user
        $this->entityManager->persist($user);
        $this->entityManager->flush();

        // Secondary flows
        $this->notifyUser->afterUserRegistrationSuccessful($user);
        $this->notifyAdmins->afterUserRegistrationSuccessful($user);

        // Create a response
        return new Response(/* ... */);
    }
}

Step 3: Replace the hardcoded injection of these classes with a service iterator

To not have to change the controller every time I want to add a new bit of secondary functionality to the flow, I use a service iterator. Service iterators are part of the Symfony Dependency Injection. Using a service iterator will have the Symfony Dependency Injection look for services that are tagged with a specific tag and inject them using an iterator for you to loop through these services.

I also add an interface to make sure the method required is always available with the correct arguments and return type. Both NotifyUser and NotifyAdmins will implement this interface.

<?php
declare(strict_types=1);

namespace App\Users\Registration;

interface AfterUserRegistration
{
    public function afterUserRegistrationSuccessful(User $user): void;
}

In the controller, I inject the iterator and loop through the services to call the correct method.

<?php
declare(strict_types=1);

namespace App\Users\Registration;

final class Register
{
    /**
     * @var iterable|AfterUserRegistration[]
     */
    private $secondaryFlows;

    public function __construct(/* ... */ iterable $secondaryFlows)
    {
        $this->secondaryFlows = $secondaryFlows;
    }

    public function __invoke(Request $request): Response
    {
        // Form or data handling from the request
        $user = new User();
        /* ... */

        // Save the new user
        $this->entityManager->persist($user);
        $this->entityManager->flush();

        foreach ($this->secondaryFlows as $flow) { 
            if ($flow instanceof AfterUserRegistration) {
                $flow->afterUserRegistrationSuccessful($user);
            }
        }

        // Create a response
        return new Response(/* ... */);
    }
}

In the services definitions file, I use the _instanceof directive to tag all classes implementing my interface with a tag, and configure the $secondaryFlows argument of the controller to have a !tagged_iterator for the same tag.

# config/services.yaml
services:
    # ...
    _instanceof:
        App\Users\Registration\AfterUserRegistration:
            tags: ['app.user.after_registration'] # add this tag to all classes implementing this interface

    App\Users\Registration\Register:
        arguments:
            $secondaryFlows: !tagged_iterator 'app.user.after_registration' # grab all services tagged with this tag

Why I’m not using the Event system

Although I think that the Symfony Event system is great for inter-package communication or to hook into the flow of other bundles, I find that using it within the boundaries of a single application usually obfuscates the flow and structure of my code. It adds an extra layer and makes it harder to figure out what exactly happens at a given time.

The method described above will decouple primary and secondary flows, while still keeping the services, their methods, and their arguments descriptive. It even allows data to be returned from each service. All wrapped in an interface that describes exactly what is to be expected from a secondary flow service.

Thanks for proofreading and improvements: