В качестве вступления небольшой дисклеймер. Ниже длиннопост про самописный PHP-фреймворк, с примерами кода и его описанием. Автор не интерпрайз-разработчик, по образованию я юрист, а программирование для меня просто хобби, то, что меня занимает. Повествование идет о том, как я собирал свою систему исходя из своего представления о прекрасном и к чему в итоге пришел.
Большинству писать свой фреймворк не нужно от слова совсем. Берете Symfony, Laravel, Spiral, [ваш любимый фреймворк], нужный набор библиотек и решаете продуктовую задачу. Это разумный путь.
Зачем тогда этим занялся я? Если кратко, то потому что могу и хочу, если не очень кратко, то мне было интересно написать свой PSR-совместимый фреймворк. Все началось еще в середине десятых. На тот момент недавно вышел PHP 5.6, приняли PSR-7, позже появился PSR-15, и сборка своего велосипеда на этих новомодных абстракциях, с middleware-пайплайном, показалась мне весьма полезным и увлекательным предприятием.
За основу было решено взять nyholm/psr7, PHP-DI, для отправки ответа прикрутил laminas/httphandlerrunner, конфигурация была написана с оглядкой на тот же laminas, а middleware-пайплайн, фабрика для них, мапшрутизация, app-интеграция, провайдер аутентификации, экспорт конфигурации (в том числе экспорт замыканий) и skeleton-пакет были написаны с нуля. На это ушло примерно два или три года, и где-то к 2020 была собрана первая рабочая версия.
Потом я то забрасывал проект, то возвращался к нему вновь, то переписывал куски полностью. В 2025 году решил в очередной раз переделать все. Реализовать это устремление сильно помогло развитие кодинг-агентов. Сначала Claude Code, позже Codex. Большой объем кодовой базы был переписан или написан полностью с нуля.
Если после прочтения последнего предложения ты, дорогой читатель, почувствовал как свербит седалищный нерв, то не торопись писать свое фи или закрывать статью. Текст ниже не о вайб-кодинге и не о том, как можно легко и круто вкатиться в разработку не хуже интерпрайз-уровня, не имея никакой экспертизы в вопросе.
Кодинг-агенты хорошо решают задачу по написанию бойлерплейта, хотя, будем честны, многие другие задачи они тоже решают на достойном уровне.
В моем случае я занимался проектированием интерфейсов, архитектурой, написанием промптов, ревью и вычиткой README, а машина писала код, README, тесты, наводила на нужные мысли, предлагала, где можно посмотреть решения.
Что касается основной задумки, то хотелось сделать фреймворк декларативным и по возможности производительным.
Маршрут должен быть рядом с методом контроллера. Команда должна сама говорить, какая политика доступа к ней применяется. Обработчик команды должен находиться по атрибуту. Валидация должна жить на DTO, а не в случайном сервисе сбоку. Если метод нужно сериализовать, превратить результат в HTTP-ответ или обернуть в пагинацию, это тоже должно быть видно на методе.
Примерно так:
<?php declare(strict_types=1); namespace App\Post\Controller; use App\Post\Command\CreatePostCommand; use App\Post\Domain\Post; use App\Post\Infrastructure\Map\MapCreatePostCommand; use Componenta\Auth\Http\Middleware\RequireAuthenticationMiddleware; use Componenta\CQRS\Command\CommandBusInterface; use Componenta\Http\Router\Attribute\Route; use Componenta\Interceptor\Http\Attribute\Respond; use Componenta\Interceptor\Serialization\Attribute\Serialize; final class CreatePost { #[Respond(201, 'application/json')] #[Serialize] #[Route( name: 'posts.create', path: '/posts', methods: 'POST', middlewares: [RequireAuthenticationMiddleware::class], group: 'api', )] public function __invoke( #[MapCreatePostCommand] CreatePostCommand $command, CommandBusInterface $dispatcher, ): Post { return $dispatcher->dispatch($command)->result ->value ->post; } }
Здесь контроллер ничего не знает про HTTP, он не мапит запрос, не проверяет пользователя и не возвращает HTTP-ответ. Он получает готовую команду и командную шину, после чего выполняет команду и возвращает результат. Всю оставшуюся работу делает фреймворк. Сначала запрос попадает во фронт-контроллер public/index.php, там задается скоуп приложения HTTP, затем подключается config/config.php.
if (!isset($paths) || !$paths instanceof PathResolverInterface) { throw new RuntimeException('config/config.php requires $paths to be a PathResolverInterface instance.'); } return new ConfigDefinition( providers: [ new ComposerPackageConfigProvider($paths->resolve('config/componenta-providers.php')), new AttributeConfigProvider(), new FileProvider($paths->resolve('config/console.php')), new FileProvider($paths->resolve('config/autoload/{{,*.}global,{,*.}local}.{php,yaml,json}')), ], discovery: new DiscoveryDefinition( directories: ['src'], exclude: ['Cycle'], ), );
ConfigDefinition собирает провайдеры конфигурации и discovery-настройки. В skeleton после установки discovery настроен на src: фреймворк ищет там файлы с объявленными классами, строит кеш и записывает его в var/cache/dev/discovery.dev.php. Если файлы в отслеживаемой директории меняются, директория сканируется заново и кеш собирается повторно. Сам механизм не привязан к имени src: он сканирует директории, переданные в DiscoveryDefinition.
ComposerPackageConfigProvider читает config/componenta-providers.php, который формируется во время установки пакетов, и подключает конфиг-провайдеры библиотек. AttributeConfigProvider проходит по найденным классам, ищет #[AsConfig] и добавляет прикладные config provider-ы. FileProvider подключает файлы по паттерну. Из этих блоков собирается общий конфиг приложения.
Для продакшен окружения общий конфиг пишется в файл и подключается только он, провайдеры не инстанцируются, дискаверинг не происходит.
После того как конфиг собран, на его основе собирается контейнер приложения.
Дальше начинается уже не конфигурационная, а рантайм часть. Точка входа вызывает Componenta\App\run() и передает туда скоуп приложения. Для HTTP это Scope::HTTP, для консоли будет CLI, для websocket сервера Scope::Websocket.
Упрощенно run() делает несколько вещей:
<?php function run(ScopeInterface $scope, PathResolverInterface $paths): void { chdir($paths->baseDir); $container = require $paths->resolve('config/container.php'); $config = $container->get(Config::class); $code = Runner::run($scope, new ContainerValue($container, $config)); if ($code !== null) { exit($code); } }
ContainerValue по сути синтаксический сахар над PSR-контейнером. Он не заменяет контейнер и не вводит новый DI-механизм. Это маленькая обертка, которая держит рядом контейнер и уже собранный Config.
Сейчас он выглядит так:
<?php declare(strict_types=1); namespace Componenta\Config; use Componenta\Config\Exception\InvalidContainerValueException; use Psr\Container\ContainerInterface; final readonly class ContainerValue implements ContainerInterface { public Config $config; public function __construct( public ContainerInterface $value, ?Config $config = null, ) { $this->config = $config ?? $this->resolveConfig(); } public function has(string $id): bool { return $this->value->has($id); } /** * @template T of object * @param class-string<T>|null $type * @return ($type is null ? mixed : T) */ public function get(string $id, ?string $type = null): mixed { $service = $this->value->get($id); if ($type === null) { return $service; } if (!$service instanceof $type) { throw InvalidContainerValueException::forService($id, $type, $service); } return $service; } public function find(string $id, mixed $default = null): mixed { if ($this->value->has($id)) { $type = $default instanceof ContainerEntry ? $default->type : null; return $this->get($id, $type); } if ($default instanceof ContainerEntry) { return $default->resolve($this); } if ($default instanceof ConfigEntry) { return $default->resolve($this->config); } if ($default instanceof LazyValue) { return $default->resolve($this); } return $default; } private function resolveConfig(): Config { if (!$this->value->has(Config::class)) { return new Config([]); } return $this->get(Config::class, Config::class); } }
Зачем здесь get(), если у контейнера уже есть get()? В основном ради второго аргумента. Обычный PSR-контейнер возвращает mixed, а ContainerValue::get(Foo::class, Foo::class) сразу проверяет тип. Если в контейнере под этим id внезапно лежит не Foo, ошибка будет понятной и ранней.
find() нужен для другого случая: сервис может быть опциональным. Без него код быстро превращается в однотипные ветки has() плюс get() плюс fallback. Например, если сервис есть, берем его. Если сервиса нет, возвращаем значение по умолчанию. А если fallback задан как ContainerEntry, ConfigEntry или LazyValue, он будет аккуратно разрешен через контейнер или конфиг.
То есть условно вместо такого:
$foo = $container->has(FooInterface::class) ? $container->get(FooInterface::class) : $container->get(DefaultFoo::class);
можно написать так:
$foo = $container->find( FooInterface::class, new ContainerEntry(DefaultFoo::class, FooInterface::class), );
Это не обязательная абстракция, скорее рабочий инструмент внутри фреймворка, где таких опциональных зависимостей много: фабрики, bootloader-ы, интеграционные пакеты, разные скоупы запуска.
После этого Runner создает само приложение под нужный скоуп. В HTTP это HTTP application, которое умеет принимать middleware и в конце отправлять PSR-7 response через emitter. Потом приложение оборачивается в boot target. Для HTTP это HttpBootTargetInterface, у которого есть метод pipe().
Затем создается BootContext:
<?php final readonly class BootContext { public function __construct( public ContainerValue $container, public ScopeInterface $scope, public object $target, ) {} public function target(string $contract): object { // проверка, что target подходит текущему bootloader-у } }
Контекст передается bootloader-ам. Bootloader видит контейнер, конфиг, текущий скоуп и boot target, но не получает прямого доступа к запуску приложения. Bootloader может зарегистрировать middleware, подписать обработчики, восстановить discovery cache, но не должен и не может сам запускать HTTP request loop.
Список bootloader-ов берется из конфига по ключу bootloaders. Каждый bootloader проходит проверку supports(). Поэтому один и тот же конфиг может содержать HTTP, CLI и Websocket bootloader-ы, а на конкретном запуске выполнятся только те, которые подходят текущему скоупу.
Для HTTP флоу есть как минимум два важных bootloader-а.
Первый приходит из componenta/app-http. Он подключает config/pipeline.php:
<?php /** @var \Componenta\App\Boot\Target\HttpBootTargetInterface $app */ $app->pipe(Componenta\Error\Http\Middleware\ErrorHandlerMiddleware::class, priority: 100); $app->pipe(Componenta\Http\Middleware\BodyParsingMiddleware::class, priority: 100);
Второй приходит из componenta/router-app. Он регистрирует middleware роутинга сам, через интеграционный bootloader:
<?php final class RoutingBootloader implements BootloaderInterface { private const int ROUTING_PRIORITY = 50; public function boot(BootContext $context): void { $app = $context->target(HttpBootTargetInterface::class); $app->pipe(MatchRouteMiddleware::class, self::ROUTING_PRIORITY); $app->pipe(DispatchRouteMiddleware::class, self::ROUTING_PRIORITY); } }
То есть MatchRouteMiddleware и DispatchRouteMiddleware не надо руками писать в config/pipeline.php. Они появляются, когда установлен router integration package. Приоритеты тоже важны: middleware с большим приоритетом выполняются раньше. В примере выше error handler и body parser идут с приоритетом 100, роутинг с приоритетом 50. Если у middleware одинаковый приоритет, сохраняется порядок регистрации.
Приложение не знает деталей роутера. Оно просто дает boot target. HTTP пакет подключает пользовательский pipeline, router пакет добавляет routing middleware, другие пакеты могут добавить свои bootloader-ы. В итоге приложение собирается из маленьких частей, но порядок остается управляемым.
<?php declare(strict_types=1); namespace Componenta\App\Boot\Target; use Componenta\App\Server\App; final readonly class HttpBootTarget implements HttpBootTargetInterface { public function __construct( private App $app, ) {} public function pipe(mixed $middleware, int $priority = 0): void { $this->app->pipe($middleware, $priority); } }
Маршрут CreatePost
Теперь можно вернуться к CreatePost, но без повторения кода контроллера, который был выше. Для нас сейчас важны его атрибуты:
#[Respond(201, 'application/json')] #[Serialize] #[Route( 'posts.create', '/posts', 'POST', middlewares: [RequireAuthenticationMiddleware::class], group: 'api', )]
#[Route], как можно догадаться, говорит роутеру: есть маршрут posts.create, путь /posts, метод POST, группа api, плюс middleware только для этого маршрута.
Группа api не создается автоматически. Ее нужно явно зарегистрировать в config/routes.php. Для примера достаточно такого куска:
<?php $api = $routes->group('api', '/api/v1', [ ResponseCacheMiddleware::class, AuthenticationMiddleware::class, ProvideCurrentUserMiddleware::class, TouchSessionMiddleware::class, RejectBannedUserMiddleware::class, ]);
После этого маршрут из атрибута получает полный путь /api/v1/posts. ResponseCacheMiddleware здесь нужен для кешируемых GET-запросов. На POST /api/v1/posts он просто пропускает запрос дальше. Если в атрибуте указан group: 'api', а группы api в config/routes.php нет, это ошибка конфигурации, а не повод молча зарегистрировать маршрут в корень.
В dev режиме route listener получает список найденных классов из class discovery, читает #[Route] и наполняет коллекцию маршрутов. В production после app:build приложение берет маршруты из скомпилированного файла кеша. В этом режиме провайдеры и discovery не запускаются на каждый запрос.
Запрос доходит до контроллера
Когда приходит POST /api/v1/posts, HTTP приложение создает ServerRequestInterface через PSR-7 request creator и запускает middleware pipeline.
Сначала срабатывают middleware с наивысшим приоритетом: обработчик ошибок и body parser. Body parser парсит тело запроса (если нужно) и кладет результат в $request->getParsedBody().
Потом срабатывает роутинг MatchRouteMiddleware находит posts.create и кладет найденный маршрут в атрибуты psr-7 запроса. DispatchRouteMiddleware берет route handler и прогоняет middleware группы api, затем middleware самого маршрута. В нашем случае перед контроллером выполняется примерно такая цепочка:
ErrorHandlerMiddleware priority 100 BodyParsingMiddleware priority 100 MatchRouteMiddleware priority 50 DispatchRouteMiddleware priority 50 ResponseCacheMiddleware group api AuthenticationMiddleware group api ProvideCurrentUserMiddleware group api TouchSessionMiddleware group api RejectBannedUserMiddleware group api RequireAuthenticationMiddleware route posts.create App\Post\Controller\CreatePost::__invoke()
AuthenticationMiddleware пробует восстановить пользователя. Он достает auth payload из запроса, передает его в authenticator chain и, если аутентификация прошла, кладет результат в атрибуты запроса под ключом IdentityInterface::class. ProvideCurrentUserMiddleware уже читает это значение и на время обработки запроса передает его в CurrentUserProvider, чтобы текущего пользователя могли получить resolver-ы и сервисы, которым он нужен. RequireAuthenticationMiddleware проверяет атрибуты запроса на наличие IdentityInterface::class. Если там нет аутентифицированного пользователя, до CreatePost выполнение не дойдет.
Как из запроса получается команда
Первый параметр контроллера помечен атрибутом #[MapCreatePostCommand]. Это прикладной маппер поверх библиотечного MapRequestPayload:
<?php declare(strict_types=1); namespace App\Post\Infrastructure\Map; use Attribute; use Componenta\DI\Attribute\MapRequestPayload; use Componenta\Identity\IdentityInterface; #[Attribute] class MapCreatePostCommand extends MapRequestPayload { protected array $attributes = [IdentityInterface::class]; protected array $cast = [ 'categoryId' => '?int', 'isPremium' => '?bool', 'commentsEnabled' => '?bool', 'allowReactions' => '?bool', 'hideAuthor' => '?bool', 'previewBlockCount' => '?int', 'publishedAt' => '?datetime', 'commentsOpenFrom' => '?datetime', 'commentsClosedAt' => '?datetime', ]; protected(set) array $map = [ IdentityInterface::class => 'actor', '?meta' => 'metaTags', ]; }
MapRequestPayload собирает данные из тела и атрибутов запроса, перечисленных в $attributes. В этом примере к payload добавляется IdentityInterface::class, то есть актор, которого до этого положил в $request AuthenticationMiddleware.
Затем DispatchRouteMiddleware берет результат матчинга маршрута. Если у маршрута есть middleware группы или самого маршрута, он собирает пайплайн и добавляет RouteHandler последним элементом. Если middleware нет, в MiddlewareFactory уходит только RouteHandler. В фабрике middleware есть цепочка резолверов, и router-app добавляет туда InterceptedRouteHandlerResolver. Этот resolver превращает RouteHandler в PSR-15 middleware: сначала через callable resolver получает реальный callable контроллера, потом заворачивает его в InterceptedRouteHandlerMiddleware.
InterceptedRouteHandlerMiddleware уже не вызывает контроллер напрямую. Он создает CallableContext, кладет туда PSR-7 запрос и его обработчик, после чего передает context в HTTP-пайплайн перехватчиков. Этот пайплайн собирается фабрикой HttpInterceptorPipelineFactory. Первым перехватчиком в нем стоит ParameterResolvingInterceptor, поэтому резолвинг параметров не берется из воздуха: это обязательный первый шаг перед вызовом callable.
Дальше ParameterResolvingInterceptor запускает ParametersResolver. В цепочке резолверов есть RequestResolver из DI-пакета. Он видит параметр #[MapCreatePostCommand] CreatePostCommand $command, вызывает mapper, получает массив данных для конструктора и передает его в DI фабрику. А фабрика создает CreatePostCommand как обычный объект, с учетом типов конструктора и остальных правил DI.
Внутри mapper-а порядок тоже фиксированный. Сначала, если подключена интеграция валидации, проверяется сырой контракт запроса по правилам DTO. После этого применяются $map, $cast, $defaults, $sortMap и $exclude, если они заданы.
Запись вида 'allowReactions' => '?bool' означает null-safe cast: если значение null, оно таким и останется, а если пришло не null, mapper попробует привести его к булеву. То же самое с '?datetime': пустое отсутствие значения не превращается в дату, но непустая строка приводится к DateTimeImmutable. Это удобно для nullable полей команды.
Что касается актора, он не является полем входного payload, а попадает в атрибуты запроса после успешной аутентификации и затем мапится в аргумент конструктора actor. А поле meta из payload можно переложить в metaTags, потому что внешнее API и внутренняя команда не обязаны называться одинаково.
Если нужно взять одно значение из атрибутов запроса без отдельного mapper-а, можно использовать RequestAttribute. Ключ можно не указывать, если он совпадает с именем параметра:
<?php use Componenta\DI\Attribute\RequestAttribute; use Componenta\Identity\IdentityInterface; final class ExampleController { public function __invoke( #[RequestAttribute] int $postId, #[RequestAttribute(IdentityInterface::class)] IdentityInterface $identity, ): array { return [ 'postId' => $postId, 'identity' => (string) $identity->uuid, ]; } }
В первом случае будет прочитан атрибут запроса postId, потому что параметр называется $postId. Во втором ключ нужно указать явно, потому что значение лежит под ключом IdentityInterface::class, а не под строкой identity.
Подобные базовые мапперы есть для всех полей запроса (серверные параметры, загруженные файлы и тд.).
Авторизация и валидация команды
Команда создания поста является обычным readonly DTO. На ней лежат правила валидации и policy атрибут:
<?php declare(strict_types=1); namespace App\Post\Command; use App\Post\Policy\PostsCreate; use App\User\Domain\User; use Componenta\Policy\Actor\ActorAwareInterface; use Componenta\Validation\Attribute\Validate; use Componenta\Validation\Attribute\When; use DateTimeImmutable; #[PostsCreate] final readonly class CreatePostCommand implements ActorAwareInterface { public function __construct( public User $actor, #[When('status:published', then: 'required|string|length:3,300', else: 'nullable|string|length:3,300')] public ?string $title = null, #[When('status:published', then: 'required|array', else: 'nullable|array')] public ?array $content = null, #[Validate('nullable|date|after:now,true,5')] public ?DateTimeImmutable $publishedAt = null, // остальные поля команды опущены ) {} }
#[PostsCreate] обрабатывает не HTTP middleware. Этот атрибут читает Componenta\CQRS\Command\Middleware\PolicyMiddleware, то есть middleware командной шины. О самой шине речь пойдет ниже, но смысл здесь важен сразу: handler не проверяет права руками. Команда сама сообщает, какая политика к ней относится. ActorAwareInterface нужен, чтобы policy слой мог взять актора из команды.
#[Validate] и #[When] описывают входной контракт. Например, опубликованный пост требует title и content, а черновик может жить без них. Это не спрятано в форме и не размазано по handler-у. Правило лежит рядом с полем.
Шина команд
В контроллере остается одна строка (формально 3):
return $dispatcher->dispatch($command)->result ->value ->post;
Командная шина находит handler по карте обработчиков. В dev режиме карта наполняется discovery listener-ом по #[AsCommandHandler]. В production шина читает уже скомпилированную карту.
Перед обработчиком команда проходит middleware пайплайн шины. В этом приложении они зарегистрированы так:
\Componenta\CQRS\ConfigKey::COMMAND_MIDDLEWARES => [ PolicyMiddleware::class, TransportMiddleware::class, TransactionMiddleware::class, EventMiddleware::class, ],
PolicyMiddleware проверяет #[PostsCreate].
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)] final class PostsCreate extends PermissionPolicy { public function __construct() { parent::__construct(PostPermission::CREATE); } }
TransportMiddleware решает, выполнять команду синхронно или отправить в очередь. TransactionMiddleware обеспечивает транзационность. EventMiddleware рассылает события жизненного цикла команды слушателям.
Обработчик CreatePostHandler создает пост через фабрику, применяет настройки комментариев и превью, сохраняет агрегат через репозиторий и возвращает PostResult. Этот результат важен не только контроллеру. Его читают слушатели команды.
Например, слушатель версии подписан на команду через #[AsCommandListener(command: CreatePostCommand::class)] и после успешной обработки сохраняет snapshot поста. Другой слушатель смотрит на PostResult->statusTransition и пишет PostStatusLog, если статус действительно изменился. Оба слушателя выполняются внутри одной транзакции, поэтому версия и журнал статуса сохраняются вместе с самим постом.
По итогу обработчик занимается созданием поста, версионирование и журналирование выполняются вместе с ним(после), но не раздувают его.
Перехватчики результата
Когда команда выполнена, контроллер возвращает Post. После этого включаются перехватчики метода.
Для CreatePost это:
#[Respond(201, 'application/json')] #[Serialize]
AttributeInterceptor строит цепочку по атрибутам метода. Верхний атрибут становится внешним слоем, нижний ближе к методу. Поэтому при возврате результата сначала срабатывает Serialize, затем Respond.
Serialize превращает Post в JSON строку через serializer (по умолчанию Symfony serializer) приложения. Respond заворачивает строку в PSR-7 response со статусом 201 и content type application/json.
Перехватчики можно подключать двумя способами. Первый способ: атрибут наследуется от Intercept и указывает, какой interceptor service нужно создать. Так сделаны Respond и Serialize. Второй способ: сам атрибут реализует InterceptorInterface. Так, например, реализован Paginate, который смотрит на результат handler-а и, если это PaginatorInterface, оборачивает его в ResourcePaginator с ссылками на следующую и предыдущую страницу.
Полная цепочка
Если собрать все в один проход, картина выглядит так:

В runtime конфигурация и DI-контейнер уже готовы до boot фазы. Bootloader-ы не обрабатывают запрос сами. Они собирают приложение: HTTP bootloader подключает пользовательский pipeline, router bootloader добавляет middleware маршрутизации, остальные bootloader-ы регистрируют нужные runtime-сервисы.
Дальше запрос идет по обычному PSR-15 пайплайну. Body parser готовит parsed body, MatchRouteMiddleware находит маршрут, DispatchRouteMiddleware собирает route pipe, а route handler запускается через interceptor pipeline. Там ParameterResolvingInterceptor превращает запрос в DTO, DI factory создает command или query, а CQRS-шина вызывает для них обработчик. На выходе response-интерцепторы сериализуют результат и собирают HTTP ответ.
Мне не хотелось делать фреймворк, где половина поведения спрятана в одном большом yaml файле, а вторая половина размазана по сервис провайдерам. Но и ручная регистрация всего подряд тоже быстро надоедает. В итоге получился компромисс: в dev режиме пишешь классы и атрибуты, а в production приложение работает по заранее собранным картам и кешам.
За это есть своя плата в виде использования рефлексии, но мне кажется это допустимый компромисс, зато код выглядит красиво и декларативно.
Контроллер не знает о JSON и response. Handler не знает о HTTP. Policy не размазывается по методам. Слушатели не засоряют основной сценарий.
Что по чтению данных ?
Ниже пример контроллера, маппера, запроса и его обработчика для получения постов.
namespace App\Post\Controller; final class GetPublicPosts { #[Respond(200, 'application/json')] #[Paginate] #[Route('posts.get.public', '/posts', 'GET', group: 'api')] public function __invoke( QueryBusInterface $bus, #[MapGetPublicPostsQuery] GetPublicPostsQuery $query, ): mixed { return $bus->handle($query); } } namespace App\Post\Infrastructure\Map; use Componenta\Cycle\Filter\Direction; use Componenta\DI\Attribute\MapQueryString; #[\Attribute(\Attribute::TARGET_PARAMETER)] class MapGetPublicPostsQuery extends MapQueryString { protected array $cast = [ 'category' => 'csv_int', 'tag' => 'csv_int', 'date' => '?date_filter', 'isPremium' => 'bool', 'user' => 'int', 'limit' => 'int', 'offset' => 'int', 'preview' => 'csv', ]; protected array $sortMap = [ 'latest' => ['published_at' => Direction::DESC], 'oldest' => ['published_at' => Direction::ASC], 'popular' => ['view_count' => Direction::DESC], ]; } namespace App\Post\Query; #[Allow] final readonly class GetPublicPosts implements PaginableInterface, RequiresTotalCountInterface, SearchableInterface, SortableInterface { /** * @param list<int>|null $category * @param list<int>|null $tag * @param array{from?: string, to?: string, dates?: list<string>}|null $date * @param array<non-empty-string, Direction>|null $orderBy * @param list<string>|null $preview */ public function __construct( public int $limit = 20, public int $offset = 0, public ?int $user = null, public ?array $category = null, public ?array $tag = null, public ?bool $isPremium = null, public ?string $search = null, public ?array $date = null, public ?array $orderBy = null, public ?string $with = null, public ?array $preview = null, ) {} } namespace App\Post\Query\Handler; final readonly class GetPublicPostsHandler { public function __construct( private PostFetcherInterface $fetcher, ) {} #[AsQueryHandler] public function __invoke(GetPublicPosts $query): array|Paginator { return $this->fetcher->listPublic($query); } }
MapGetPublicPostsQuery берет именно query string, а не тело запроса. Для чтения это логично: внешний URL остается контрактом фильтрации, сортировки и пагинации. Маппер приводит входные значения к типам query DTO: limit, offset, user, CSV-поля category, tag, preview, дату, isPremium и варианты сортировки latest, oldest, popular.
#[Allow] на GetPublicPosts не относится к роутингу и не отключает policy middleware. Это обычная policy для CQRS-запроса. Шина запросов перед вызовом обработчика запускает Componenta\CQRS\Query\Middleware\PolicyMiddleware, middleware берет action id из класса query, в данном случае это App\Post\Query\GetPublicPosts, и передает его в PolicyEnforcer. В dev режиме policy provider может прочитать атрибут с класса через рефлексию, в production это берется из скомпилированной карты policy. Сам Allow очень простой: он всегда возвращает true.
Если #[Allow] убрать и не поставить другую policy, публичным запрос от этого не станет. PolicyMiddleware сначала получает actor: из контекста вызова, из самого query, если он реализует ActorAwareInterface, или через ActorProviderInterface. В стандартной конфигурации componenta/policy есть гостевой actor provider, поэтому для публичного чтения actor не нужно класть в DTO. Но policy все равно должна быть. Если actor найден, а policy для action id нет, PolicyEnforcer по умолчанию откажет в доступе, потому что missing policy behavior равен DENY. Если убрать и actor provider, и actor из query/context, выполнение остановится еще раньше, на резолвинге actor (упадет с исключением). Публичность должна быть явной.
После policy шина запросов находит обработчик по карте #[AsQueryHandler] и вызывает его через callable invoker. Обработчик не строит HTTP-ответ и не занимается правами. Он отдает DTO в PostFetcher, а fetcher уже применяет фильтры, сортировку и пагинацию к SQL-запросу. Если результат является Paginator, атрибут #[Paginate] оборачивает его в ресурс с метаданными и ссылками, а #[Respond] собирает HTTP response.
Что по цифрам ?
Я замерял этот endpoint локально, на PHP 8.4.5 из OSPanel. В PHP был включен OPcache, JIT был отключен (opcache.jit=disable), preload не использовался. Production режим запускался с APP_ENV=production, development режим с APP_ENV=development.
Методика простая: 10 прогревочных запросов, затем 80 последовательных измеряемых запросов. Клиент - PowerShell Invoke-WebRequest -UseBasicParsing. Я отдельно смотрел два сценария. Первый дергает один и тот же URL и попадает в warm HTTP-cache внутри приложения. Второй добавляет уникальный query-параметр __bench, чтобы пройти backend path заново: роутинг, middleware, mapper, CQRS, handler, SQL и сериализацию.
Режим | Сценарий | p50 | p95 | min | max | avg |
prod | warm HTTP-cache | 107.40 ms | 180.27 ms | 71.67 ms | 227.45 ms | 117.11 ms |
prod | backend path | 152.41 ms | 211.62 ms | 119.78 ms | 236.45 ms | 156.48 ms |
dev | warm HTTP-cache | 234.65 ms | 324.34 ms | 194.49 ms | 410.32 ms | 248.12 ms |
dev | backend path | 283.95 ms | 369.64 ms | 228.81 ms | 745.75 ms | 296.41 ms |
Насколько это показательный тест - судите сами.
Что в итоге ?
По факту сейчас у меня получился более-менее стабильный скелет приложения, с местами довольно точной документацией на русском и английском языках, с набором тестов. Но документацию еще нужно вычитать до конца, поправить неточности и дополнить, тесты нужно проверить, некоторые модули причесать, чем я и занимаюсь по мере возможности.
Если мне удалось заинтересовать и есть желание потрогать мою поделку руками, то сделать это можно здесь.
Если текст был интересен или полезен можно дать обратную связь.
З.Ы. Руками пока проверил только сценарий полной установки (пресет full), он работает.
