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
dispatchin a DB transaction is a one-liner that protects every command uniformly.