Ivan Centamori

EN IT


The CQRS Pattern

If you've ever worked on a web application that started small but grew over time into a medium or large-scale project, you are well aware of the code maintainability problem. At first, everything is clean and tidy, but as the project expands, Controllers become massive files with thousands of lines, and Models turn into God Objects (omniscient monsters) that handle everything: complex relationships, saving business logic, calculating statistics, all the way to sending emails or making external API calls.

Imagine a classic UserController@store: it receives data, creates the user, assigns them a role, writes a log to the database, subscribes the user to a Mailchimp newsletter, and finally fires off a welcome email. A nightmare to read, maintain, and test.

This is where the CQRS (Command Query Responsibility Segregation) pattern comes into play. It's an architectural pattern that is conceptually very simple and straightforward: the core principle is to separate the operations that read data from those that modify it.

In a traditional CRUD (Create, Read, Update, Delete) architecture, we use the same Eloquent model (e.g., User) both to save a new user and to retrieve the user list. The problem is that read and write needs are almost always different. To display a dashboard, you might need to JOIN 5 different tables, whereas to update a user's name, you only need to touch a single record.

With CQRS, we abandon the "all-in-one" approach and divide our system into two distinct hemispheres:

Pros and Cons

Introducing CQRS adds an initial layer of complexity: we'll find ourselves creating more files (classes) and directories compared to a basic approach. However, in enterprise or fast-growing applications, the long-term benefits will be huge:

  1. Asymmetric optimization: In almost all applications, reads are much more frequent than writes (often with a 10:1 or 100:1 ratio). Think of an e-commerce product catalog: thousands of users view products (Read), but the administrator only rarely changes the price or stock (Write). By separating responsibilities, you can optimize the two sides independently. You could use heavy and secure Eloquent transactions for writes, and raw SQL queries or an in-memory database (like Redis or Elasticsearch) for lightning-fast reads.
  2. Lean and responsible Controllers: Forget the old mantra "Fat Model, Skinny Controller". With CQRS we switch to "Skinny Controller, Focused Handlers". Your controllers will literally shrink down to 3-4 lines of code. Their sole purpose will be to receive the HTTP Request, validate it, and delegate the work by "firing" a Command or a Query.
  3. Extreme testability: Testing an entire Controller means having to mock HTTP requests, middleware, and routing. In CQRS, the logic resides in pure PHP classes (the Handlers). In Pest or PHPUnit, all you have to do is instantiate your Command with dummy data, pass it to the Handler, and verify the output in the database. Simple and isolated.
  4. "Speaking" code (Screaming Architecture): As theorized by Uncle Bob (Robert C. Martin), your software architecture should "scream" its intent. Reading a directory full of files like BanUserCommand, ApplyDiscountCodeCommand, and GetTrendingArticlesQuery tells you exactly, at a glance, what your application does from a business perspective, without having to open and decipher routes or controller methods.

Implementing CQRS in Laravel

Laravel doesn't force the use of CQRS, but it offers us out-of-the-box all the native tools needed to implement it elegantly, such as the Command Bus (the Bus facade).

1. Project structure

Let's abandon for a moment the idea of throwing all the logic into app/Http/Controllers (which should only handle the HTTP transport layer) and create a CQRS namespace dedicated to our business logic:

app/
├── CQRS/
│   ├── Commands/
│   │   └── Article/
│   │       ├── PublishArticleCommand.php
│   │       └── PublishArticleHandler.php
│   └── Queries/
│       └── Article/
│           ├── GetPublishedArticlesQuery.php
│           └── GetPublishedArticlesHandler.php

In this structure, we'll have two main subfolders dedicated to Queries and Commands, which we can further divide by scope or entity (like Article in our case).

You'll notice that for every action we have two distinct files:

2. The write side: Commands and Handlers

Our Command is nothing more than a DTO (Data Transfer Object). By leveraging the readonly properties introduced in recent PHP versions, we can create an immutable object perfect for transporting data from the Controller to the Handler.

namespace App\CQRS\Commands\Article;

class PublishArticleCommand
{
    public function __construct(
        public readonly string $title,
        public readonly string $content,
        public readonly int $authorId
    ) {}
}

The Command Handler, on the other hand, is the operational class that will contain the business logic. Its only public method will be handle(), which will receive the newly created Command and execute the action on the database.

namespace App\CQRS\Commands\Article;

use App\Models\Article;
use Illuminate\Support\Str;

class PublishArticleHandler
{
    public function handle(PublishArticleCommand $command): Article
    {
        $article = Article::create([
            'title' => $command->title,
            'slug' => Str::slug($command->title),
            'content' => $command->content,
            'author_id' => $command->authorId,
            'published_at' => now(),
        ]);

        return $article;
    }
}

3. The read side: Queries and Handlers

The Query object works exactly like the Command: it transports our search parameters.

namespace App\CQRS\Queries\Article;

class GetPublishedArticlesQuery
{
    public function __construct(
        public readonly int $limit = 10,
        public readonly ?string $search = null
    ) {}
}

And the corresponding Query Handler? Here lies the true superpower of CQRS. Since we are only reading, we are not forced to instantiate heavy Eloquent models. If our page needs maximum speed, we can use Laravel's native Query Builder or even query a cache.

namespace App\CQRS\Queries\Article;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;

class GetPublishedArticlesHandler
{
    public function handle(GetPublishedArticlesQuery $query): Collection
    {
        $dbQuery = DB::table('articles')
            ->select('id', 'title', 'slug', 'published_at')
            ->whereNotNull('published_at');

        if ($query->search) {
            $dbQuery->where('title', 'like', '%' . $query->search . '%');
        }

        return $dbQuery->orderByDesc('published_at')
                       ->limit($query->limit)
                       ->get();
    }
}

4. The Command Bus

At this point, we have the pieces of the puzzle, but how do we tell Laravel that when we dispatch a PublishArticleCommand it must execute exactly the PublishArticleHandler? We use the Bus facade.

We can create a dedicated Service Provider and map our classes in the boot method:

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Bus;

class CqrsServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Bus::map([
            PublishArticleCommand::class => PublishArticleHandler::class,
            GetPublishedArticlesQuery::class => GetPublishedArticlesHandler::class,
        ]);
    }
}

5. The Controller

Instead of creating a "monster" capable of validating, manipulating strings, saving to the database, and firing events, our controller now only does two things: it receives a validated request and routes it.

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Bus;
use App\Http\Requests\StoreArticleRequest;
use App\CQRS\Commands\Article\PublishArticleCommand;
use App\CQRS\Queries\Article\GetPublishedArticlesQuery;

class ArticleController extends Controller
{
    public function index()
    {
        $query = new GetPublishedArticlesQuery(
            limit: request('limit', 20),
            search: request('search')
        );

        $articles = Bus::dispatchSync($query);

        return response()->json($articles);
    }

    public function store(StoreArticleRequest $request)
    {
        $validated = $request->validated();

        $command = new PublishArticleCommand(
            $validated['title'],
            $validated['content'],
            auth()->id()
        );

        $article = Bus::dispatchSync($command);

        return response()->json($article, 201);
    }
}

Adopting CQRS in Laravel radically changes the way you think about your application. The separation of intents (reading vs. writing) forces you to reason in terms of behaviors ("What does the user want to do right now?") rather than simple CRUD interactions with a database.

Sure, it doesn't make sense to use this architecture for a simple personal blog or a To-Do list. But if you're working on a project designed to scale, or if you feel your controllers are getting out of hand, try isolating the most complex action of your system in a Command Handler. I bet you'll never look back!