Очередное решение для разработки API и не только
Возможно, вы не слышали о Sunrise экосистеме, так или иначе, сегодня я поделюсь с вами опытом разработки API, используя Sunrise решения и не только.
В 2018 году передо мной встал выбор маршрутизатора с четкими требованиями:
Легковесность
В основе PSR-стандарты
Поддержка аннотаций
Интеграция со Swagger
На тот момент подходящего для себя решения я не нашел, ввиду чего появился Sunrise Router. Вторая версия, выпущенная год спустя, закрыла основные потребности, но мне было нужно нечто большее. Под влиянием Spring и других фреймворков я стремился к комплексному решению, включающему:
Иммутабельную архитектуру
Самодокументируемое API
Получение DTO и других атрибутов запроса сразу в параметрах экшена
Отправку View-объектов сразу из экшена
Минимальную зависимость от Request/Response
Обработку ошибок и локализацию из коробки
Гибкость и расширяемость

Цель была не просто в создании удобного маршрутизатора, а в сбалансированном решении, где, в том числе, документация является естественным продолжением кода, а не отдельным этапом работы.
Однажды устроился на работу, и мне досталась пачка «интересных» задач: нужно было вручную синхронизировать OpenAPI, оформленный в комментариях к коду, с самим кодом в десятке сервисах. Звучит как начало анекдота, но тогда мне было не до смеха…
Недавно вышла третья версия Sunrise Router, реализующая все вышеописанное. Это проверенное в продакшене решение, которое стабильно поддерживается и развивается все это время.
Демонстрационный контроллер
namespace App\Controller\Api;
use App\Contract\Service\PostServiceInterface;
use App\Dictionary\MediaType;
use App\Dto\Post\PostCreateRequest;
use App\Entity\Post;
use App\View\Post\PostView;
use Sunrise\Bridge\Doctrine\Integration\Router\Annotation\RequestedEntity;
use Sunrise\Http\Router\Annotation\Consumes;
use Sunrise\Http\Router\Annotation\EncodableResponse;
use Sunrise\Http\Router\Annotation\GetApiRoute;
use Sunrise\Http\Router\Annotation\PostApiRoute;
use Sunrise\Http\Router\Annotation\Produces;
use Sunrise\Http\Router\Annotation\RequestBody;
final readonly class PostController
{
public function __construct(
private PostServiceInterface $postService,
) {
}
#[PostApiRoute('api.posts.create', '/api/posts')]
#[Consumes(MediaType::JSON)]
public function create(#[RequestBody] PostCreateRequest $createRequest): void
{
$this->postService->createPost($createRequest);
}
#[GetApiRoute('api.posts.read', '/api/posts/{id<\d+>}')]
#[Produces(MediaType::JSON)]
#[EncodableResponse]
public function read(#[RequestedEntity] Post $post): PostView
{
return PostView::create($post);
}
}

Маршрутизатора недостаточно. Любой API-проект требует:
Гибкой конфигурации
DI
CLI
Так появился Awesome Skeleton, который закрывает эти задачи и предлагается в качестве готового базового решения, где за конфигурацию и DI отвечает PHP-DI, а за CLI — Symfony Сonsole.
Обзор некоторых возможностей
Следование PSR-стандартам
Хотя в третьей версии сохранена полная совместимость с PSR-15, следование этому стандарту в контексте контроллеров при разработке API не рекомендуется. Причина проста: система не сможет понять, что ожидается на входе и что будет на выходе, из-за чего разработчику придется смещать фокус с логики на документацию, что противоречит изначальной концепции.
В то же время, для промежуточного ПО, наоборот, рекомендуется придерживаться PSR-15, чтобы обеспечить максимальную совместимость и гибкость.
Иммутабельная архитектура
Переходя к практике, я много лет использую RoadRunner для PHP-проектов. Давайте подключим его к нашему проекту.
composer require spiral/roadrunner-cli spiral/roadrunner-http
php vendor/bin/rr get-binary
Определим команду-воркер, которая будет точкой входа:
declare(strict_types=1);
namespace App\Command;
use Override;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Spiral\RoadRunner\Http\PSR7Worker as HttpWorker;
use Spiral\RoadRunner\Worker;
use Sunrise\Http\Router\RouterInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('rr:work')]
final class RoadRunnerWorker extends Command
{
public function __construct(
private readonly RouterInterface $router,
private readonly ServerRequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
private readonly UploadedFileFactoryInterface $uploadsFactory,
) {
parent::__construct();
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$worker = new HttpWorker(Worker::create(), $this->requestFactory, $this->streamFactory, $this->uploadsFactory);
while ($request = $worker->waitRequest()) {
$worker->respond($this->router->handle($request));
}
return self::SUCCESS;
}
}
Добавим ее в конфигурацию config/definitions/app.php
:
use App\Command\RoadRunnerWorker;
use function DI\autowire;
return [
'app.commands' => add([
autowire(RoadRunnerWorker::class),
]),
];
Создадим .rr.yaml
в корне проекта:
# https://github.com/roadrunner-server/roadrunner/blob/master/.rr.yaml
version: '3'
server:
command: php bin/app rr:work
http:
address: 127.0.0.1:8000
pool:
num_workers: ${ROADRUNNER_NUM_WORKERS}
max_jobs: ${ROADRUNNER_MAX_JOBS}
Добавим переменные в .env
и .env.dist
:
# How many worker processes will be started.
# Zero (or nothing) means the number of logical CPUs.
ROADRUNNER_NUM_WORKERS=1
# Maximal count of worker executions.
# Zero (or nothing) means no limit.
ROADRUNNER_MAX_JOBS=1
Запуск:
./rr serve --dotenv .env
Если все работает и RoadRunner вас устраивает, можно удалить public/index.php
, так как он больше не нужен.
Самодокументируемое API
В самом начале мы рассмотрели пример контроллера, который не заработает сразу — к нему мы вернемся позже. А сейчас давайте создадим контроллер для авторизации. Он не будет содержать логики и носит демонстрационный характер, хотя может послужить точкой старта.
Сначала опишем DTO, чтобы задать ожидаемую структуру данных от клиента:
declare(strict_types=1);
namespace App\Dto\Auth;
use SensitiveParameter;
final readonly class SignInRequest
{
public function __construct(
#[SensitiveParameter]
public string $email,
#[SensitiveParameter]
public string $password,
) {
}
}
Теперь опишем сам контроллер:
declare(strict_types=1);
namespace App\Controller\Api;
use App\Dictionary\MediaType;
use App\Dto\Auth\SignInRequest;
use Sunrise\Http\Router\Annotation\Consumes;
use Sunrise\Http\Router\Annotation\PostApiRoute;
use Sunrise\Http\Router\Annotation\RequestBody;
final readonly class AuthController
{
#[PostApiRoute('api.auth.signIn', '/api/auth/sign-in')]
#[Consumes(MediaType::JSON)]
public function signIn(
#[RequestBody]
SignInRequest $signInRequest,
): void {
}
}
Далее можно сгенерировать OpenAPI, открыть его в Swagger и протестировать метод:
php bin/app router:openapi:build-document
В результате по адресу http://localhost:8000/swagger.html мы должны увидеть что-то вроде этого:

Больше аннотаций и примеров можно найти в документации.
Обработка ошибок
По умолчанию ошибки отлавливаются и логируются. Клиент получает ошибку в формате JSON, адаптированном под его язык, в зависимости от типа ошибки. Если вы заглянете в схемы OpenAPI (Swagger), то увидите объекты-представления Error и Violation — именно они используются для отображения ошибок.
Если вам необходимо отдавать ошибки в другом формате, это легко настраивается через параметр router.error_handling_middleware.produced_media_types.
А если вы разрабатываете не только backend, но и frontend на основе скелетона, возможно, вам потребуется отображать ошибки в HTML. Это тоже легко реализовать: достаточно добавить промежуточное ПО перед ErrorHandlingMiddleware
, проверить, запрашивает ли клиент HTML-ответ, и если да — вернуть HTML. В противном случае запрос просто передается следующему middleware.
Валидация DTO
Здесь все просто: чтобы DTO автоматически валидировались, требуется интеграция с Symfony Validator. Это делается очень легко — сначала установим его:
composer require symfony/validator
Далее интегрируем в наш проект. Для этого создадим файл config/definitions/validator.php
и приведем его к следующему виду:
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Symfony\Component\Validator\ContainerConstraintValidatorFactory;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\ValidatorBuilder;
return [
ValidatorInterface::class => static fn(ContainerInterface $container): ValidatorInterface => (new ValidatorBuilder())
->enableAttributeMapping()
->setConstraintValidatorFactory(new ContainerConstraintValidatorFactory($container))
->getValidator(),
];
Теперь, на примере нашего SignInController
, доработаем SignInRequest
, добавив валидацию:
declare(strict_types=1);
namespace App\Dto\Auth;
use SensitiveParameter;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class SignInRequest
{
public function __construct(
#[Assert\NotBlank]
#[SensitiveParameter]
public string $email,
#[Assert\NotBlank]
#[SensitiveParameter]
public string $password,
) {
}
}
Интеграция с Doctrine
Интеграция с Doctrine дает ряд преимуществ, упрощающих работу с сущностями и повышающих удобство разработки.
Резолвинг сущности в свойстве DTO
Хотя смешивание слоев домена и приложения в одной точке может показаться спорным, на практике это весьма полезная возможность. Например, клиент отправляет запрос на создание товара, передавая categoryId
. Вместо того чтобы просто принять этот идентификатор, система автоматически проверит существование категории, установит её в свойство DTO или вернет ошибку, если категория отсутствует. В результате контроллер получает не просто «сырые» данные от клиента, а уже частично обработанную и валидированную структуру.
Резолвинг сущности в параметре экшена
Иногда удобно сразу получать сущность в контроллере, чтобы не загружать её вручную и не обрабатывать ситуацию, когда объект не найден. Это компромисс между строгим разделением слоев и практичностью. Лично я нахожу такой подход удобным, но в любом случае это всего лишь опция, которую можно отключить — как и большинство возможностей в системе.
Промежуточное ПО для управления транзакциями
При разработке экшенов я придерживаюсь простого, но важного принципа: контроллер должен быть транзакционно атомарным, то есть выполняться как единая операция. Идеальный сценарий — когда разработчику не нужно думать о вызове flush()
. Если в экшене вызываются разные сервисы, и каждый из них самостоятельно вызывает flush()
(например, FooService
сначала что-то делает и коммитит, затем BarService
делает что-то своё и тоже коммитит), это приводит к потере согласованности данных. В худшем случае часть операции пройдет успешно, а часть — нет. Этот механизм автоматически оборачивает выполнение контроллера в транзакцию, устраняя подобные проблемы, однако всегда помните о распределенных транзакциях.
Гарантия уникальности сущности
Разработчики Symfony знакомы с валидатором UniqueEntity
. Здесь реализован аналогичный механизм, который никак не связан с этим фреймворком и встроен в систему как отдельный «компонент».
Управление подключениями к базе данных
Классический сценарий: воркер потребляет сообщения из Kafka, но через какое-то время появляется ошибка «Lost Connection» — знакомо? В долгоработающих приложениях это критично, так как воркер — это, по сути, долгоживущий процесс. Для решения этой проблемы менеджер сущностей перед каждым использованием проверяет, не закрыто ли соединение. Если оно разорвано, происходит автоматическая перезагрузка соединения, что устраняет возможные сбои в работе.
В Awesome Skeleton всё это настраивается буквально в несколько шагов. Устанавливаем мост к Doctrine:
composer require sunrise-studio/doctrine-bridge
Обязательно добавьте PSR-18 совместимый пакет кеширования. Я рекомендую symfony/cache
:
composer require symfony/cache
Далее интегрируем Doctrine в контейнер config/container.php
, добавив перед определениями приложения следующий код:
$containerBuilder->addDefinitions(
__DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/doctrine.php',
__DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/hydrator/type_converters/map_entity_type_converter.php',
__DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/router/middlewares/request_termination_middleware.php',
__DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/router/parameter_resolvers/requested_entity_parameter_resolver.php',
__DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/validator/unique_entity_validator.php',
);
Наконец, добавляем DSN в переменные окружения:
DATABASE_DSN=pdo-pgsql://user:password@localhost:5432/acme?charset=utf8
Google reCAPTCHA
Продолжая тему расширяемости Sunrise Router, давайте добавим защиту экшена входа, используя Google reCAPTCHA. Установим соответствующий пакет:
composer require sunrise/recaptcha
Также нам понадобятся пакеты, совместимые с PSR-17 и PSR-18:
Теперь интегрируем reCAPTCHA в проект. Для этого откроем config/container.php
и перед основными определениями приложения добавим следующее:
$containerBuilder->addDefinition(
__DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/recaptcha_verification.php',
__DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/integration/router/middleware/recaptcha_challenge_middleware.php',
__DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/integration/validator/constraint/recaptcha_challenge_validator.php',
);
После этого необходимо указать секретный ключ в переменных окружения:
RECAPTCHA_VERIFICATION_PRIVATE_KEY=secret
Вы можете выбрать один из двух способов валидации reCAPTCHA:
Через HTTP-заголовок
Через свойство в DTO
Конечно, есть и другие варианты. Например, можно использовать Constraint-аннотацию для валидации параметров в экшене.
Промежуточное ПО
declare(strict_types=1);
namespace App\Controller\Api;
use App\Dictionary\MediaType;
use App\Dto\Auth\SignInRequest;
use Sunrise\Http\Router\Annotation\Consumes;
use Sunrise\Http\Router\Annotation\Middleware;
use Sunrise\Http\Router\Annotation\PostApiRoute;
use Sunrise\Http\Router\Annotation\RequestBody;
use Sunrise\Recaptcha\Integration\Router\Middleware\RecaptchaChallengeMiddleware;
final readonly class AuthController
{
#[PostApiRoute('api.auth.signIn', '/api/auth/sign-in')]
#[Consumes(MediaType::JSON)]
#[Middleware(RecaptchaChallengeMiddleware::class)]
public function signIn(
#[RequestBody]
SignInRequest $signInRequest,
): void {
}
}
По умолчанию ожидается заголовок X-Recaptcha-Token
.
Свойства DTO
declare(strict_types=1);
namespace App\Dto\Auth;
use SensitiveParameter;
use Sunrise\Recaptcha\Integration\Validator\Constraint\RecaptchaChallenge;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class SignInRequest
{
public function __construct(
#[Assert\NotBlank]
#[SensitiveParameter]
public string $email,
#[Assert\NotBlank]
#[SensitiveParameter]
public string $password,
#[RecaptchaChallenge]
public string $recaptcha,
) {
}
}
Резюмируя все вышеизложенное
Мы рассмотрели ключевые возможности и примеры использования, затронув как концептуальные аспекты, так и практические примеры. В данный момент инструментарий активно развивается и документируется. Если вас заинтересовал проект — задавайте вопросы, буду рад обсуждению!