За 6 лет опыта работы в разных IT-компаниях — ни разу не встречал проекты на Laravel, где использовался бы CQRS. Да и погуглив немного, если честно, не нашел ничего стоящего (касательно примеров), поэтому решил сам написать статью на данную тему.

CQRS (Command Query Responsibility Segregation) — это по сути архитектурный паттерн, который позволяет разделить операции с данными на две категории: команды и запросы.

  • Command (команда) — операция, которая изменяет состояние системы, но не возвращает данных (кроме, возможно, результата успеха/ошибки).

  • Query (запрос) — операция, которая возвращает данные и не изменяет состояние системы. Она не должна иметь никаких побочных эффектов и лучше всего подходит для параллельного выполнения.

Разделять команды и запросы нужно для предотвращения побочных эффектов при чтении, улучшения масштабируемости и четкого распределения ответственности. Запросы — только для получения данных, команды — для их изменения. Это ключ к пониманию и правильной реализации CQRS.

Если с этим все понятно (чтобы потом не путать, что делает query, а что command), то переходим к примерам реализации CQRS на Laravel.

Реализация командных шин CQRS

1. Базовые классы Command и Query

В своих проектах я обычно использую пакет wendelladriel/laravel-validated-dto для базовых классов Command и Query, но т. к. это ознакомительная статья, то обойдемся пока без него.

Базовый класс Command:

<?php declare(strict_types=1);

namespace App\Shared;

abstract class Command {}

Базовый класс Query:

<?php declare(strict_types=1);

namespace App\Shared;

abstract class Query {}

Можно было бы обойтись и без них даже, но если вы решите использовать какой-либо пакет DTO, то в этом случае они ОЧЕНЬ сильно пригодятся.

2. Интерфейсы командных шин

Помним, да, пятый принцип SOLID — Dependency Inversion Principle?

Интерфейс CommandBusContract:

<?php declare(strict_types=1);

namespace App\Contracts;

use App\Shared\Command;

interface CommandBusContract
{
    /**
     * Dispatches a command and returns the result.
     *
     * @param Command $command
     * @return mixed|null
     */
    public function send(Command $command): mixed;

    /**
     * Registers a mapping of commands to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void;
}

Интерфейс QueryBusContract:

<?php declare(strict_types=1);

namespace App\Contracts;

use App\Shared\Query;

interface QueryBusContract
{
    /**
     * Executes a query and returns the result.
     *
     * @param Query $query
     * @return mixed
     */
    public function ask(Query $query): mixed;

    /**
     * Registers a mapping of queries to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void;
}

3. Реализация интерфейсов командных шин

В Laravel есть интерфейс Illuminate\Contracts\Bus\Dispatcher — он по сути является фундаментальной частью системы обработки команд и запросов. Основное назначение этого интерфейса — определить контракт для диспетчеризации (отправки) команд и запросов к соответствующим обработчикам. К нему мы и прибегнем, дабы не изобретать велосипед.

Класс командной шины CommandBus:

<?php declare(strict_types=1);

namespace App\Buses;

use App\Contracts\CommandBusContract;
use Illuminate\Contracts\Bus\Dispatcher;
use App\Shared\Command;

final class CommandBus implements CommandBusContract
{
    /**
     * Constructs a new CommandBus instance.
     *
     * @param Dispatcher $commandBus
     */
    public function __construct(
        private Dispatcher $commandBus
    ) {}

    /**
     * Dispatches a command and returns the result.
     *
     * @param Command $command
     * @return mixed|null
     */
    public function send(Command $command): mixed
    {
        return $this->commandBus->dispatch(
            command: $command
        );
    }

    /**
     * Registers a mapping of commands to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void
    {
        $this->commandBus->map(map: $map);
    }
}

Класс командной шины QueryBus:

<?php declare(strict_types=1);

namespace App\Buses;

use App\Contracts\QueryBusContract;
use Illuminate\Contracts\Bus\Dispatcher;
use App\Shared\Query;

final class QueryBus implements QueryBusContract
{
    /**
     * Constructs a new QueryBus instance.
     *
     * @param Dispatcher $queryBus
     */
    public function __construct(
        private Dispatcher $queryBus
    ) {}

    /**
     * Executes a query and returns the result.
     *
     * @param Query $query
     * @return mixed
     */
    public function ask(Query $query): mixed
    {
        return $this->queryBus->dispatch(command: $query);
    }

    /**
     * Registers a mapping of queries to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void
    {
        $this->queryBus->map(map: $map);
    }
}

Сервис-провайдер:

<?php declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

final class BusServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->singleton(
            abstract: \App\Contracts\CommandBusContract::class,
            concrete: \App\Buses\CommandBus::class
        );

        $this->app->singleton(
            abstract: \App\Contracts\QueryBusInterface::class,
            concrete: \App\Buses\QueryBus::class
        );
    }
}

В конечном итоге структурно это будет выглядеть примерно так:

Пример "самопальной" структуры с использованием CQRS на Laravel
Пример "самопальной" структуры с использованием CQRS на Laravel

Вот и все, наши шины готовы к работе. Не забудьте только зарегистрировать сервис-провайдер в bootstrap/providers.php.

Примеры реализации операций с Command и Query

Чтобы было поинтереснее, я покажу реальные примеры из своего проекта на Laravel, где используется гексагональная архитектура и DDD, а также Action-Domain-Responder и, конечно, CQRS.

За основу будут взяты операции CheckMe и Login, т. к. та же регистрация у меня используется с пайплаными и джобами, это сложно будет для понимания, поэтому пока на простых операциях. Но если вам все же интересно, то вот тут можно глянуть пример.

Реализация операции Login с использованием Command

1. Класс LoginCommand:

<?php declare(strict_types=1);

namespace App\Account\Application\Auth\Login;

use App\Shared\Application\Command\Command;
use WendellAdriel\ValidatedDTO\Casting\StringCast;
use WendellAdriel\ValidatedDTO\Casting\BooleanCast;
use WendellAdriel\ValidatedDTO\Attributes\Cast;

final class LoginCommand extends Command
{
    /**
     * The email address of the user to register.
     *
     * @var string
     */
    #[Cast(type: StringCast::class, param: null)]
    public string $email;

    /**
     * The password for the new user account.
     *
     * @var string
     */
    #[Cast(type: StringCast::class, param: null)]
    public string $password;

    /**
     * Whether to remember the user (i.e. keep them logged in).
     *
     * @var bool
     */
    #[Cast(type: BooleanCast::class, param: null)]
    public bool $rememberMe = false;
    
    /**
     * Maps properties to data keys.
     *
     * @return array<string, string>
     */
    protected function mapData(): array
    {
        return [
            'remember_me' => 'rememberMe'
        ];
    }
}

2. Обработчик LoginHandler:

<?php declare(strict_types=1);

namespace App\Account\Application\Auth\Login;

use App\Shared\Application\Handler;
use App\Account\Domain\Provider\AuthProviderInterface;
use Illuminate\Support\Facades\Log;

final class LoginHandler extends Handler
{
    /**
     * Constructs a new LoginHandler instance.
     *
     * @param AuthProviderInterface $auth
     */
    public function __construct(
        private AuthProviderInterface $auth
    ) {}

    /**
     * Handler to process user login and return a JWT token.
     *
     * @param LoginCommand $command
     * @return array<string, string>|null
     *
     * @throws \RuntimeException
     */
    public function handle(LoginCommand $command): ?array
    {
        try {
            return $this->auth->getTokenByCredentials(
                credentials: $command->toArray()
            );
        }

        catch (\Throwable $e) {
            $message = trim(string: <<<MSG
                Login handler error: {$e->getMessage()}
                in {$e->getFile()}:{$e->getLine()}
            MSG);
            
            Log::error(message: $message, context: [
                'exception' => $e
            ]);

            throw new \RuntimeException(
                message: 'Login failed. Please try again',
                code: (int) $e->getCode(),
                previous: $e
            );
        }
    }
}

3. Сервис-провайдер:

<?php declare(strict_types=1);

namespace App\Account\Infrastructure\Dispatching;

use Illuminate\Support\ServiceProvider;
use App\Account\Application\Auth\Login\LoginCommand;
use App\Account\Application\Auth\Login\LoginHandler;
use App\Shared\Domain\Bus\CommandBusInterface;

final class CommandDispatcher extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(CommandBusInterface $commandBus): void
    {
        $commandBus->register(map: [
            LogoutCommand::class => LogoutHandler::class,
        ]);
    }
}

4. Экшен:

<?php declare(strict_types=1);

namespace App\Account\Presentation\Action\Auth;

use App\Shared\Presentation\Controller as Action;
use App\Shared\Domain\Bus\CommandBusInterface;
use App\Account\Presentation\Request\LoginRequest;
use App\Account\Presentation\Responder\Auth\LoginResponder;
use App\Account\Application\Auth\Login\LoginCommand;
use App\Account\Presentation\Response\TokenResponse;
use Spatie\RouteAttributes\Attributes\Route;
use Spatie\RouteAttributes\Attributes\Prefix;

#[Prefix(prefix: 'v1')]
final class LoginAction extends Action
{
	/**
	 * Handles formatting and returning the login response.
	 * 
     * @var LoginResponder
     */
	private readonly LoginResponder $responder;

    /**
     * Constructs a new LoginAction instance.
     *
     * @param CommandBusInterface $commandBus
     */
	public function __construct(
		private readonly CommandBusInterface $commandBus
	) {
		$this->responder = new LoginResponder();
	}

    /**
     * Handles the login HTTP POST request.
     *
     * @param LoginRequest $request
     * @return TokenResponse
     */
    #[Route(methods: 'POST', uri: '/login')]
    public function __invoke(LoginRequest $request): TokenResponse
    {
        /** @var array<string, string>|null $result */
        $result = $this->commandBus->send(
            command: LoginCommand::fromRequest(request: $request)
        );
        
        return $this->responder->respond(result: $result);
    }
}

Респондер тут не стану демонстрировать, т. к. по сути уже понятно, как используется шина в сочетании с Command. Теперь перейдем к Query.

Реализация операции CheckMe с использованием Query

1. Класс CheckMeQuery:

<?php declare(strict_types=1);

namespace App\Account\Application\Auth\Check\Me;

use App\Shared\Application\Query\Query;
use Illuminate\Http\Request;

final class CheckMeQuery extends Query
{
    /**
     * Constructs a new CheckMeQuery instance.
     *
     * @param Request $request
     */
	public function __construct(
		public private(set) Request $request
	) {}
}

2. Обработчик CheckMeHandler:

<?php declare(strict_types=1);

namespace App\Account\Application\Auth\Check\Me;

use App\Shared\Application\Handler;
use App\Account\Domain\User;
use Illuminate\Support\Facades\Log;

final class CheckMeHandler extends Handler
{
    /**
     * Handler to retrieve the currently authenticated user.
     *
     * @param CheckMeQuery $query
     * @return User|null
     *
     * @throws \RuntimeException
     */
    public function handle(CheckMeQuery $query): ?User
    {
        try {
            $auth = $query->request->user();
            $user = $auth->user;

            return $user ?? null;
        }

        catch (\Throwable $e) {
            $message = trim(string: <<<MSG
                CheckMe handler error: {$e->getMessage()}
                in {$e->getFile()}:{$e->getLine()}
            MSG);

            Log::error(message: $message, context: [
                'exception' => $e
            ]);

            throw new \RuntimeException(
                message: 'Failed to retrieve authenticated user.',
                code: (int) $e->getCode(),
                previous: $e
            );
        }
    }
}

3. Сервис-провайдер:

<?php declare(strict_types=1);

namespace App\Account\Infrastructure\Dispatching;

use Illuminate\Support\ServiceProvider;
use App\Account\Application\Auth\Check\Me\CheckMeHandler;
use App\Account\Application\Auth\Check\Me\CheckMeQuery;
use App\Shared\Domain\Bus\QueryBusInterface;

final class QueryDispatcher extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(QueryBusInterface $queryBus): void
    {
        $queryBus->register(map: [
            CheckMeQuery::class => CheckMeHandler::class,
        ]);
    }
}

4. Экшен:

<?php declare(strict_types=1);

namespace App\Account\Presentation\Action\Auth\Check;

use App\Shared\Presentation\Controller as Action;
use App\Shared\Domain\Bus\QueryBusInterface;
use App\Shared\Presentation\Response\ResourceResponse;
use App\Account\Presentation\Responder\Auth\Check\CheckMeResponder;
use App\Account\Application\Auth\Check\Me\CheckMeQuery;
use Spatie\RouteAttributes\Attributes\Prefix;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Route;
use Illuminate\Http\Request as CheckMeRequest;

#[Prefix(prefix: 'v1')]
#[Middleware(middleware: 'auth:api')]
final class CheckMeAction extends Action
{
    /**
     * Handles the authenticated user's identity check request.
     *
     * @var CheckMeResponder
     */
	private readonly CheckMeResponder $responder;

    /**
     * Constructs a new CheckMeAction instance.
     *
     * @param QueryBusInterface $queryBus
     */
	public function __construct(
		private readonly QueryBusInterface $queryBus
	) {
		$this->responder = new CheckMeResponder();
	}

    /**
     * Processes the GET request to verify the current authenticated user.
     *
     * @param CheckMeRequest $request
     * @return ResourceResponse
     */
    #[Route(methods: 'GET', uri: '/check-me')]
    public function __invoke(CheckMeRequest $request): ResourceResponse
    {
        $result = $this->queryBus->ask(
            query: new CheckMeQuery(request: $request)
        );
        
        return $this->responder->respond(result: $result);
    }
}

Видите? По сути ничего сложного, единственное, структурно изначально собрать проект правильно, чтобы потом не упереться в стену.

Заключение

CQRS в связке с Laravel — это мощный подход, который позволяет четко разделить логику чтения и записи, сделать код более читаемым и поддерживаемым.

Несмотря на то, что на практике проекты с Laravel редко используют CQRS, этот паттерн отлично вписывается в современные архитектуры, особенно при применении DDD с гексагоналкой.

Если статья вызвала вопросы или захочется глубже погрузиться в детали — буду рад помочь и поделиться опытом.

Комментарии (17)


  1. ARACOOOL
    20.10.2025 13:37

    Спасибо за статью.
    Я использую похожий подход в своих проектах и это даем действительно много преимуществ, особенно при изоляции бизнес логики.

    У меня есть пару вопросов.

    На сколько я знаю, в правильном CQRS, команда меняет состояние системы и ничего не возвращает. В этом и идея CRQS (хотя могу ошибаться).

    Обработчики команд находятся в Application слое и не могут работать/знать ничего о нижнем слое. Обработчики не должны ничего знать о фреймворке.


    1. fluid
      20.10.2025 13:37

      Все так и есть, можно только добавить, что команда может вернуть резульиатом идентификатор, например созданной сущность или с чем Вы там работаете.

      Это скорее относится не к CQRS, а к луковичной архитектуре(которая зачастую применяется совместно)


  1. KOS_MOS
    20.10.2025 13:37

    CQRS раскрывается вкупе с EventSourcing.
    А так, по факту, CQRS - это просто договоренность о разделении сообщений в используемой вами шине на команды и запросы данных.


    1. SerafimArts
      20.10.2025 13:37

      Не согласен. CQRS просто позволяет нормально разделять и изолировать разные bound context друг от друга


  1. wispoz
    20.10.2025 13:37

    А есть где-то репо, с работающим примером?


    1. a0xh Автор
      20.10.2025 13:37

      Да, конечно, есть. В статье выше указана ссылка на репозиторий с гексагональной архитектурой и DDD на Laravel. Если слишком сложно подобное для понимания, то у меня есть более простой пример, где можно посмотреть реализацию CQRS.


  1. LeGront
    20.10.2025 13:37

    Спасибо за статью. Интересно смотреть на эволюцию языка, PHP уже на C# больше похож, чем на самого себя из прошлого


  1. japanxt
    20.10.2025 13:37

    позволяет четко разделить логику чтения и записи, сделать код более читаемым и поддерживаемым.

    Гибко настраиваемые обработчики всегда уменьшают читаемость кода. Гибкое и скрытое всегда будет менее читаемое, чем жесткое и явное. Это база.

    По поводу более лучше поддержки такого кода, тоже все не так хорошо. По сути чтобы нам написать бизнес логику надо определить обработчик (Handler), дто(Command\Query), и связь(CommandDispatcher). Это очень много кода, а самое главное - бесполезного кода.

    Дальше. Какую проблему мы решили? Что мешает все это заменить на eventDispatcher? Нам действительно нужно в контроллере иметь возможность отвязывать данные от исполняемой логики? Также я не очень понял, как это будет выглядеть когда мне надо,например, при регистрации пользователя сначала проверить каптчу, а потом создать пользователя? Также я честно не понимаю, почему у вас логирование не в отдельных обработчиках команд?

    За основу будут взяты операции CheckMe и Login, т. к. та же регистрация у меня используется с пайплаными и джобами, это сложно будет для понимания, поэтому пока на простых операциях

    Хотя ответ на поверхности, ваш концепт просто рушится из за возросшей сложности доменной логики. Хороший концепт бывает тогда когда он универсален для всех частей системы, даже для самых сложных.


    1. a0xh Автор
      20.10.2025 13:37

      Хотя ответ на поверхности, ваш концепт просто рушится из за возросшей сложности доменной логики.

      Этой не мой "концепт", это паттерн такой:) Хотите - юзайте, хотите - нет, я лишь привел примеры:)


      1. japanxt
        20.10.2025 13:37

        Концептом я это называю, потому что вы взяли паттерн Команду и CQRS(по вашему мнению), и скомпилировали это во что-то третье. Я безусловно это использовать не буду, по причинам описанным выше. А в данном случае я вам даю обычную обратную связь. Представьте что вы работаете в команде и хотите или убедить нового сотрудника что это "круто", или хотите убедить старого сотрудника, что это "круто", какие тезисы вы ему предложите, какие болевые точки на типичном ларавель проекте (и не только) вы закроет?


        1. a0xh Автор
          20.10.2025 13:37

          Концептом я это называю, потому что вы взяли паттерн Команду и CQRS(по вашему мнению), и скомпилировали это во что-то третье.

          Ну, тогда понятно:) Почитайте о том, что такое CQRS, а лучше - попробуйте на практике:)


          1. japanxt
            20.10.2025 13:37

            Очень интересный способ уйти от аргументации, апеллировать к авторитету шаблона проектирования. Но опустим, там дальше есть вопрос, каков ответ?


    1. a0xh Автор
      20.10.2025 13:37

      Также я не очень понял, как это будет выглядеть когда мне надо,например, при регистрации пользователя сначала проверить каптчу, а потом создать пользователя?

      Вы не ограничены одним обработчиком, вы можете запускать целую цепочку через пайпланы, если нужно.

      <?php declare(strict_types=1);
      
      namespace App\Account\Application\Auth\Register;
      
      use App\Shared\Application\Process;
      use App\Account\Application\Auth\Register\Handler\AttachDefaultRoleHandler;
      use App\Account\Application\Auth\Register\Handler\RegisterUserHandler;
      
      final class RegisterProcess extends Process
      {
      	/**
           * List of process handlers to be executed in order.
           *
           * @var array<int, class-string>
           */
          protected array $handlers = [
              AttachDefaultRoleHandler::class,
              RegisterUserHandler::class
          ];
      
          /**
           * Executes the registration process pipeline.
           *
           * @param RegisterCommand $command
           * @return bool
           *
           * @throws \Throwable
           */
          public function __invoke(RegisterCommand $command): bool
          {
              try {
                  dispatch(
                      new RegisterJob(command: $command)
                  );
      
                  return true;
              }
      
              catch (\Throwable $e) {
                  return false;
              }
          }
      }


      1. japanxt
        20.10.2025 13:37

        Получается AttachDefaultRoleHandler и RegisterUserHandler привязаны к RegisterCommand жестко за счет handle(RegisterCommand $command)? Ладно еще RegisterUserHandler привязан к RegisterCommand, но должен ли быть привязан AttachDefaultRoleHandler к нему? А если вдруг понадобится использовать AttachDefaultRoleHandler в другой команде? Также я не очень понял как у вас взаимодействуют обработчики между собой, как передают данные следующему обработчику, если оно нужно? Допустим я при регистрации захотел добавить логирование третьим обработчиком (после AttachDefaultRoleHandler и RegisterUserHandler), который залогирует сообщение используя роль полученную в результате отработки AttachDefaultRoleHandler и пользователя полученного в результате отработки RegisterUserHandler, как это вы сделаете, приведете пример?


        1. a0xh Автор
          20.10.2025 13:37

          А если вдруг понадобится использовать AttachDefaultRoleHandler в другой команде?

          Повторяю еще раз, ЭТО ПАЙПЛАН. Количество обработчиков может быть ЛЮБЫМ. Если нужно вызвать тот или иной обработчик - можно связать его с командой:

          /**
           * Bootstrap any application services.
           */
          public function boot(CommandBusInterface $commandBus): void
          {
              $commandBus->register(map: [
                  LogoutCommand::class => LogoutHandler::class,
              ]);
          }

          В статье есть ссылка на репозиторий, там можно посмотреть. Я не могу в рамках одного комментария рассказать вам и о том, как работают пайпланы в Laravel, что такое сервис-провайдеры и при этом доказывать, что это не абы что, а именно паттерн CQRS:)


          1. japanxt
            20.10.2025 13:37

            извиняюсь, не правильно выразился не "использовать AttachDefaultRoleHandler в другой команде?" а "использовать AttachDefaultRoleHandler в другом кейсе, но в таком же архитектурном слое?".

            Опять же. Не надо мне ничего доказывать :) Мне не важно что это CQRS, Command Bus, пайплайны или что-то еще. Я хочу понять, какие потребности типового проекта, команды разработчиков и кодовой базы вы закрываете данным подходом, что упрощаете, какие болевые точки закрываете? Или вы просто решили рассказать о возможности использовать такие паттерны в ларавель, без изучения того насколько вообще эффективен полученный результат их применения по сравнению со стандартными типовыми подходами?


  1. a0xh Автор
    20.10.2025 13:37

    .