DEV Community

Cover image for PHP Symfony Microservice with gRPC: A Practical Guide
Albert Colom
Albert Colom

Posted on • Originally published at Medium on

PHP Symfony Microservice with gRPC: A Practical Guide

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let’s go! you can try to run your first Symfony App via Roadrunner.

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Now you can open url in your browser: http://127.0.0.1:8080/ and show a default Symfony welcome page:

Symfony 8 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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Well done! We now have our first gRPC server running with the Symfony container.

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

docker compose output

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"]
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Generate gRPC files

Now we have everything we need to generate our files with this simple command.

 docker compose run --rm proto-gen
Enter fullscreen mode Exit fullscreen mode

proto-gen output

This will generate the files required by our application.

Generated files

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"
        }
    },

...
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
} 
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
...
Enter fullscreen mode Exit fullscreen mode

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));

...
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

docker compose output

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
Enter fullscreen mode Exit fullscreen mode

GRPCurl output

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)