Splitting the Stack: Architecting CQRS in Laravel 🧱

php dev.to

The Asymmetry of Enterprise Data

In standard Laravel applications, the Eloquent ORM is typically used for everything. You use User::create() to write data, and you use User::with('orders')->get() to read data. This is the traditional CRUD (Create, Read, Update, Delete) paradigm. While exceptionally fast to develop, this unified model breaks down under immense enterprise scale.

In most applications, data access is highly asymmetrical. A system might process 100 write operations per second (e.g., updating a user's location), but it might process 10,000 read operations per second (e.g., users viewing a global dashboard). When you use the exact same database table, the exact same indexes, and the exact same Eloquent models for both reading and writing, you are forcing a massive compromise. Your database indexes that speed up your queries simultaneously slow down your inserts. Your complex domain logic that validates a write operation gets tangled up with the lightweight logic needed to just display a view.

At Smart Tech Devs, when performance and scalability are non-negotiable, we separate these concerns entirely by implementing CQRS (Command Query Responsibility Segregation).

Understanding the CQRS Philosophy

CQRS dictates a strict architectural boundary: methods that change state (Commands) must not return data, and methods that return data (Queries) must not change state. We physically separate the application into two distinct stacks.

  • The Command Stack: Optimized exclusively for writing data. It contains complex business validation, domain rules, and dispatches events. It typically writes to a highly normalized, relational database (Primary Database).
  • The Query Stack: Optimized exclusively for reading data. It contains zero business logic. It reads from denormalized "Projections" (often a Read Replica database, Redis, or Elasticsearch) tailored specifically for the UI to consume instantly.

Phase 1: Architecting the Command Stack

Let's build a Command Stack for approving a financial loan. Instead of placing this logic in a Controller, we create a dedicated Command object and a CommandHandler.


namespace App\Domain\Loans\Commands;

// 1. The Command Object (A simple Data Transfer Object)
class ApproveLoanCommand
{
    public function __construct(
        public readonly string $loanId,
        public readonly string $approvedByUserId
    ) {}
}

Now, we build the Handler. This class executes the complex business rules. Notice that this handler does not return the Loan object back to the controller. It only returns void (or a success boolean).


namespace App\Domain\Loans\Handlers;

use App\Domain\Loans\Commands\ApproveLoanCommand;
use App\Models\Loan;
use Exception;
use Illuminate\Support\Facades\DB;

class ApproveLoanCommandHandler
{
    public function handle(ApproveLoanCommand $command): void
    {
        DB::transaction(function () use ($command) {
            $loan = Loan::findOrFail($command->loanId);

            // Complex Domain Logic
            if ($loan->status !== 'pending') {
                throw new Exception("Only pending loans can be approved.");
            }

            // Mutate State
            $loan->status = 'approved';
            $loan->approved_by = $command->approvedByUserId;
            $loan->save();

            // Dispatch an event to update the Read Models asynchronously
            event(new \App\Events\LoanApproved($loan->id));
        });
    }
}

Phase 2: Architecting the Query Stack

On the read side, we abandon Eloquent ORM. Eloquent is fantastic for hydrating objects, but it introduces memory overhead. If our API just needs to return a list of approved loans to a dashboard, we use raw SQL or the Query Builder to fetch exactly what the UI needs, straight from a Read Replica.


namespace App\Domain\Loans\Queries;

use Illuminate\Support\Facades\DB;

class GetApprovedLoansQuery
{
    public function execute(int $limit = 50): array
    {
        // We use the 'replica' database connection specifically optimized for reads.
        // We bypass Eloquent hydration completely, returning raw, blazing-fast arrays.
        return DB::connection('mysql_read_replica')
            ->table('loans_dashboard_projection') // A denormalized table tailored for the UI
            ->where('status', 'approved')
            ->select(['id', 'borrower_name', 'amount', 'approved_at'])
            ->limit($limit)
            ->get()
            ->toArray();
    }
}

Phase 3: Database Segregation in Laravel

To make the architectural split physical, we configure Laravel to route all Eloquent write operations to our primary database cluster, and all Read operations to our read replicas. Laravel supports this natively in config/database.php.


// config/database.php

'mysql' => [
    'driver' => 'mysql',
    
    // 1. The Write Connection (Command Stack)
    'write' => [
        'host' => [env('DB_HOST_PRIMARY')],
    ],
    
    // 2. The Read Connections (Query Stack)
    'read' => [
        'host' => [
            env('DB_HOST_REPLICA_1'),
            env('DB_HOST_REPLICA_2'),
        ],
    ],
    
    'sticky' => true, // Prevents immediate read-after-write inconsistencies
    
    'database' => env('DB_DATABASE'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
    // ...
],

The Engineering ROI and Eventual Consistency

Implementing CQRS fundamentally changes how your enterprise application scales. By separating the read and write models, you can scale them independently. If your application experiences a massive spike in dashboard views, you simply spin up five more Read Replicas without touching your Primary database. Because your Queries no longer rely on complex SQL JOINs (as they read from denormalized projections), your API response times plummet to single-digit milliseconds.

The trade-off is Eventual Consistency. Because writes update the primary database and events subsequently update the read replicas, there might be a 50-millisecond delay before a newly approved loan appears on the dashboard. In modern enterprise architecture, embracing this micro-delay is the ultimate key to unlocking infinite, unbottlenecked scalability.

Source: dev.to

arrow_back Back to Tutorials