Implementing Commands and Queries in Laravel 12

  • avatar
  • 79 Views
  • 8 mins read

CQRS, short for Command Query Responsibility Segregation, is a simple yet powerful way to split the responsibility for writing and reading data in an application. Instead of having a single service or model method that both changes and retrieves data, you give commands the job of making changes and queries the job of fetching information. This split can make large projects much easier to follow, especially as business rules grow and read and write requirements start to differ.

In Laravel 12, CQRS is not something you enable with a flag. It is a pattern you implement yourself, and the framework's service container and dependency injection make it straightforward to set up. Let's go through a practical way to apply it, keeping things clear and avoiding unnecessary complexity.

Folder structure

A consistent folder structure is essential to keep commands, queries, handlers, and domain contracts organized. By separating layers, you make it easy to locate files and maintain the project over time.

app
├── Application
│ └── User
│ ├── Command
│ │ ├── CreateUserCommand.php
│ │ └── CreateUserCommandHandler.php
│ └── Query
│ ├── GetUserByIdQuery.php
│ └── GetUserByIdQueryHandler.php
├── Domain
│ ├── Shared
│ │ └── Bus
│ │ ├── Command.php
│ │ └── Query.php
│ └── User
│ └── UserRepositoryInterface.php
└── Infrastructure
├── Controllers
│ └── ...
└── Laravel
├── Providers
│ └── CqrsServiceProvider.php
└── Controller.php

This layout keeps responsibilities clear. Domain contains core business rules, Application contains use case logic, and Infrastructure integrates with Laravel.

Domain layer

The domain layer is the core of your application. It defines entities, value objects, and contracts without knowing anything about Laravel or how data is stored. Keeping business logic isolated ensures it is easier to test, maintain, and adapt to future changes.

User repository interface

The repository interface defines persistence contracts for the User entity:

<?php

namespace App\\Domain\\User;

interface UserRepositoryInterface
{
public function save(User $user): void;

public function searchById(string $id): ?User;
}

The repository interface allows the Application layer to interact with users abstractly, without depending on a database implementation. This decouples your business logic from storage details.

Shared bus interfaces

To clearly separate commands from queries, we define empty interfaces. These are used as type markers to indicate whether a class is intended to mutate state (command):

<?php

namespace App\\Domain\\Shared\\Bus;

interface Command {}

Or fetch data (query):

<?php

namespace App\\Domain\\Shared\\Bus;

interface Query {}

These interfaces do not contain any logic but are crucial for type safety and for Laravel to identify dispatchable objects in the system. This way, handlers can be automatically resolved without ambiguity.

Application layer

The application layer orchestrates your use cases. Commands carry data needed for operations that change state, and queries carry data to fetch information. Handlers contain the logic that interacts with the domain, repositories, or other services.

CreateUser command and handler

The command represents the intention to create a user:

<?php

namespace App\\Application\\User\\Command;

use App\\Domain\\Shared\\Bus\\Command;

final readonly class CreateUserCommand implements Command
{
public function __construct(
public string $name,
public string $email
) {}
}

The command only carries data. It does not perform any business logic, making it simple to test and understand. The handler executes the command:

<?php

namespace App\\Application\\User\\Command;

use App\\Domain\\User\\User;
use App\\Domain\\User\\UserRepositoryInterface;

final class CreateUserCommandHandler
{
public function __construct(private UserRepositoryInterface $repository) {}

public function __invoke(CreateUserCommand $command): void
{
$user = new User($command->name, $command->email);
$this->repository->save($user);
}
}

The handler is responsible for creating a User and saving it. By separating the command from the handler, each part can be tested individually.

GetUserById query and handler

The query carries data for fetching a user:

<?php

namespace App\\Application\\User\\Query;

use App\\Domain\\Shared\\Bus\\Query;

final readonly class GetUserByIdQuery implements Query
{
public function __construct(public string $id) {}
}

Queries carry data needed to fetch information but do not modify state. This ensures a strict separation between reads and writes. The query handler executes the fetching logic:

<?php

namespace App\\Application\\User\\Query;

use App\\Domain\\User\\User;
use App\\Domain\\User\\UserRepositoryInterface;

final class GetUserByIdHandler
{
public function __construct(private UserRepositoryInterface $repository) {}

public function __invoke(GetUserByIdQuery $query): ?User
{
return $this->repository->searchById($query->id);
}
}

The handler interacts with the repository to retrieve the user. Reads remain independent from writes, keeping logic simple and maintainable.

Infrastructure layer

The infrastructure layer connects your CQRS setup with Laravel. It handles service providers, dispatching commands and queries, and any Laravel-specific integration needed for the system to function.

EventServiceProvider

Laravel's built-in EventServiceProvider can be used to automatically register all commands and queries as dispatchable events. With this setup, each command or query is linked to its corresponding handler, and Laravel resolves the handler when the event is dispatched.

<?php

namespace App\\Infrastructure\\Laravel\\Providers;

use Illuminate\\Foundation\\Support\\Providers\\EventServiceProvider;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;

final class CqrsServiceProvider extends EventServiceProvider
{
public function register(): void
{
// Directory to search (recursively)
$applicationLayerPath = app_path('Application');

// Recursive iterator to get all PHP files
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($applicationLayerPath)
);

// Dispatchable event handlers
$handlers = [];

/**
* Iterate through each file in the iterator
* @var SplFileInfo $file
*/
foreach ($iterator as $file) {
// Check if the file is a PHP file and ends with "Query.php" or "Command.php"
if ($file->isFile() && $this->isDispatchable($file->getFilename())) {
$filePathNamespace = str_replace([app_path() . '/', '/'], ['', '\\\\'], $file->getPath());
$class = sprintf('App\\%s\\%s', $filePathNamespace, $file->getBasename('.php'));
$handlers[$class] = [sprintf('%sHandler', $class)];
}
}

$this->listen = $handlers;

parent::register();
}

private function isDispatchable(string $filename): bool
{
return (bool) preg_match('/(?:Query|Command)\\.php$/', $filename);
}
}

This provider scans the Application layer and automatically registers all commands and queries with their corresponding handlers. It keeps the system dynamic and reduces manual wiring.

To make it work, ensure it is registered in your application. Open config/app.php and add it to the providers array if it's not already there:

<?php

return [
App\\Providers\\AppServiceProvider::class,
App\\Infrastructure\\Laravel\\Providers\\CqrsServiceProvider::class,

// Other Laravel service providers...
];

Laravel will now load this provider on each request and automatically wire up your commands and queries with their handlers.

Base controller for dispatching

All other controllers extend this base controller to dispatch commands and execute queries:

<?php

namespace App\\Infrastructure\\Laravel;

use App\\Domain\\Shared\\Bus\\Command;
use App\\Domain\\Shared\\Bus\\Query;
use Illuminate\\Events\\Dispatcher;

abstract class Controller
{
private Dispatcher $bus;

public function __construct(Dispatcher $dispatcher)
{
$this->bus = $dispatcher;
}

protected function dispatch(Command $command): void
{
$this->bus->dispatch($command);
}

protected function ask(Query $query)
{
$response = $this->bus->dispatch($query);

return $response[0] ?? null;
}
}

The base controller centralizes dispatching. Any controller can extend it and use $this->dispatch($command) for commands or $this->ask($query) for queries.

Example controller using CQRS

Here's a practical example of a controller extending the base controller:

<?php

namespace App\\Infrastructure\\Controllers;

use App\\Application\\User\\Command\\CreateUserCommand;
use App\\Application\\User\\Query\\GetUserByIdQuery;
use App\\Infrastructure\\Laravel\\Controller;
use Illuminate\\Http\\JsonResponse;

class UserController extends Controller
{
public function createUser(): JsonResponse
{
$this->dispatch(new CreateUserCommand(name: 'Alice', email: '[email protected]'));

return response()->json(['message' => 'User created']);
}

public function getUser(string $id): JsonResponse
{
$user = $this->ask(new GetUserByIdQuery(id: $id));

if (!$user) {
return response()->json(['message' => 'User not found'], 404);
}

return response()->json(['id' => $user->id, 'name' => $user->name, 'email' => $user->email]);
}
}

This example shows how a controller can create and fetch users using the CQRS setup. The base controller handles dispatching, keeping the HTTP controller clean and focused.

Conclusion

With this setup, the responsibilities of the system are clearly separated. The domain layer focuses on business rules, the application layer orchestrates use cases with commands and queries, and infrastructure handles Laravel integration and dispatching. Using a base controller for dispatching keeps all HTTP controllers clean, testable, and focused only on web concerns, making your application easier to maintain and scale over time.

Credits

Official GitHub: https://github.com/hibit-dev/laravel12-cqrs

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.