CQRS Without Event Sourcing: Practical Read/Write Model Separation in Laravel
#laravel #cqrs #architecture #ddd #eloquent

CQRS Without Event Sourcing: Practical Read/Write Model Separation in Laravel

4 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need a dedicated CQRS package like Tactician or Broadway for this pattern?
No. A convention-based command bus backed by Laravel's service container is sufficient for most applications. Third-party packages add value when you need middleware pipelines on every command, but the core pattern requires no external dependencies.
Q02 Should query objects use Eloquent or the query builder?
Prefer the query builder (`DB::table()`) for read-only queries. It removes the risk of accidentally calling write methods on a result and avoids loading Eloquent's model overhead when you only need raw data shaped into a DTO.
Q03 How does this differ from the Repository pattern?
Repositories abstract persistence for the write model and are a natural companion to command handlers. Query objects are separate — they bypass the repository entirely and talk directly to the database for read-optimised queries, keeping the two concerns cleanly divided.

Continue reading

More Articles

View all