
Классические PHP-приложения живут по модели «запрос — ответ»: веб-сервер (как правило, Nginx) передаёт HTTP-запрос в менеджер процессов PHP-FPM, процесс поднимается, выполняет скрипт и затем завершается. Для REST API и большинства монолитных систем это привычно, надёжно и работает десятилетиями. Но в микросервисной архитектуре всё иначе: сервисы постоянно общаются между собой, а к задержке (latency) и соблюдению контрактов требования высоки. Индустриальным стандартом здесь стал gRPC — бинарный протокол на базе HTTP/2, предлагающий строгую типизацию сообщений (Protobuf), нативную потоковую передачу данных (streaming) и автоматическую кодогенерацию для десятков языков.
Долго gRPC в PHP считался экзотикой: расширение grpc сложно настроить, а вокруг долгоживущих процессов приходилось городить костыли. Например, в Go или Java gRPC — привычная часть экосистемы: официальные библиотеки, кодогенерация из .proto, серверы проектируют как long-running процессы. В PHP интерпретатор сам по себе не рассчитан на разбор бинарных HTTP/2-потоков и мультиплексирование на уровне сокетов. Поэтому сообщество разработало расширение grpc: его низкоуровневый C-код в теории должен давать максимальную скорость сериализации и работы с сетью. Но на практике связка PECL grpc + PHP-FPM упирается в модель жизненного цикла процесса. FPM обслуживает запрос и сбрасывает либо завершает worker. TCP-соединение HTTP/2 при этом рвётся, плохо переиспользуется, а мультиплексирование нескольких RPC в одном канале теряет смысл. На каждый RPC Symfony инициализируется заново: autoload, прогрев DI-контейнера, подключение к БД. Остаётся два пути: держать отдельный PHP-процесс, который слушает gRPC, или проксировать вызовы в FPM. Оба варианта усложняют инфраструктуру и плохо вписываются в привычную модель Symfony-приложения.
Выход — разделить транспорт и приложение:
RoadRunner (Go) принимает gRPC поверх HTTP/2 и передаёт задачи в PHP через Goridge. spiral/roadrunner-grpc превращает бинарные сообщения в вызовы методов.
Symfony по-прежнему отвечает за всё, к чему привыкли в обычном проекте: DI-контейнер, Doctrine, миграции, консоль.
В статье мы с нуля спроектируем и запустим gRPC-микросервис на стеке Symfony 8 + RoadRunner + PostgreSQL, реализуем два RPC-сервиса: HelloService — сервис для базовой проверки связи и демонстрации работы контракта. OrderService (Заказы) — бизнес-ориентированный сервис с методами создания заказа и получения списка. Здесь мы настроим полноценную работу с базой данных через Doctrine ORM и валидацию данных. А тестировать готовое API и отправлять бинарные запросы мы будем с помощью консольной утилиты grpcurl.
При написании статьи мы сделали репозиторий на GitHub, который можно скопировать себе и воспроизвести примеры на практике.
Почему gRPC — стандарт для межсервисного взаимодействия
Перед тем как перейти к практике, подробно рассмотрим, зачем gRPC в микросервисной архитектуре.
Для внутреннего взаимодействия между сервисами gRPC обычно выигрывает у REST по скорости сериализации, строгости контракта и возможностям HTTP/2:
Критерий | REST (JSON) | gRPC (Protobuf) |
|---|---|---|
Формат | Текст | Бинарный, компактный |
Контракт | OpenAPI (часто постфактум) | .proto |
Транспорт | HTTP/1.1 | HTTP/2, мультиплексирование |
Стриминг | SSE, WebSocket | Unary, server/client/bidi |
Кодогенерация | Опциональна | Часть toolchain |
Публичный API для браузеров по-прежнему чаще делают на REST или GraphQL. gRPC — это про service-to-service: каталоги, биллинг, оркестрация, всё, что живёт за gateway.
Так почему RoadRunner, а не PHP-FPM
FPM заточен под короткий HTTP-запрос: процесс поднялся, отработал, ушёл в пул или завершился. Для gRPC это неудобно — HTTP/2-канал должен жить долго, а bootstrap Symfony на каждый RPC слишком дорог по ресурсам и времени.
Сервер приложения RoadRunner реализует транспорт на стороне Go: держит пул PHP worker'ов, принимает gRPC и передаёт вызовы через Goridge (pipes или TCP). PHP видит уже готовый метод с типизированными request/response. На следующем рисунке показаны схемы работы RoadRunner и PHP-FPM:

Быстрый старт
Если вы скопировали демонстрационный проект и в системе установлен Docker, то для запуска достаточно выполнить:
docker compose up -d --build
Сразу можно проверить, что всё поднялось:
grpcurl -plaintext \ -import-path proto -proto hello/v1/hello.proto \ -d '{"name":"Alex"}' \ localhost:9001 hello.v1.HelloService/SayHello
Ожидаемый ответ:
{"message": "Hello, Alex!"}
Давайте разберём, как это работает. Клиент шлёт запрос protobuf по HTTP/2 → RoadRunner принимает gRPC → PHP worker получает вызов через Goridge → Symfony-обработчик возвращает ответ. Взаимодействие показано на схеме ниже:

В таблице список основных директорий проекта с кратким описанием.
Путь | Назначение |
|---|---|
proto/ | Публичный контракт, версии API |
generated/ | DTO и интерфейсы — не править вручную |
src/Grpc/ | Реализация RPC |
src/Entity/, migrations/ | Модель и схема БД |
public/grpc-worker.php | Склейка RoadRunner ↔ Symfony |
.rr.yaml | Порты, proto, команда worker |
docker/php-roadrunner/entrypoint.sh | protoc, composer, миграции при старте |
Hello-сервис: от контракта до RPC
Hello — минимальный пример RPC-сервиса.
1. Контракт
Для начала нужно создать файл контракта proto/hello/v1/hello.proto. Ниже показано содержимое этого файла:
syntax = "proto3"; package hello.v1; option php_metadata_namespace ="Generated\\GRPC\\Hello\\V1\\GPBMetadata"; option php_namespace = "Generated\\GRPC\\Hello\\V1"; message SayHelloRequest { string name = 1; } message SayHelloResponse { string message = 1; } service HelloService { rpc SayHello(SayHelloRequest) returns (SayHelloResponse); }
Разберём этот пример:
Элемент | Зачем |
|---|---|
| Версия языка Protobuf; для новых API — всегда proto3 |
| Логическое имя API и версия; в gRPC сервис будет называться hello.v1.HelloService |
| Путь, куда protoc положит PHP-классы; должен совпадать с PSR-4 autoload |
| Структура запроса/ответа; поле name = 1 — имя и номер (не порядок в файле) |
| Публичные методы; здесь unary — один запрос, один ответ |
Правила совместимости: номера полей (1, 2, …) никогда не переиспользуют и не меняют тип. Новое поле — новый номер. Удалённое поле помечают reserved. Breaking change → новый пакет hello.v2, старый hello.v1 оставляют для клиентов.
Полное имя в gRPC: hello.v1.HelloService, метод: SayHello. Именно так вызывает grpcurl: hello.v1.HelloService/SayHello.
2. Генерация PHP
Из .proto можно сгенерировать типизированные DTO и интерфейс сервиса (этот код не создаётся и не редактируется вручную).
mkdir -p generated protoc \ --proto_path=proto \ --php_out=generated \ --plugin=protoc-gen-php-grpc=/usr/local/bin/protoc-gen-php-grpc \ --php-grpc_out=generated \ hello/v1/hello.proto
Флаг | Назначение |
|---|---|
--proto_path=proto | Корень для import и путей к файлам |
--php_out=generated | Сообщения (SayHelloRequest, SayHelloResponse) |
--php-grpc_out=generated | gRPC-интерфейс (HelloServiceInterface) |
--plugin=protoc-gen-php-grpc=... | Плагин RoadRunner для PHP gRPC |
Структура после генерации кода:
generated/ └── Generated/ └── GRPC/ └── Hello/ └── V1/ ├── HelloServiceInterface.php ├── SayHelloRequest.php ├── SayHelloResponse.php └── GPBMetadata/ └── Hello.php
Обратите внимание на вложенность generated/Generated/ — это особенность вывода protoc, в composer.json секции Autoload должно быть прописано следующее: "Generated\\": "generated/Generated/".
Сгенерированный интерфейс — контракт, который обязана выполнить реализация:
interface HelloServiceInterface extends GRPC\ServiceInterface { public const NAME = "hello.v1.HelloService"; public function SayHello(ContextInterface $ctx, SayHelloRequest $in): SayHelloResponse; }
Константа NAME совпадает с полным именем сервиса в gRPC — RoadRunner использует её при маршрутизации вызовов. Файлы в generated/ не редактируют вручную: при изменении .proto их нужно перегенерировать.
3. Обработчик в Symfony
gRPC-сервис в Symfony — обычный PHP-класс, реализующий сгенерированный интерфейс. HTTP-контроллеры, роуты и framework.router для этого не нужны.
Создадим src/Grpc/HelloService.php:
<?php declare(strict_types=1); namespace App\Grpc; use Generated\GRPC\Hello\V1\HelloServiceInterface; use Generated\GRPC\Hello\V1\SayHelloRequest; use Generated\GRPC\Hello\V1\SayHelloResponse; use Spiral\RoadRunner\GRPC\ContextInterface; final class HelloService implements HelloServiceInterface { public function SayHello(ContextInterface $ctx, SayHelloRequest $in): SayHelloResponse { $name = trim($in->getName()) !== '' ? $in->getName() : 'World'; return new SayHelloResponse()->setMessage(sprintf('Hello, %s!', $name)); } }
Что важно в сигнатуре:
ContextInterface $ctx — metadata входящего вызова (аналог HTTP-заголовков): trace-id, JWT, deadline. В Hello не используем, но параметр обязателен по интерфейсу.
SayHelloRequest $in — уже декодированный protobuf; доступ к полям читаем через методы getName(), hasName().
Возврат SayHelloResponse — объект ответа; у protobuf-классов методы set* возвращают $this (fluent).
Затем нужно зарегистрировать сервис в DI. По умолчанию сервисы Symfony приватные; worker получает их через $container->get(), поэтому помечаем наш сервис публичным:
services: App: resource: '../src/' App\Grpc\HelloService: public: true
На этом этапе Hello ещё не доступен снаружи — класс есть, но RoadRunner о нём не знает. Связку «интерфейс → реализация → gRPC-порт» делаем в worker (следующий раздел).
Worker и RoadRunner
public/grpc-worker.php — единственная точка входа PHP: (new Dotenv())->bootEnv(dirname(__DIR__) . '/.env'); $kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool) ($_SERVER['APP_DEBUG'] ?? true)); $kernel->boot(); $container = $kernel->getContainer(); $server = new Server(); $server->registerService(HelloServiceInterface::class, $container->get(HelloService::class)); $server->registerService(OrderServiceInterface::class, $container->get(OrderService::class)); $server->serve(Worker::create());
В spiral/roadrunner-grpc 3.x registerService принимает FQCN интерфейса и экземпляр реализации. RoadRunner не связывает .proto с PHP автоматически — каждую пару нужно зарегистрировать явно.
.rr.yaml:
version: "3" server: command: "php public/grpc-worker.php" relay: pipes grpc: listen: "tcp://0.0.0.0:9001" proto: - "proto/hello/v1/hello.proto" - "proto/order/v1/order.proto" rpc: listen: "tcp://127.0.0.1:6001" server: command: "php public/grpc-worker.php" relay: pipes grpc: listen: "tcp://0.0.0.0:9001" proto: - "proto/hello/v1/hello.proto" - "proto/order/v1/order.proto" rpc: listen: "tcp://127.0.0.1:6001"
Ключевое: server.command запускает worker, grpc.listen — адрес gRPC, grpc.proto — файлы для reflection/валидации на стороне RR.
Сервис заказов
Давайте сделаем практический пример и превратим абстрактный Hello-воркер во что-то более прикладное. Мы создадим сервис управления заказами — OrderService. Он будет поддерживать два базовых метода: CreateOrder (создание заказа) и ListOrders (получение списка).
Архитектура и логика работы
База данных: Сущность Order отображается на таблицу orders. Для создания таблицы используем Doctrine-миграцию Version20260530120000.
Метод CreateOrder:
Принимает данные, валидирует входящие поля через компонент symfony/validator.
В случае успеха сохраняет заказ в БД со статусом new.
Возвращает сгенерированный id и текущий статус.
Метод ListOrders:
Реализует постраничную навигацию с помощью параметров limit и offset.
Возвращает массив объектов Order и общее количество записей (total) для пагинации.
Обработка ошибок: Если валидация не пройдена, сервис выбрасывает GRPCException со стандартным gRPC-статусом INVALID_ARGUMENT, передавая детали ошибки клиенту.
Проверка работы через grpcurl
После генерации DTO и запуска RoadRunner мы можем протестировать методы с помощью утилиты grpcurl.
1. Создание нового заказа:
grpcurl -plaintext -import-path proto -proto order/v1/order.proto \ -d '{"customer_name":"Alex","product":"Widget","quantity":2}' \ localhost:9001 order.v1.OrderService/CreateOrder
# Ожидаемый ответ: # { "id": "1", "status": "new" }
2. Получение списка заказов:
grpcurl -plaintext -import-path proto -proto order/v1/order.proto \ -d '{"limit":10,"offset":0}' \ localhost:9001 order.v1.OrderService/ListOrders
Заключение
Связка Symfony + RoadRunner + Protobuf доказывает: современный PHP полностью готов к высоким нагрузкам и эффективной работе в микросервисной архитектуре. В рамках одного проекта нам удалось развернуть производительный gRPC-сервер, сохранив привычный DX (Developer Experience). При этом каждый компонент стека четко изолирует свою зону ответственности:
Protobuf гарантирует строгую и кроссплатформенную спецификацию контрактов данных.
Symfony обеспечивает гибкое управление зависимостями (DI) и привычную реализацию бизнес-логики.
RoadRunner берет на себя сетевой уровень (HTTP/2), мультиплексирование запросов и эффективное управление пулом долгоживущих (long-running) воркеров.
Однако написанный нами прототип — это лишь демонстрация механики взаимодействия компонентов, а не готовое production-решение. Чтобы превратить этот каркас в надежный боевой сервис, необходимо учесть специфику long-running среды.
При переходе от прототипа к enterprise-эксплуатации вам в обязательном порядке придется решить следующие задачи:
Менеджмент памяти (Memory Leaks): В режиме долгоживущих процессов любая утечка памяти (например, накопление данных в статических переменных или неотключаемый логгер в контейнере) приведет к падению воркера. Необходимо настроить директиву max_jobs или лимиты по памяти в roadrunner.yaml для автоматического рестарта воркеров.
Состояние ресурсов (Stateful Connections): Соединения с базой данных (Doctrine) или брокерами сообщений могут отваливаться по таймауту. Нужно реализовать проверку активности соединений (ping/reconnect) перед каждым запросом или сбрасывайте EntityManager после обработки задачи.
Обработка системных ошибок: Ошибки уровня сети (broken pipes, таймауты сокетов) не должны приводить к падению всего мастера. Нужно настроить корректный перехват исключений и трансляцию их в понятные gRPC-статусы (INTERNAL, UNAVAILABLE).
Observability (Мониторинг): Интегрируйте сбор метрик. RoadRunner из коробки умеет отдавать метрики для Prometheus (плагин metrics).
Безопасность (TLS/mTLS): Внутри периметра микросервисов общение должно быть защищено. Нужно настроить шифрование трафика (gRPC over TLS) на уровне конфигурации RoadRunner или делегируйте это на сторону Service Mesh (например, Istio).
Ссылки
Автор текста — Александр Донцов
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.
