CQRS (Command Query Responsibility Segregation) is a way of organizing your code so that the parts that change data (commands) are separate from the parts that just read data (queries). This separation makes your code cleaner, easier to maintain, and simpler to scale, because each part has its own clear job.
In this article, we’ll take a deep dive into how to use CQRS with Laravel 12, showing practical examples and advanced architectural tips. It’s aimed at experienced developers who want to make their apps more scalable, easier to maintain, and clearer by using CQRS alongside domain-driven design principles.
Defining Interfaces for Command and Query Buses
<?php declare(strict_types=1);
namespace App\Shared\Domain\Bus;
interface CommandBusInterface
{
/**
* Dispatches a command and returns the result.
*
* @param object $command
* @return mixed|null
*/
public function send(object $command): mixed;
/**
* Registers a mapping of commands to their handlers.
*
* @param array<string, string> $map
*/
public function register(array $map): void;
}
<?php declare(strict_types=1);
namespace App\Shared\Domain\Bus;
interface QueryBusInterface
{
/**
* Executes a query and returns the result.
*
* @param object $query
* @return mixed
*/
public function ask(object $query): mixed;
/**
* Registers a mapping of queries to their handlers.
*
* @param array<string, string> $map
*/
public function register(array $map): void;
}
The interfaces define contracts for dispatching commands and queries as well as for registering their respective handlers.
Implementing Command and Query Buses
<?php declare(strict_types=1);
namespace App\Shared\Application\Command;
use App\Shared\Domain\Bus\CommandBusInterface;
use Illuminate\Contracts\Bus\Dispatcher;
final class CommandBus implements CommandBusInterface
{
/**
* Constructs a new CommandBus instance.
*
* @param Dispatcher $commandBus
*/
public function __construct(
private Dispatcher $commandBus
) {}
/**
* Dispatches a command and returns the result.
*
* @param object $command
* @return mixed|null
*/
public function send(object $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);
}
}
<?php declare(strict_types=1);
namespace App\Shared\Application\Query;
use App\Shared\Domain\Bus\QueryBusInterface;
use Illuminate\Contracts\Bus\Dispatcher;
final class QueryBus implements QueryBusInterface
{
/**
* Constructs a new QueryBus instance.
*
* @param Dispatcher $queryBus
*/
public function __construct(
private Dispatcher $queryBus
) {}
/**
* Executes a query and returns the result.
*
* @param object $query
* @return mixed
*/
public function ask(object $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);
}
}
These classes leverage Laravel’s built-in Dispatcher
to handle the actual dispatching of commands and queries through centralized buses.
Registering Command and Query Buses in Laravel’s Service Container
<?php declare(strict_types=1);
namespace App\Shared\Infrastructure;
use Illuminate\Support\ServiceProvider;
final class BusServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(
abstract: \App\Shared\Domain\Bus\CommandBusInterface::class,
concrete: \App\Shared\Application\Command\CommandBus::class
);
$this->app->singleton(
abstract: \App\Shared\Domain\Bus\QueryBusInterface::class,
concrete: \App\Shared\Application\Query\QueryBus::class
);
}
}
By registering these buses as singletons, Laravel's service container provides consistent access and supports dependency injection throughout the application.
Implementing the User Repository in a CQRS Architecture
Proper repository layers isolate domain logic from persistence concerns in a CQRS system.
<?php declare(strict_types=1);
namespace App\Account\Domain\Repository;
use App\Shared\Domain\Repository\UserRepositoryInterface as RepositoryInterface;
use App\Account\Domain\User;
interface UserRepositoryInterface extends RepositoryInterface
{
/**
* Save the given User entity.
*
* @param User $user
*/
public function save(User $user): void;
}
<?php declare(strict_types=1);
namespace App\Account\Domain\Repository;
use App\Shared\Domain\Id\UserId;
use App\Account\Domain\User;
abstract class UserDecoratorRepository implements UserRepositoryInterface
{
/**
* Find a User entity by its unique identifier.
*
* @param UserId $id
* @return User|null
*/
abstract public function findById(UserId $id): ?User;
}
<?php declare(strict_types=1);
namespace App\Account\Infrastructure\Repository;
use App\Account\Domain\Repository\UserDecoratorRepository;
use App\Account\Domain\User;
use App\Account\Infrastructure\Repository\Storage\UserStorageRepository;
use App\Account\Infrastructure\Repository\Transaction\UserTransactionRepository;
use App\Shared\Domain\Id\UserId;
final class UserRepository extends UserDecoratorRepository
{
/**
* Injects user storage and transaction repositories.
*
* @param UserStorageRepository $storage
* @param UserTransactionRepository $transaction
*/
public function __construct(
private UserStorageRepository $storage,
private UserTransactionRepository $transaction
) {}
/**
* Find a User entity by its unique identifier.
*
* @param UserId $id
* @return User|null
*/
public function findById(UserId $id): ?User
{
return $this->storage->findById(id: $id);
}
/**
* Save a User entity using transactional repository.
*
* @param User $user
*/
public function save(User $user): void
{
$this->transaction->save(user: $user);
}
}
This separation divides responsibilities between querying data and saving changes, promoting modularity and testability.
Binding the User Repository in Laravel’s Service Container
<?php declare(strict_types=1);
namespace App\Account\Infrastructure;
use Illuminate\Support\ServiceProvider;
final class RepositoryBinding extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->app->bind(
abstract: \App\Account\Domain\Repository\UserRepositoryInterface::class,
concrete: \App\Account\Infrastructure\Repository\UserRepository::class
);
$this->app->bind(
abstract: \App\Shared\Domain\Repository\UserRepositoryInterface::class,
concrete: \App\Account\Infrastructure\Repository\UserRepository::class
);
}
}
Using CQRS Components: User Registration and Profile Retrieval Examples
1. User Registration via Command and Process Pipeline
<?php declare(strict_types=1);
namespace App\Account\Application\Register;
use App\Shared\Application\Command\Command;
use WendellAdriel\ValidatedDTO\Casting\StringCast;
use WendellAdriel\ValidatedDTO\Attributes\Cast;
final class RegisterCommand extends Command
{
/**
* The name of the user to register.
*
* @var string
*/
#[Cast(type: StringCast::class, param: null)]
public string $name;
/**
* 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;
}
<?php declare(strict_types=1);
namespace App\Account\Application\Register;
use App\Shared\Application\Process;
use App\Account\Application\Register\Handler\FetchUserRoleHandler;
use App\Account\Application\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 = [
FetchUserRoleHandler::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;
}
}
}
<?php declare(strict_types=1);
namespace App\Account\Application\Register;
use App\Shared\Application\Job\Job;
final class RegisterJob extends Job
{
/**
* Create a new registration job instance.
*
* @param RegisterCommand $command
*/
public function __construct(
private readonly RegisterCommand $command
) {}
/**
* Handle the registration job.
*
* @param RegisterProcess $process
*/
public function handle(RegisterProcess $process): void
{
$process->run(command: $this->command);
}
}
<?php declare(strict_types=1);
namespace App\Account\Application\Register\Handler;
use App\Shared\Application\Handler;
use App\Account\Domain\Repository\RoleRepositoryInterface;
use App\Account\Application\Register\RegisterCommand;
use App\Shared\Domain\Slug\RoleSlug;
final class FetchUserRoleHandler extends Handler
{
/**
* Constructs a new FetchUserRoleHandler instance.
*
* @param RoleRepositoryInterface $repository
*/
public function __construct(
private RoleRepositoryInterface $repository
) {}
/**
* Handles fetching the 'user' role and adds it to the command.
*
* @param RegisterCommand $command
* @param \Closure $next
*
* @return mixed
*/
public function handle(RegisterCommand $command, \Closure $next): mixed
{
$role = $this->repository->findBySlug(
slug: RoleSlug::fromString(value: 'user')
);
if (is_null(value: $role)) {
throw new \RuntimeException(
message: 'Role "user" not found.'
);
}
$command->role = $role;
return $next($command);
}
}
<?php declare(strict_types=1);
namespace App\Account\Application\Register\Handler;
use App\Shared\Application\Handler;
use App\Account\Domain\Repository\UserRepositoryInterface;
use App\Account\Application\Register\RegisterCommand;
use App\Account\Domain\User;
use App\Account\Domain\Email\Email;
use App\Account\Domain\Password\Password;
use Illuminate\Support\Facades\Log;
final class RegisterUserHandler extends Handler
{
/**
* Constructs a new RegisterUserHandler instance.
*
* @param UserRepositoryInterface $repository
*/
public function __construct(
private UserRepositoryInterface $repository
) {}
/**
* Handler to create user, assign role, and save the user entity.
*
* @param RegisterCommand $command
* @param \Closure $next
*
* @return mixed
*/
public function handle(RegisterCommand $command, \Closure $next): mixed
{
try {
$user = new User(
name: $command->name,
email: Email::fromString(value: $command->email),
password: Password::fromPlain(value: $command->password),
);
$user->addRole(role: $command->role);
$this->repository->save(user: $user);
return $next($command);
}
catch (\Throwable $e) {
Log::error(
message: 'Register error: ' . $e->getMessage(),
context: ['exception' => $e]
);
return $next($command);
}
}
}
2. Retrieving the Authenticated User’s Profile
<?php declare(strict_types=1);
namespace App\Account\Application\Profile\Show;
use App\Shared\Application\Query\Query;
use App\Shared\Domain\Id\UserId;
use Illuminate\Support\Facades\Auth;
final class ShowProfileQuery extends Query
{
/**
* The unique identifier of the authenticated user.
*
* @var UserId
*/
public private(set) UserId $userId;
/**
* Constructs a new ShowProfileQuery instance.
*
* @throws \RuntimeException
*/
public function __construct()
{
/** @var \App\Account\Infrastructure\Auth\AuthUserAdapter|null $auth */
$auth = Auth::user();
if ($auth === null) {
throw new \RuntimeException(
message: 'No authenticated user found.'
);
}
$this->userId = $auth->user->id;
}
}
<?php declare(strict_types=1);
namespace App\Account\Application\Profile\Show;
use App\Shared\Application\Handler;
use App\Shared\Domain\Id\UserId;
use App\Account\Domain\Repository\UserRepositoryInterface;
use App\Account\Domain\User;
final class ShowProfileHandler extends Handler
{
/**
* Constructs a new ShowProfileHandler instance.
*
* @param UserRepositoryInterface $repository
*/
public function __construct(
private UserRepositoryInterface $repository
) {}
/**
* Handles ShowProfileQuery to retrieve a User by ID.
*
* @param ShowProfileQuery $query
* @return User|null
*/
public function handle(ShowProfileQuery $query): ?User
{
return $this->repository->findById(
id: $query->userId
);
}
}
This securely fetches user information by encapsulating the authenticated user ID and querying the repository.
Registering Command and Query Handlers with the Buses
<?php declare(strict_types=1);
namespace App\Account\Infrastructure\Dispatching;
use Illuminate\Support\ServiceProvider;
use App\Account\Application\Logout\LogoutQuery;
use App\Account\Application\Logout\LogoutHandler;
use App\Account\Application\Profile\Show\ShowProfileQuery;
use App\Account\Application\Profile\Show\ShowProfileHandler;
use App\Account\Application\Profile\Delete\DeleteProfileQuery;
use App\Account\Application\Profile\Delete\DeleteProfileHandler;
use App\Shared\Domain\Bus\QueryBusInterface;
final class QueryDispatcher extends ServiceProvider
{
/**
* Auth related query handlers
*
* @var array<class-string, class-string>
*/
private array $auth = [
LogoutQuery::class => LogoutHandler::class,
];
/**
* Profile related query handlers
*
* @var array<class-string, class-string>
*/
private array $profile = [
ShowProfileQuery::class => ShowProfileHandler::class,
DeleteProfileQuery::class => DeleteProfileHandler::class,
];
/**
* Bootstrap any application services.
*/
public function boot(QueryBusInterface $queryBus): void
{
$queryBus->register(map: [
...$this->auth,
...$this->profile,
]);
}
}
<?php declare(strict_types=1);
namespace App\Account\Infrastructure\Dispatching;
use Illuminate\Support\ServiceProvider;
use App\Account\Application\Register\RegisterCommand;
use App\Account\Application\Register\RegisterProcess;
use App\Account\Application\Login\LoginCommand;
use App\Account\Application\Login\LoginHandler;
use App\Account\Application\Profile\Update\UpdateProfileCommand;
use App\Account\Application\Profile\Update\UpdateProfileHandler;
use App\Shared\Domain\Bus\CommandBusInterface;
final class CommandDispatcher extends ServiceProvider
{
/**
* Auth related command handlers
*
* @var array<class-string, class-string>
*/
private array $auth = [
RegisterCommand::class => RegisterProcess::class,
LoginCommand::class => LoginHandler::class,
];
/**
* Profile related command handlers
*
* @var array<class-string, class-string>
*/
private array $profile = [
UpdateProfileCommand::class => UpdateProfileHandler::class,
];
/**
* Bootstrap any application services.
*/
public function boot(CommandBusInterface $commandBus): void
{
$commandBus->register(map: [
...$this->auth,
...$this->profile
]);
}
}
This setup enables automatic resolution and routing of commands and queries through the proper handlers.
Integrating CQRS with ADR Architecture: Action and Responder Examples
To maintain clean HTTP-layer separation, CQRS can be integrated with the ADR (Action-Domain-Responder) pattern:
<?php declare(strict_types=1);
namespace App\Account\Presentation\Action;
use App\Shared\Presentation\Controller as Action;
use App\Account\Application\Register\RegisterCommand;
use App\Account\Presentation\Request\RegisterRequest;
use App\Account\Presentation\Responder\RegisterResponder;
use App\Shared\Domain\Bus\CommandBusInterface;
use App\Shared\Presentation\Response\MessageResponse;
use Spatie\RouteAttributes\Attributes\Route;
use Spatie\RouteAttributes\Attributes\Prefix;
#[Prefix(prefix: 'v1')]
final class RegisterAction extends Action
{
/**
* Handles formatting and returning the registration response.
*
* @var RegisterResponder
*/
private readonly RegisterResponder $responder;
/**
* Constructs a new RegisterAction instance.
*
* @param CommandBusInterface $commandBus
*/
public function __construct(
private readonly CommandBusInterface $commandBus
) {
$this->responder = new RegisterResponder();
}
/**
* Handles user registration by creating a new user.
*
* @param RegisterRequest $request
* @return MessageResponse
*/
#[Route(methods: 'POST', uri: '/register')]
public function __invoke(RegisterRequest $request): MessageResponse
{
$command = RegisterCommand::fromRequest(request: $request);
return $this->responder->respond(
result: (bool) $this->commandBus->send(command: $command)
);
}
}
<?php declare(strict_types=1);
namespace App\Account\Presentation\Responder;
use App\Shared\Presentation\Response\MessageResponse;
use Illuminate\Http\Response;
final class RegisterResponder
{
/**
* Generate a response based on the registration result.
*
* @param bool $result
* @return MessageResponse
*/
public function respond(bool $result): MessageResponse
{
if ($result) {
return new MessageResponse(
message: 'Registration successful!',
status: Response::HTTP_CREATED
);
}
return new MessageResponse(
message: 'Registration failed. Try again.',
status: Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
<?php declare(strict_types=1);
namespace App\Account\Presentation\Action\Profile;
use App\Shared\Presentation\Controller as Action;
use App\Account\Application\Profile\Show\ShowProfileQuery;
use App\Account\Presentation\Responder\Profile\ShowProfileResponder;
use App\Shared\Domain\Bus\QueryBusInterface;
use App\Shared\Presentation\Response\ResourceResponse;
use Spatie\RouteAttributes\Attributes\Prefix;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Route;
use Illuminate\Http\Request;
#[Prefix(prefix: 'v1')]
#[Middleware(middleware: 'auth:api')]
final class ShowProfileAction extends Action
{
/**
* Formats and returns the profile response.
*
* @var ShowProfileResponder
*/
private readonly ShowProfileResponder $responder;
/**
* Constructs a new ProfileAction instance.
*
* @param QueryBusInterface $queryBus
*/
public function __construct(
private readonly QueryBusInterface $queryBus
) {
$this->responder = new ShowProfileResponder();
}
/**
* Handles the profile retrieval HTTP GET request.
*
* @return ResourceResponse
*/
#[Route(methods: 'GET', uri: '/profile')]
public function __invoke(): ResourceResponse
{
/** @var \App\Account\Domain\User|null $user */
$user = $this->queryBus->ask(
query: new ShowProfileQuery()
);
return $this->responder->respond(
result: $user
);
}
}
<?php declare(strict_types=1);
namespace App\Account\Presentation\Responder\Profile;
use App\Account\Domain\User;
use App\Account\Presentation\UserResource;
use App\Shared\Presentation\Response\ResourceResponse;
use Illuminate\Http\Response;
final class ShowProfileResponder
{
/**
* Creates a ResourceResponse based on the given User result.
*
* @param User|null $result
* @return ResourceResponse
*/
public function respond(?User $result): ResourceResponse
{
if ($result instanceof User) {
return new ResourceResponse(
data: new UserResource(resource: $result),
status: Response::HTTP_OK
);
}
return new ResourceResponse(
data: ['message' => 'User not found.'],
status: Response::HTTP_NOT_FOUND
);
}
}
This approach cleanly separates HTTP request handling, domain logic dispatching, and response generation.
Conclusion: Benefits and Next Steps with CQRS in Laravel
This article demonstrated a robust way to implement CQRS in Laravel 12 by defining clear interfaces for Command and Query buses, wiring them into Laravel's service container, and organizing domain repositories to isolate concerns.
We explored real-world examples that show commands and queries being created, dispatched, and handled in decoupled, testable manners. This architecture enables scalable maintenance and parallel development.
CQRS investments pay off with clearer codebases and extensibility. You can further enhance this foundation by adding event sourcing, validation pipelines, or asynchronous messaging to fit your project's needs.
Top comments (1)
Great article. Would be interesting if you revisit the article and include using Laravel Verbs for event sourcing with CQRS - verbs.thunk.dev/