Software Development
Sebastian Luczak
Sebastian Luczak
PHP Unit Leader
2022-06-28

Microservices communication in Symfony - Part I

Read the first part of our PHP series devoted to microservices communication in Symfony framework and the most popular way - AMQP communication using RabbitMQ.

Modern application architecture has forced developers to change the way of thinking about the communication between different components of IT systems. Once the matter was simpler - most systems were created as monolithic structures connected with each other by a network of business logic connections. Maintaining such dependencies in a PHP project was a huge challenge for PHP developers, and the growing popularity of SaaS solutions and the huge increase in the popularity of cloud services caused that today we hear more and more about microservices and application modularity.

Just how, by creating independent microservices, can we make them exchange information with each other? πŸ€”

This article is the first in a series of posts on microservices communication in Symfony framework and it covers the most popular way - AMQP communication using RabbitMQ.

Goal

To create two independent applications and achieve communication between them using only Message Bus.

The Concept

We have two, imaginary, independent applications:

  • app1: which sends E-Mail and SMS notifications to employees
  • app2: which allows you to manage employee's work and assign them tasks.

We want to create a modern and simple system whereby the assignment of work to an employee in app2 will send a notification to the customer using app1. Despite appearances, this is very simple!

Preparation

For the purpose of this article, we will use the latest Symfony(version 6.1 at the time of writing) and the latest version of PHP (8.1). In a few very simple steps we will create a working local Docker environment with two microservices. All you need is:

Runtime environment

We will use Docker's capabilities as an application virtualization and containerization tool. Let's start by creating a directory tree, a framework for two Symfony applications, and describe the infrastructure of our environments using the docker-compose.yml file.

cd ~
mkdir microservices-in-symfony
cd microservices-in-symfony
symfony new app1
symfony new app2
touch docker-compose.yml

We have created two directories for two separate Symfony applications and created an empty docker-compose.yml file to launch our environment.

Let's add the following sections to the docker-compose.yml file:

version: '3.8'

services:
  app1:
    container_name: app1
    build: app1/.
    restart: on-failure
    env_file: app1/.env
    environment:
      APP_NAME: app1
    tty: true
    stdin_open: true

  app2:
    container_name: app2
    build: app2/.
    restart: on-failure
    env_file: app2/.env
    environment:
      APP_NAME: app2
    tty: true
    stdin_open: true

  rabbitmq:
    container_name: rabbitmq
    image: rabbitmq:management
    ports:
      - 15672:15672
      - 5672:5672
    environment:
      - RABBITMQ_DEFAULT_USER=user
      - RABBITMQ_DEFAULT_PASS=password

Source code available directly: thecodest-co/microservices-in-symfony/blob/main/docker-compose.yml

But wait, what happened here? For those unfamiliar with Docker, the above configuration file may seem enigmatic, however its purpose is very simple. Using Docker Compose we are building three "services":

  • app1: which is a container for the first Symfony application
  • app2: which is the container for the second Symfony application
  • rabbitmq: the RabbitMQ application image as a communication middleware layer

For proper operation, we still need Dockerfile files which are the source to build the images. So let's create them:

touch app1/Dockerfile
touch app2/Dockerfile

Both files have exactly the same structure and look as follows:

FROM php:8.1

COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
COPY . /app/
WORKDIR /app/

ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/

RUN chmod +x /usr/local/bin/install-php-extensions && sync && \
    install-php-extensions amqp

RUN apt-get update \
  && apt-get install -y libzip-dev wget --no-install-recommends \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN docker-php-ext-install zip;

CMD bash -c "cd /app && composer install && php -a"

Source code available directly: /thecodest-co/microservices-in-symfony/blob/main/app1/Dockerfile

The above file is used by Docker Compose to build a container from a PHP 8.1 image with Composer and the AMQP extension installed. Additionally, it runs the PHP intepreter in append mode to keep the container running in the background.

Your directory and file tree should now look as follows:

.
β”œβ”€β”€ app1
β”‚   └── Dockerfile
|       (...) # Symfony App Structure
β”œβ”€β”€ app2
β”‚   └── Dockerfile
|       (...) # Symfony App Structure
└── docker-compose.yml

The first Symfony microservice

Let's start with the app1 directory and the first application. In our example, it is an application that listens and consumes messages from the queue sent by app2 as described in the requirements:

assigning a job to a worker in app2 will send a notification to the client

Let's start by adding the required libraries. AMQP is natively supported for the symfony/messenger extension. We will additionally install monolog/monolog to keep track of system logs for easier application behavior analysis.

cd app1/
symfony composer req amqp ampq-messenger monolog

After the installation, an additional file was added under config/packages/messenger.yaml. It is a configuration file for the Symfony Messenger component and we don't need its full configuration. Replace it with the YAML file below:

framework:
    messenger:
        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
        # failure_transport: failed

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            external_messages:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    auto_setup: false
                    exchange:
                        name: messages
                        type: direct
                        default_publish_routing_key: from_external
                    queues:
                        messages:
                            binding_keys: [from_external]

Source code available directly: thecodest-co/microservices-in-symfony/blob/main/app1/config/packages/messenger.yaml

Symfony Messenger is used for synchronous and asynchronous communication in Symfony applications. It supports a variety of transports, or sources of truth of the transport layer. In our example, we use the AMQP extension that supports the RabbitMQ event queue system.

The above configuration defines a new transport named external_messages, which references the MESSENGER_TRANSPORT_DSN environment variable and defines direct listening on the messages channel in Message Bus. At this point, also edit the app1/.env file and add the appropriate transport address.

(...)
MESSENGER_TRANSPORT_DSN=amqp://user:password@rabbitmq:5672/%2f/messages
(...)

After preparing the application framework and configuring the libraries, it is time to implement the business logic. We know that our application must respond to the assignment of a job to a worker. We also know that assigning a job in the app2 system changes the status of the job. So let's create a model that mimics the status change and save it in the app1/Message/StatusUpdate.php path:

image

<?php
declare(strict_types=1);
namespace App\Message;

class StatusUpdate
{
    public function __construct(protected string $status){}

    public function getStatus(): string
    {
        return $this->status;
    }
}

Source code available directly: /thecodest-co/microservices-in-symfony/blob/main/app1/src/Message/StatusUpdate.php

We still need a class that will implement the business logic when our microservice receives the above event from the queue. So let's create a Message Handler in the app1/Handler/StatusUpdateHandler.php path:

image

<?php
declare(strict_types=1);
namespace App\Handler;

use App\Message\StatusUpdate;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class StatusUpdateHandler
{
    public function __construct(
        protected LoggerInterface $logger,
    ) {}

    public function __invoke(StatusUpdate $statusUpdate): void
    {
        $statusDescription = $statusUpdate->getStatus();

        $this->logger->warning('APP1: {STATUS_UPDATE} - '.$statusDescription);
        
        // the rest of business logic, i.e. sending email to user
        // $this->emailService->email()
    }
}

Source code available directly: /thecodest-co/microservices-in-symfony/blob/main/app1/src/Handler/StatusUpdateHandler.php

PHP attributes make things much easier and mean that in this particular case we don't have to worry about autowiring or service declaration. Our microservice for handling domain events is ready, it's time to get down to the second application.

Second Symfony microservice

We will take a look at the app2 directory and the second Symfony application. Our idea is to send a message to the queue when a worker is assigned a task in the system. So let's do a quick configuration of AMQP and get our second microservice to start publishing StatusUpdate events to the Message Bus.

Installing the libraries is exactly the same as for the first application.

cd ..
cd app2/
symfony composer req amqp ampq-messenger monolog

Let's make sure that the app2/.env file contains a valid DSN entry for RabbitMQ:

(...)
MESSENGER_TRANSPORT_DSN=amqp://user:password@rabbitmq:5672/%2f/messages
(...)

All that remains is to configure Symfony Messenger in the app2/config/packages/messenger.yaml file:

framework:
    messenger:
        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
        # failure_transport: failed

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'

        routing:
            # Route your messages to the transports
            'App\Message\StatusUpdate': async

Source code available directly: thecodest-co/microservices-in-symfony/blob/main/app2/config/packages/messenger.yaml

As you can see, this time the transport definition points directly to async and defines routing in the form of sending our StatusUpdate message to the configured DSN. This is the only area of configuration, all that is left is to create the logic and implementation layer of the AMQP queue. For this we will create the twin StatusUpdateHandler and StatusUpdate classes in app2.

image

<?php
declare(strict_types=1);

namespace App\Handler;

use App\Message\StatusUpdate;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class StatusUpdateHandler
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function __invoke(StatusUpdate $statusUpdate): void
    {
        $statusDescription = $statusUpdate->getStatus();

        $this->logger->warning('APP2: {STATUS_UPDATE} - '.$statusDescription);
        
        ## business logic, i.e. sending internal notification or queueing some other systems
    }
}

Source code available directly: /thecodest-co/microservices-in-symfony/blob/main/app2/src/Handler/StatusUpdateHandler.php

image

<?php
declare(strict_types=1);

namespace App\Message;

class StatusUpdate
{
    public function __construct(protected string $status){}

    public function getStatus(): string
    {
        return $this->status;
    }
}

Source code available directly: /thecodest-co/microservices-in-symfony/blob/main/app2/src/Message/StatusUpdate.php

Finally, all that has to be done is to create a way to send a message to the Message Bus. We will create a simple Symfony Command for this:

image

<?php
declare(strict_types=1);

namespace App\Command;

use App\Message\StatusUpdate;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsCommand(
    name: "app:send"
)]
class SendStatusCommand extends Command
{
    public function __construct(private readonly MessageBusInterface $messageBus, string $name = null)
    {
        parent::__construct($name);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $status = "Worker X assigned to Y";

        $this->messageBus->dispatch(
            message: new StatusUpdate($status)
        );

        return Command::SUCCESS;
    }
}

Source code available directly: /thecodest-co/microservices-in-symfony/blob/main/app2/src/Command/SendStatusCommand.php

Thanks to Dependency Injection we can use an instance of MessageBusInterface in our Command and send a StatusUpdate message via the dispatch() method to our queue. Additionally, we also use PHP Attributes here.

That's it - all that's left is to run our Docker Compose environment and see how our applications behave.

Running the environment and testing

With Docker Compose the containers with our two applications will be built and run as separate instances, the only middleware layer will be the rabbitmq container and our Message Bus implementation.

From the root directory of the project, let's run the following commands:

cd ../ # make sure you're in main directory
docker-compose up --build -d

This command can take some time, as it builts two separate containers with PHP 8.1 + AMQP and pulls RabbitMQ image. Be patient. After images are built you can fire our Command from app2 and send some messages on a queue.

docker exec -it app2 php bin/console app:send

You can do it as many times as you can. As long as there's no consumer your messages will not be processed. As soon as you fire up the app1 and consume all the messages they'll show on your screen.

docker exec -it app1 php bin/console messenger:consume -vv external_messages

image

The complete source code along with the README can be found in our public repository The Codest Github

Summary

Symfony with its libraries and tools allows for a fast and efficient approach to developing modern web applications. With a few commands and a few lines of code we are able to create a modern communication system between applications. Symfony, like PHP, is ideal for developing web applications and thanks to its ecosystem and ease of implementation this ecosystem achieves some of the best time-to-market indicators.

However, fast does not always mean good - in the example above we presented the simplest and fastest way of communication. What the more inquisitive will certainly notice that there is a lack of disconnection of domain events outside the application layer - in the current version they are duplicated, and there is no full support for Envelope, among others there is no Stamps. For those and others, I invite you to read Part II, where we'll cover the topic of unifying the domain structure of Symfony applications in a microservices environment, and discuss the second popular microservices communication method - this time synchronous, based on the REST API.

PHP development free consulting

Read more:

5 Mistakes You Should Avoid While Maintaining a Project in PHP

PHP Development. Symfony Console Component - Tips & Tricks

Why do we need Symfony Polyfill (... and why we shouldn't)