Skip to content

CQRS in Laravel Without a Framework: Commands, Handlers, and Query Objects

Skip the heavy CQRS libraries. Learn how to implement commands, command handlers, and query objects in plain Laravel using the service container, making your application testable and intent-revealing.

Published · Updated · 3 min read

CQRS in Laravel Without a Framework: Commands, Handlers, and Query Objects

Why Roll Your Own CQRS Layer?

Heavy CQRS packages bring conventions you may not need and abstractions that obscure what is actually happening. For most Laravel applications a thin, explicit layer — commands, handlers, and query objects wired through the service container — delivers 90% of the benefit with none of the magic.

This article builds that layer from scratch.


The Command Bus

A command is a plain DTO expressing intent. A handler executes it. A bus resolves the handler and calls it.

// app/CQRS/CommandBus.php
namespace App\CQRS;

use Illuminate\Contracts\Container\Container;

final class CommandBus
{
    public function __construct(private readonly Container $container) {}

    public function dispatch(object $command): mixed
    {
        $handlerClass = $this->resolveHandler($command);
        $handler = $this->container->make($handlerClass);

        return $handler->handle($command);
    }

    private function resolveHandler(object $command): string
    {
        // Convention: App\Commands\FooCommand → App\Handlers\FooHandler
        $base = class_basename($command);
        $handler = str_replace('Command', 'Handler', $base);
        $class = 'App\\Handlers\\' . $handler;

        if (! class_exists($class)) {
            throw new \RuntimeException("No handler found for {$base}");
        }

        return $class;
    }
}

Register it as a singleton in AppServiceProvider:

$this->app->singleton(CommandBus::class);

A Concrete Command and Handler

// app/Commands/RegisterUserCommand.php
namespace App\Commands;

final readonly class RegisterUserCommand
{
    public function __construct(
        public string $email,
        public string $name,
        public string $plainPassword,
    ) {}
}
// app/Handlers/RegisterUserHandler.php
namespace App\Handlers;

use App\Commands\RegisterUserCommand;
use App\Models\User;
use Illuminate\Support\Facades\Hash;

final class RegisterUserHandler
{
    public function handle(RegisterUserCommand $command): User
    {
        return User::create([
            'email'    => $command->email,
            'name'     => $command->name,
            'password' => Hash::make($command->plainPassword),
        ]);
    }
}

In a controller:

$user = $bus->dispatch(new RegisterUserCommand(
    email: $request->email,
    name:  $request->name,
    plainPassword: $request->password,
));

The controller knows nothing about hashing, model creation, or side effects.


Query Objects Instead of Fat Repositories

Query objects encapsulate a single read concern. They return data, never modify state.

// app/Queries/ActiveSubscribersQuery.php
namespace App\Queries;

use App\Models\User;
use Illuminate\Pagination\CursorPaginator;

final class ActiveSubscribersQuery
{
    public function __construct(
        private readonly int $perPage = 50,
        private readonly ?string $cursor = null,
    ) {}

    public function get(): CursorPaginator
    {
        return User::query()
            ->whereHas('subscription', fn ($q) => $q->active())
            ->orderBy('id')
            ->cursorPaginate($this->perPage, cursor: $this->cursor);
    }
}

Call it directly — no bus needed for queries:

$page = (new ActiveSubscribersQuery(
    perPage: 25,
    cursor: $request->query('cursor'),
))->get();

Testing with Pest

Handlers are plain classes, so testing is straightforward:

it('creates a user with a hashed password', function () {
    $command = new RegisterUserCommand(
        email: 'alice@example.com',
        name: 'Alice',
        plainPassword: 'secret',
    );

    $user = app(RegisterUserHandler::class)->handle($command);

    expect($user)
        ->email->toBe('alice@example.com')
        ->and(Hash::check('secret', $user->password))->toBeTrue();
});

No HTTP overhead, no mocking the bus — just the handler under test.


Middleware on the Bus

Add cross-cutting concerns (logging, transactions, authorization) by wrapping dispatch:

public function dispatch(object $command): mixed
{
    return DB::transaction(
        fn () => $this->resolveAndCall($command)
    );
}

For finer control, accept an array of middleware callables and pipe the command through them before reaching the handler — the same pattern Laravel's own Pipeline uses.


Key Takeaways

  • Commands are write-only DTOs; handlers own all mutation logic.
  • Query objects replace ad-hoc repository methods with focused, testable read concerns.
  • Convention-based handler resolution keeps the bus tiny; swap it for explicit mapping when you need more control.
  • No package dependency means you own the abstraction and can evolve it freely.
  • Handlers are plain classes — Pest tests are fast and require no HTTP layer.
  • Wrapping dispatch in a DB transaction is a one-liner that protects every command uniformly.

Frequently asked questions

Should every controller action go through the command bus?

No. Simple CRUD with no domain logic can stay in the controller or a single action class. Introduce the bus where you have meaningful side effects, multiple steps, or logic you want to test in isolation.

How do I handle commands that need to dispatch jobs or fire events?

Do it inside the handler. Handlers can inject the Queue, EventDispatcher, or any other service via the container. Keeping side effects inside the handler keeps the controller ignorant of them.

Is this pattern compatible with Filament actions?

Yes. Filament's action closures receive the record and form data, so you can instantiate a command and call the bus directly inside the action's `action` callback without any extra wiring.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project