A Step-by-Step Guide to Building High-Performance Hexagonal Microservices in PHP with Symfony and gRPC.
Why use gRPC?
gRPC is a modern, high-performance communication framework designed to enable microservices to communicate with each other more efficiently than traditional methods. Unlike standard REST APIs, which send data in the form of bulky, human-readable text (JSON), gRPC converts data into a compact binary format using Protocol Buffers. This results in much smaller messages and significantly faster processing, which is vital for high-traffic systems.
gRPC runs on HTTP/2 , which enables multiplexing — sending multiple requests over a single connection simultaneously — and supports real-time, bidirectional streaming. This means that your services can maintain a constant, two-way flow of information, rather than just “requesting and waiting”.
You can find more information on the official website: gRPC
Previous requirements
- Docker with Docker Compose or any other container-compatible alternative
- Composer
Install Symfony + Docker + Roadrunner
Let’s see how to install a basic version of Symfony running PHP CLI and Roadrunner via Docker.
Create a Symfony project
In this tutorial create a Symfony project from scratch, feel free to use an existing Symfony project.
composer create-project symfony/skeleton symfony-grpc
Install roadrunner-bundle
To facilitate the integration of Roadrunner with Symfony, we will use the baldinof/roadrunner-bundle vendor. You can find more information on the project’s GitHub page.
composer require baldinof/roadrunner-bundle
NOTE: Usually if you used the Recipe, the roadrunner-bundle create an example rr.yaml and rr.dev.yaml on your root project. If not create you can copy manually from the vendor:
cp vendor/baldinof/roadrunner-bundle/.rr.dev.yaml .
cp vendor/baldinof/roadrunner-bundle/.rr.yaml .
Create a simple Docker Image with PHP and Roadrunner
We created a basic Docker image containing PHP CLI and the RoadRunner binary. Including the RoadRunner binary in our PHP image means that we are no longer dependent on external services such as Apache or Nginx.
#resources/docker/php-roadrunner/Dockerfile
FROM ghcr.io/roadrunner-server/roadrunner:2025.1.6 AS roadrunner
FROM php:8.4-cli-alpine
COPY --from=roadrunner /usr/bin/rr /usr/local/bin/rr
EXPOSE 8080 9000
WORKDIR /var/www/app
Create Docker compose
To make the project easier to execute, let’s create a simple Docker Compose file.
#docker-compose.yml
services:
php-rr:
build:
context: resources/docker/php-roadrunner/
container_name: php-symfony-grpc
ports:
- "8080:8080"
- "6001:6001"
- "9001:9001"
volumes:
- .:/var/www/app
command: rr serve -c .rr.yaml
Let’s go! you can try to run your first Symfony App via Roadrunner.
docker compose up -d
Now you can open url in your browser: http://127.0.0.1:8080/ and show a default Symfony welcome page:
Congrats! you run your first Symfony Application via Roadrunner.
Implement gRPC Server con RoadRunner
We have RoadRunner running with an HTTP server, so now is the time to learn how to implement our gRPC server with Symfony.
Install gRPC Radrunner dependences
First, we installed the Roadrunner gRPC server and the Protocol Buffers library.
composer require google/protobuf spiral/roadrunner-grpc
Create gRPC Worker
We create the gRPC worker using the Symfony kernel and container.
<?php
# public/grpc-worker.php
use App\Kernel;
use Spiral\RoadRunner\GRPC\Server;
use Spiral\RoadRunner\Worker;
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'prod', (bool) ($_SERVER['APP_DEBUG'] ?? false));
$kernel->boot();
$container = $kernel->getContainer();
$worker = Worker::create();
$server = new Server();
try {
$server->serve($worker);
} catch (Throwable $e) {
error_log($e->getMessage());
exit(1);
}
exit(0);
Update RoadRunner server config
The next step is to update the RoadRunner configuration filerr.yaml with the gRPC configuration.
# rr.yaml
version: "3"
server:
command: "php public/grpc-worker.php"
relay: pipes
grpc:
listen: "tcp://0.0.0.0:9001"
logs:
mode: production
channels:
http:
level: debug
server:
level: info
mode: raw
metrics:
level: error
Well done! We now have our first gRPC server running with the Symfony container.
docker compose up -d
Generating gRPC codes
gRPC requires code generation because a .proto file only defines a contract, it is not executable code. The generated code creates typed classes and methods, enabling your language to safely send and receive Protobuf messages.
Create a Docker Image to generate gRPC files
The next step is to create a Dockerfile with an entrypoint that will generate the necessary files using protoc-gen-grpc .
#resources/docker/php-roadrunner/Dockerfile
FROM alpine:3.23
RUN apk add --no-cache protobuf wget && \
ARCH=$(uname -m) && \
if ["$ARCH" = "aarch64"]; then ARCH="arm64"; else ARCH="amd64"; fi && \
cd /tmp && \
wget https://github.com/roadrunner-server/roadrunner/releases/download/v2025.1.6/protoc-gen-php-grpc-2025.1.6-linux-${ARCH}.tar.gz && \
tar -xzf protoc-gen-php-grpc-2025.1.6-linux-${ARCH}.tar.gz && \
cd protoc-gen-php-grpc-2025.1.6-linux-${ARCH} && \
mv protoc-gen-php-grpc /usr/local/bin/ && \
chmod +x /usr/local/bin/protoc-gen-php-grpc && \
rm -rf /tmp/*
COPY entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
NOTE : You can specify different paths for the prototype files and the location where the generated files should be stored.
default : PROTO_PATH=/app/proto and OUTPUT_PATH=/app/generated
#!/bin/sh
#resources/docker/php-roadrunner/entrypoint.sh
set -e
PROTO_PATH="${PROTO_PATH:-/app/proto}"
OUTPUT_PATH="${OUTPUT_PATH:-/app/generated}"
echo "[INFO] Generating gRPC files | Proto path: $PROTO_PATH | Output: $OUTPUT_PATH"
if [! -d "$PROTO_PATH"]; then
echo "[ERROR] Proto path directory not found: $PROTO_PATH"
exit 1
fi
PROTO_FILES=$(find "$PROTO_PATH" -name "*.proto")
if [-z "$PROTO_FILES"]; then
echo "[ERROR] No .proto files found in $PROTO_PATH"
exit 1
fi
echo -n "[INFO] Proto files: " && echo "$PROTO_FILES" | xargs -n 1 basename | paste -sd ',' - | sed 's/,/, /g'
mkdir -p "$OUTPUT_PATH"
protoc --proto_path="$PROTO_PATH" \
--php_out="$OUTPUT_PATH" \
--php-grpc_out="$OUTPUT_PATH" \
$PROTO_FILES
echo "[INFO] gRPC files generated successfully"
Update Docker Compose
Add the new Docker to your docker file proto-gen .
services:
proto-gen:
build:
context: resources/docker/protobuf/
volumes:
- ./proto:/app/proto
- ./generated:/app/generated
php-rr:
build:
context: resources/docker/php-roadrunner/
container_name: php-symfony-grpc
ports:
- "8080:8080"
- "6001:6001"
- "9001:9001"
volumes:
- .:/var/www/app
command: rr serve -c .rr.yaml
Create the proto file
A .proto file is a schema that defines gRPC messages and services.
It is language-neutral and is used to generate client and server code in different programming languages Protocol Buffers (Protobuf)
// proto/user.proto
syntax = "proto3";
package user.v1;
option php_namespace = "GRPC\\User\\V1";
option php_metadata_namespace = "GRPC\\GPBMetadata";
service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
string id = 1;
string email = 2;
}
Generate gRPC files
Now we have everything we need to generate our files with this simple command.
docker compose run --rm proto-gen
This will generate the files required by our application.
Add the generated files to the autoload.
Add the path of the generated files to the autoload section of the composer.json
...
"autoload": {
"psr-4": {
"App\\": "src/",
"GRPC\\": "generated/GRPC"
}
},
...
Sample User code
This section provides a concise code example of a PHP Symfony microservice that has been structured with hexagonal architecture and domain-driven design (DDD). The implementation of gRPC is as an infrastructure service that is exclusively responsible for external communication. Symfony is responsible for application wiring, and the domain remains fully isolated from technical concerns.
Domain
The User class represents the domain entity and encapsulates only the business logic for validating IDs and emails, independently of Symfony or gRPC.
<?php
# src/User/Domain/User.php
declare(strict_types=1);
namespace App\User\Domain;
use InvalidArgumentException;
final readonly class User
{
public function __construct(
public string $id,
public string $email
)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
}
public function equals(User $other): bool
{
return $this->id === $other->id && $this->email === $other->email;
}
}
This UserRepositoryInterface defines the contract for the domain’s user repository.
<?php
# src/User/Domain/UserRepositoryInterface.php
namespace App\User\Domain;
interface UserRepositoryInterface
{
public function getById(string $id): ?User;
}
Application
The GetUserQuery class is an application-layer query object that carries the user ID and encapsulates the request to retrieve a user. It does not contain any business logic.
<?php
# src/User/Application/Query/GetUserQuery.php
declare(strict_types=1);
namespace App\User\Application\Query;
final readonly class GetUserQuery
{
public function __construct(public string $id)
{
}
}
The GetUserQueryHandler class represents the application-layer service that handles GetUserQuery.
<?php
# src/User/Application/Query/GetUserQueryHandler.php
declare(strict_types=1);
namespace App\User\Application\Query;
use App\User\Domain\User;
use App\User\Domain\UserRepositoryInterface;
final readonly class GetUserQueryHandler
{
public function __construct(private UserRepositoryInterface $repository)
{
}
public function __invoke(GetUserQuery $query): ?User
{
return $this->repository->getById($query->id);
}
}
Infrastructure
The InMemoryUserRepository class is an implementation of the UserRepositoryInterface in the infrastructure layer.
<?php
# src/User/Infrastructure/Persistence/InMemoryUserRepository.php
declare(strict_types=1);
namespace App\User\Infrastructure\Persistence;
use App\User\Domain\User;
use App\User\Domain\UserRepositoryInterface;
final class InMemoryUserRepository implements UserRepositoryInterface
{
/** @var array<string, User> */
private array $users;
public function __construct()
{
$this->users = [
'f5fb962d-60f6-4096-8bd6-f717249d46fa' => new User('f5fb962d-60f6-4096-8bd6-f717249d46fa', 'foo1@example.com'),
'f7eb16c3-5b92-480b-8d58-e6a3d3b293a7' => new User('f7eb16c3-5b92-480b-8d58-e6a3d3b293a7', 'foo2@example.com'),
'923ecb26-a08b-41e2-861e-8f9449addc93' => new User('923ecb26-a08b-41e2-861e-8f9449addc93', 'foo3@example.com'),
'20aa442b-5031-4db6-be04-df15ca9881d3' => new User('20aa442b-5031-4db6-be04-df15ca9881d3', 'foo4@example.com'),
'464d60fb-3823-4e9d-8413-4e0b1c68c77d' => new User('464d60fb-3823-4e9d-8413-4e0b1c68c77d', 'foo5@example.com'),
];
}
public function getById(string $id): ?User
{
return $this->users[$id] ?? null;
}
}
The UserService class is the gRPC adapter in the infrastructure layer. It receives gRPC requests, calls the application layer handler to fetch the User entity, and maps the result to a gRPC response, keeping the domain logic completely decoupled from transport concerns.
<?php
declare(strict_types=1);
# src/User/Infrastructure/GRPC/UserService.php
namespace App\User\Infrastructure\GRPC;
use App\User\Application\Query\GetUserQuery;
use App\User\Application\Query\GetUserQueryHandler;
use GRPC\User\V1\GetUserRequest;
use GRPC\User\V1\GetUserResponse;
use GRPC\User\V1\UserServiceInterface;
use Spiral\RoadRunner\GRPC\ContextInterface;
use Spiral\RoadRunner\GRPC\Exception\GRPCException;
use Spiral\RoadRunner\GRPC\StatusCode;
final readonly class UserService implements UserServiceInterface
{
public function __construct(private GetUserQueryHandler $handler)
{
}
public function GetUser(ContextInterface $ctx, GetUserRequest $in): GetUserResponse
{
$user = $this->handler->__invoke(new GetUserQuery($in->getId()));
if ($user === null) {
throw new GRPCException(
sprintf('User with id "%s" not found', $in->getId()),
StatusCode::NOT_FOUND
);
}
$response = new GetUserResponse();
$response->setId($user->id);
$response->setEmail($user->email);
return $response;
}
}
Updating gRPC Configuration
We add the path of theproto/user.proto file to the RoadRunner configuration file rr.yaml.
# rr.yaml
...
grpc:
listen: "tcp://0.0.0.0:9001"
proto:
- "proto/user.proto"
...
You need to define and register the gRPC service manually public/grppc-worker.php because gRPC in PHP (via RoadRunner) doesn’t automatically discover your service implementations.
# public/grpc-worker.php
...
$worker = Worker::create();
$server = new Server();
$server->registerService(UserServiceInterface::class, $container->get(UserService::class));
...
Update services
Setting public: true for UserService allows you to manually retrieve it from the container in your gRPC worker script:
# config/services.yaml
...
App\:
resource: '../src/'
App\User\Infrastructure\GRPC\UserService:
public: true
...
Run the project
Finally, it’s time to test all our code!
Start the server
We use Docker Compose to generate the files and start our gRPC server.
docker compose up -d
Testing the gRPC User Service
We use our preferred client to test that our service is functioning correctly. In my case, I will use GRPCurl.
grpcurl -plaintext \
-proto proto/user.proto \
-d '{"id":"f5fb962d-60f6-4096-8bd6-f717249d46fa"}' \
localhost:9001 \
user.v1.UserService/GetUse
Congratulations! You now have your first gRPC microservice up and running with Symfony.
Repository
The code that the tutorial is based on can be found in the public repository.
GitHub - albertcolom/symfony-grpc
NOTE: The tutorial uses the minimum required version of the code. The complete project can be found in the repository.
Original published at: albertcolom.com






Top comments (0)