Modernizing PHP apps using DDD with Symfony

Razvan Dubau
8 min readMar 4, 2024

--

In the realm of web development, constant evolution is not just desirable; it’s imperative. As technology progresses, so do the demands and expectations of users. Modernizing existing PHP applications to meet contemporary standards is a challenge many companies face.

In this article, we’ll explore how to revitalize a PHP application using Domain-Driven Design (DDD) principles with the Symfony framework. We’ll discuss the tools and methodologies required for this transformation. We will delve into the Symfony Messenger component and how we can abstract and easily switch the way the system processes an action.

To make use of all the mentioned above, we’ll have to first discuss the integration of DDD with a Layered architecture, specifically Command Query Responsibility Segregation (CQRS).

Architectural key concepts

Domain-Driven Design (DDD) is a software development approach that emphasizes modeling software based on the business domain. One of the key architectural patterns within DDD is Layered Architecture, which promotes the separation of concepts and concerns and modularization. When combined with Command Query Responsibility Segregation (CQRS), DDD becomes a potent framework for modernizing PHP applications.

Layered Architecture: In a layered architecture, the application is divided into distinct layers, each responsible for a specific set of functionalities. Common layers include application services, domain, and infrastructure. By separating concerns and enforcing boundaries between layers, developers can achieve greater flexibility, scalability, and maintainability.

CQRS: Command Query Responsibility Segregation (CQRS) is a design pattern that advocates for the segregation of read and write operations within an application. By decoupling commands (write operations) from queries (read operations), CQRS enables optimization for specific use cases, improving performance and scalability. Implementing CQRS within a DDD context fosters a clear separation of concerns and aligns closely with the principles of domain modeling.

Monolith with service based approach vs DDD with layered architecture

We will analyze how both approaches function within the context of an online furniture store. Reflecting on the typical REST API controller -> service examples prevalent in Symfony documentation and other frameworks over the past decade, it’s evident that when implementing an endpoint such as GET /api/orders, the conventional pattern involves a OrderController invoking an OrderService, which in turn interacts with the OrderRepository to retrieve the list from the database. Subsequently, the data is returned through the entire chain, often accompanied by a serialization process conducted within the controller. Thus far, this approach suffices for most simple APIs, albeit not the most optimal one.

When considering the creation of an order, which prompts us to think of an endpoint like POST /api/orders. Using the service-based approach like for the GET endpoint, we can easily reach very long execution times.

Why?
Imagine a scenario, but not limit to this, where creating an Order involves a series of actions/tasks across different domains. To understand this we can focus on 4 tasks for now:

  • check and update the inventory
  • send email notification to the client
  • send SMS notification to the client
  • book a delivery slot for the created order

Looking at this chain of actions that need to be taken we can easily asses that the creation of an order can take more than few seconds. If we use third party clients for email and sms notifications we might get some latency for those as well.

From a delivery standpoint, determining the optimal time slot and selecting a vehicle with sufficient capacity among a fleet of hundreds or thousand of cars requires substantial computational resources.

On the other had, the inventory update should be a easy task, part of the inventory domain, that has to be executed in the same thread with the creation of the order to make sure that the product is not out of stock due to concurent orders.

Check the inventory, create the order, send email and sms, book a delivery slot

How will all these tasks impact the UX?
The user will have to wait for a long time looking at a loading spinner.

How it will affect the servers?
In general if we have executions that have to run for more than x seconds and consume a lot of RAM memory, the server will soon get into memory issues or timeout errors. Of course we can increase these settings, but do we want it? Of course not! That’s the first sign of a bad architectural design.

What would be the next option in this case without using DDD?
Usually, what technical teams tend to do in these scenarios is to study a message queue system like rabbitMQ, AWS SQS, Kafka and others, in order to decouple the long execution tasks from the API endpoint and make the route respond faster so the user gets a better experience.

The team will then modify the OrderService to push a series of messages to a queue and process them asynchronously instead of directly calling the EmailService, SmsService and DeliveryService to do their tasks.

Create order and start an async task for the email, sms and delivery tasks

This will work, but the code itself and the changes needed for the code architecture and structure tend to be quite big for a single action.

(*) Also probably you noticed that the OrderController is not capable to respond with data about Delivery anymore, since the processing of the delivery happens now in a separate asynchronous task.

Because the business evolve, the team can get into the same problem again with a different set of connected actions between business domains. The solution will be to change another service to queue a message instead of direct service call again and again. This will enforce another rethink about how the queues and workers are built up.

All of the above steps cause a lot of unexpected work for the team and also unexpected costs for the business!

What if we can design a system that is modular enough and can deal with synchronous or asynchronous messages from day one?

As you can expect the solution for this problems is using DDD with Layered architecture along and Symfony Messenger.

The use of messenger is quite simple. We will need a rabbitMQ container/instance to connect the messenger component with and very few configurations for it.

What we want to achieve is to be able to dispatch a Command sync or async based on a simple interface implementation.

Create order, dispatch async email and sms task and dispatch domain event

We can see in the above schema that the OrderController creates a new Command object, which contains all the necessary order data. The CreateOrderCommand is then sent to the message bus (sf messenger), which decides if the job will be processed synchronously or asynchronously.

namespace App\Order\Domain\Command;

final class CreateOrderCommand
{
private array $articles;
private float $amount;
}

In this case, the CreateOrderCommand is dispatched as a synchronous task. First, the Order domain has to communicate with the Inventory domain to determine if the articles are still available. This communication should occur between domains synchronously. As a solution to this, we can make curl calls from the Order domain infrastructure layer.

Next, when the CreateOrderHandler finishes processing and saving the data, it has to dispatch two commands: SendEmailCommand and SendSmsCommand.

namespace App\Order\Domain\Command\CreateOrder;

use App/MessageBus/ProcessAsynchronouslyInterface;

final class SendEmailCommand implements ProcessAsynchronouslyInterface
{
private string $userFirstName;
private string $userLastName;
private string $email;
...
}
namespace App\Order\Domain\Command\CreateOrder;

use App/MessageBus/ProcessAsynchronouslyInterface;

final class SendSmsCommand implements ProcessAsynchronouslyInterface
{
private string $userFirstName;
private string $userLastName;
private string $phoneNumber;
...
}

Ultimately, the Order domain triggers a domain event, enabling any subscriber to perform actions based on this specific business event. In our scenario, we refer to the Delivery domain, which must listen to this event and dispatch various commands within its domain to facilitate the delivery process.

namespace App\DomainEvents;

final class OrderEvents
{
public const ORDER_CREATED = 'order_created';

...
}
namespace App\DomainEvents\Order;

use App/DomainEvents/OrderEvents;

final class OrderCreatedEvent implements DomainEventInterface
{
public function __construct(public string $uuid)
{
}

public function getEventName(): string
{
return OrderEvents::ORDER_CREATED;
}
}
namespace App\Order\Application\CommandHandler;

class CreateOrderHandler
{
public function __construct(private MessageBusInterface $messageBus)
{}

public function __invoke(CreateOrderCommand $command)
{

/*
Check the inventory. We can do it by a curl or Symfony sub-request if
the domains share the same Symfony instance.
*/

$order = // Create and save the Order entity.

$sendEmailCommand = new SendEmailCommand(/* Order details to be added here*/);
$this->messageBus->dispatch(Envelope::wrap($sendEmailCommand));

$sendSmsCommand = new SendSmsCommand(/* Order details to be added here*/);
$this->messageBus->dispatch(Envelope::wrap($sendSmsCommand));

// Dispatch the order created domain event
$orderCreatedEvent = new OrderCreatedEvent($order->getUuid());
$this->messageBus->dispatch(Envelope::wrap($orderCreatedEvent);
}
}

In conclusion, the same architectural pattern is employed for all Command/Handler pairs. One (CreateOrderCommand) is processed directly, while the other two are processed as entirely decoupled tasks (sending SMS and email). Additionally, a domain event is dispatched to all subscribers, with the Delivery domain subscribing to it via the fanout exchange named domain_events .

How things are running synchronously or asynchronously, and how do we ensure they function accordingly?
With the benefit of the messenger component, we can simply add an interface to the SendEmailCommand and SendSmsCommand and route that interface to an asynchronous transport, implicitly to RabbitMQ.

# config/packages/messenger.yaml
framework:
messenger:
buses:
messenger.bus.default:
default_middleware: false
middleware:
- id: 'add_bus_name_stamp_middleware'
arguments:
- 'messenger.bus.default'
- 'dispatch_after_current_bus'
- 'failed_message_processing_middleware'
- 'send_message'
- 'handle_message' # this will execute the task synchronously
transports:
async_tasks:
dsn: '%env(AMQP_URL)%'
options:
exchange:
name: app_async_tasks
type: fanout
queues:
app_async_tasks: ~

domain_events:
dsn: '%env(AMQP_URL)%'
options:
exchange:
name: domain_events
type: fanout
queues:
domain_events: ~
routing:
App\MessageBus\ProcessAsynchronouslyInterface : async_tasks # this will force any ProcessAsynchronously command to be processed async
App\MessageBus\DomainEventInterface : domain_events

Commands that implement the ProcessAsynchronouslyInterface will be executed as asynchronous tasks, while events implementing the DomainEventInterface are processed asynchronously by multiple handlers across the domains. Each of these handlers or event-listeners will then create different commands and dispatch them internally as synchronous or asynchronous tasks.

By repeating this architectural pattern, we can achieve and execute anything that the domain requires in a highly decoupled and flexible manner.

(*) Since the delivery is processed separately, our app is no longer capable of returning the delivery costs and time estimations together with the confirmation that the order was created. Here, we need to change our mindset and let go of the idea that an endpoint should handle and return everything.
Possible solutions in this case include either using websockets (where the delivery domain, upon completing its job, pushes a notification to the browser), or having the frontend app continuously fetch the data in a loop until the delivery domain has finished processing.

Is this everything we need for migrating a legacy project to a modern architecture?

No! There will be a lot of challenges ahead, such as:

For all of these topics, we will discuss them in separate articles, so stay tuned.

--

--

Razvan Dubau

Software developer that focuses on clean code and scalable solutions