backend, Apr 12, 20233 min read

Handling doctrine entities with messenger

When working with large applications, it is common to experience performance issues, especially when dealing with complex data processing, such as sending thousands of emails to newsletter subscribers or post-processing a huge excel file.

Asynchronous Messaging, a store-and-forward technique, can help to alleviate this. Unlike the typical client-server system, which relies on reply-based communication, Asynchronous Messaging does not require the sender to wait for a response from the recipient. This decoupling of senders and receivers ensures that they are no longer required to execute in lockstep allowing for greater flexibility and scalability.

In order to create message-driven applications, the Symfony messenger component seems to be a good choice, but you need to be careful when using it with doctrine entities.

Now let’s assume you are working on a Ticketing System for Events and need to notify all users that you’ve made a discount on specific event tickets.

First, let’s define the data structure we need:

How to notify all users that you've made a discount on specific event tickets while working on Ticketing System.

What should you do?

1. Create the DiscountNotification class;

<?php

namespace App\Message;

class DiscountNotification
{
    public function __construct(
        private int $discountId,
    ) {
    }
    public function getDiscountId(): int
    {
        return $this->discountId;
    }
}


As you can clearly see here, we’re passing the discountId to the DiscountNotification message and not the discount object itself.

2. Create the DiscountNotificationHandler class

<?php

namespace App\MessageHandler;

use App\Message\DiscountNotification;
use App\Repository\DiscountRepository;
use App\Repository\TicketRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class DiscountNotificationHandler
{
    public function __construct(
        private DiscountRepository $discountRepository,
        private TicketRepository $ticketRepository,
    ) {
    }
    public function __invoke(DiscountNotification $discountNotification): void
    {
        $discount = $this->discountRepository->find(
            $discountNotification->getDiscountId()
        );
        foreach ($discount->getEvent()->getTickets() as $ticket) {
            $ticket->setPrice(
                $ticket->getPrice() * (1 - $discount->getPercentage() / 100)
            );
            $this->ticketRepository->add($ticket, true);
        }
        // ... send emails to users to notify them with new prices
    }
}

In DiscountNotificationHandler, we calculate the discount for each event ticket and then email users.

3. Configure your DiscountNotification message as asynchronous

framework:
    messenger:
        transports:
             async: '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
             'App\Message\DiscountNotification': async

4. Dispatch the DiscountNotification message

public function new(
    Request $request, 
    DiscountRepository $discountRepository, 
    MessageBusInterface $bus): Response
{
    $discount = new Discount();
    $form = $this->createForm(DiscountType::class, $discount);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $discountRepository->add($discount, true);
        // dispatch discount notification message
        $bus->dispatch(new DiscountNotification($discount->getId()));
        return $this->redirectToRoute(
            'app_discount_index',
            [],
            Response::HTTP_SEE_OTHER
        );
    }
    return $this->renderForm('discount/new.html.twig', [
        'discount' => $discount,
        'form' => $form,
    ]);
}

Now for the final touch, in our controller, we need to create and save the discount entity before dispatching the DiscountNotification message.

What you should not do :
You should not pass the entire discount object to the DiscountNotification message like the following:

class DiscountNotification
{
    public function __construct(
        private Discount $discount,
    ) {
    }
    public function getDiscount(): Discount
    {
        return $this->discount;
    }
}

Instead, you should pass only the discount ID and later fetch the object from the database in the message handler, like shown before.
Passing the entire discount object will make the doctrine unable to manage > level 1 depth relations. In our case, ticket objects are considered a level 2 depth since we’re loading them from the event, which is by itself loaded from the discount.

public function __invoke(DiscountNotification $discountNotification): void
{
    $discount = $discountNotification->getDiscount();
    // count($discount->getEvent()->getTickets()) = 0
    foreach ($discount->getEvent()->getTickets() as $ticket) {
        $ticket->setPrice(
            $ticket->getPrice() * (1 - $discount->getPercentage() / 100)
        );
        $this->ticketRepository->add($ticket, true);
    }
    // ... send emails to users to notify them with new prices
}

Now our DiscountNotificationHandler won’t update any ticket price because, as you see in the comment above, the number of event tickets is equal to 0 since ticket collection is lazy loaded, even if you explicitly make it as fetch EAGER.

class Event
{
    ...
    /**
     * @ORM\OneToMany(targetEntity=Ticket::class, mappedBy="event", fetch="EAGER")
     */
    private $tickets;
    ...
}

Tickets collection will be initialized, but still, the DiscountNotificationHandler won’t work, and messenger will throw a Critical Error this time saying that:

Handling ‘App\Message\DiscountNotification’ failed: A new entity was found through the relationship ‘App\Entity\Ticket#event’ that was not configured to cascade persist operations for entity…

Conclusion:

When working with Symfony messenger, make sure that you pass minimal required data to message and later fetch all the data you need with the help of the entity manager.

Another approach is to work with serialization groups and encode objects before passing them to the message, but be careful. This way will make you lose the ability to have fresh data in your message handler.


You liked this? Give Ghaith a .

2075