CQRS Without Event Sourcing: Practical Command and Query Separation in Laravel
CQRS — Command Query Responsibility Segregation — is often introduced alongside event sourcing, which makes it feel heavyweight. But the core idea is simple: code that changes state and code that reads state should not share the same path. You can apply this in any Laravel application today, without an event store, without projectors, and without a new framework.
This article shows a concrete, opinionated approach using typed command objects, action classes, and dedicated query objects.
The Problem With Fat Controllers and Service Classes
A typical Laravel service class ends up with methods like create, update, delete, getById, listForUser, and export. These mix reads and writes, making each method harder to test in isolation and easier to accidentally couple.
Separating commands from queries forces you to think about intent at the boundary, not inside the implementation.
Commands and Command Handlers
A command is a typed DTO that expresses intent. It carries only what is needed to perform the operation.
final readonly class RegisterUserCommand
{
public function __construct(
public string $email,
public string $name,
public string $plainPassword,
) {}
}
A command handler (or action) performs the side effect. One class, one public method, no return value beyond the created model when strictly needed.
final class RegisterUserHandler
{
public function __construct(
private readonly UserRepository $users,
private readonly Hasher $hasher,
) {}
public function handle(RegisterUserCommand $command): User
{
return $this->users->create([
'email' => $command->email,
'name' => $command->name,
'password' => $this->hasher->make($command->plainPassword),
]);
}
}
Dispatch it from a controller:
public function store(RegisterUserRequest $request, RegisterUserHandler $handler): JsonResponse
{
$user = $handler->handle(new RegisterUserCommand(
email: $request->validated('email'),
name: $request->validated('name'),
plainPassword: $request->validated('password'),
));
return UserResource::make($user)->response()->setStatusCode(201);
}
Laravel's service container resolves RegisterUserHandler automatically, injecting its dependencies.
Query Objects for Read Paths
Queries are not commands. They should never trigger side effects. A query object encapsulates a read operation and its parameters.
final readonly class ActiveUsersQuery
{
public function __construct(
public int $perPage = 25,
public ?string $search = null,
) {}
}
final class ActiveUsersQueryHandler
{
public function handle(ActiveUsersQuery $query): LengthAwarePaginator
{
return User::query()
->where('status', UserStatus::Active)
->when($query->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%"))
->orderByDesc('created_at')
->paginate($query->perPage);
}
}
Query handlers can be optimised independently — add caching, switch to a read replica, or replace Eloquent with a raw query — without touching any command path.
Read Replica Binding
Because query handlers are their own classes, you can bind them to a read-only database connection via contextual binding:
$this->app->when(ActiveUsersQueryHandler::class)
->needs('$connection')
->give('mysql_read');
Or inject a dedicated read model repository that always uses DB::connection('mysql_read').
Wiring With a Command Bus (Optional)
For larger applications, a simple command bus removes the need to inject specific handler classes into controllers:
final class CommandBus
{
public function __construct(private readonly Container $container) {}
public function dispatch(object $command): mixed
{
$handlerClass = str(class_basename($command))
->replace('Command', 'Handler')
->prepend(app()->getNamespace() . 'Commands\\');
return $this->container->make($handlerClass)->handle($command);
}
}
This is a lightweight convention-based bus. No third-party package required.
Testing Commands and Queries in Isolation
With Pest, each handler is trivially unit-testable:
it('hashes the password before persisting', function () {
$repo = Mockery::mock(UserRepository::class);
$repo->expects('create')
->withArgs(fn ($data) => Hash::check('secret', $data['password']))
->andReturn(new User());
$handler = new RegisterUserHandler($repo, app(Hasher::class));
$handler->handle(new RegisterUserCommand('a@b.com', 'Alice', 'secret'));
});
No HTTP layer, no database, no Eloquent boot cycle.
Key Takeaways
- Commands express intent and carry only the data needed to perform one operation.
- Query objects encapsulate read logic and can be optimised, cached, or rerouted to a replica independently.
- Handlers are single-responsibility classes — easy to test, easy to swap.
- A convention-based command bus removes controller coupling without a heavy library.
- CQRS does not require event sourcing; the pattern pays dividends in any medium-to-large Laravel codebase.