CQRS Without Event Sourcing: Practical Command and Query Separation in Laravel
#laravel #cqrs #architecture #ddd #testing

CQRS Without Event Sourcing: Practical Command and Query Separation in Laravel

4 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need a package like Laravel Actions or Tactician to implement CQRS in Laravel?
No. A plain PHP class with a `handle` method and Laravel's service container for dependency injection is sufficient. Packages can add convenience, but the pattern itself requires no third-party dependency.
Q02 How is a query object different from a repository method?
A repository method is a reusable data-access primitive. A query object encapsulates a specific read use-case — including its parameters and pagination — and maps directly to one screen or API endpoint. Repositories can be used inside query handlers.
Q03 Should command handlers fire Laravel events?
They can, but keep it intentional. Firing a `UserRegistered` event from a handler is fine for side effects like sending welcome emails. Avoid firing events just to trigger other commands — that creates implicit coupling that is hard to trace.

Continue reading

More Articles

View all