CQRS Without Event Sourcing in Laravel
CQRS — Command Query Responsibility Segregation — is often bundled with event sourcing in tutorials, which makes it feel heavyweight. In practice, the core idea is simpler: commands mutate state, queries read it, and the two never share the same model. You can apply this in a standard Laravel application today, with Eloquent and no extra infrastructure.
Why Bother Separating Models?
When a single Eloquent model handles both writes and complex reporting queries, it accumulates pressure from both directions. Write logic demands validation, business rules, and transactional integrity. Read logic demands joins, aggregates, and performance tuning. Separating them lets each evolve independently.
The Command Side
A command is a plain PHP object expressing intent. A handler executes it.
// app/Commands/RegisterUser.php
final readonly class RegisterUser
{
public function __construct(
public string $email,
public string $name,
public string $plainPassword,
) {}
}
// app/Handlers/RegisterUserHandler.php
final class RegisterUserHandler
{
public function __construct(
private UserRepository $users,
private Hasher $hasher,
) {}
public function handle(RegisterUser $command): void
{
if ($this->users->existsByEmail($command->email)) {
throw new EmailAlreadyTaken($command->email);
}
$this->users->save(new User(
email: $command->email,
name: $command->name,
password: $this->hasher->make($command->plainPassword),
));
}
}
Bind the handler in a service provider and dispatch via the container:
// In a controller or action
$this->app->make(RegisterUserHandler::class)
->handle(new RegisterUser($request->email, $request->name, $request->password));
Or build a lightweight command bus that resolves {CommandClass}Handler by convention, keeping controllers thin.
The Query Side
Query objects encapsulate a read concern. They return view-specific DTOs, not write-model Eloquent instances.
// app/Queries/ActiveUsersQuery.php
final class ActiveUsersQuery
{
public function __construct(private Connection $db) {}
/** @return list<UserSummaryDTO> */
public function paginate(int $perPage = 25): LengthAwarePaginator
{
return $this->db
->table('users')
->select('id', 'name', 'email', 'created_at')
->where('active', true)
->orderByDesc('created_at')
->paginate($perPage)
->through(fn ($row) => new UserSummaryDTO(
id: $row->id,
name: $row->name,
email: $row->email,
joinedAt: CarbonImmutable::parse($row->created_at),
));
}
}
Using DB::table() instead of an Eloquent model on the read side is intentional. It removes the temptation to call write methods on a read result and keeps the query lean.
Wiring It Together Without a Framework
You don't need a dedicated CQRS package. A simple command bus using the service container is enough:
final class CommandBus
{
public function __construct(private Container $container) {}
public function dispatch(object $command): void
{
$handlerClass = str_replace('\\Commands\\', '\\Handlers\\', $command::class) . 'Handler';
$this->container->make($handlerClass)->handle($command);
}
}
Register it as a singleton and inject it wherever needed. The naming convention keeps discovery automatic.
Keeping the Write Model Clean
The write-side User Eloquent model should expose only what mutations need: fillable attributes, relationships used during writes, and domain events if you fire them. Strip out scopeActive(), withCount(), and any accessor that only serves a view. Those belong in query objects.
Testing the Separation
it('rejects duplicate emails', function () {
User::factory()->create(['email' => 'taken@example.com']);
expect(fn () => app(CommandBus::class)->dispatch(
new RegisterUser('taken@example.com', 'Alice', 'secret')
))->toThrow(EmailAlreadyTaken::class);
});
Query objects are tested by seeding the database and asserting the shape of the returned DTOs — no mocking required.
Takeaways
- Commands express intent; handlers enforce business rules and write to the database.
- Query objects own read concerns; they return DTOs, not Eloquent models.
- Using
DB::table()on the read side removes accidental write surface area. - A convention-based command bus needs fewer than 15 lines and zero packages.
- The pattern scales: swap the query object's data source for a read replica or a cache layer without touching the command side.