CQRS — Command Query Responsibility Segregation
Concept
CQRS — Command Query Responsibility Segregation — is the principle that operations which change state (commands) and operations that read state (queries) should be handled by separate models. The name comes from Bertrand Meyer's Command-Query Separation principle at the method level, scaled up to the architectural level by Greg Young. The core insight is that reads and writes have fundamentally different characteristics: reads must be fast and flexible, writes must be consistent and safe.
In a traditional CRUD architecture, the same model (Eloquent class) handles both reads and writes. This creates tension: writes need strict validation, business rules, and transaction handling; reads need denormalized, joined, possibly cached data in exactly the shape the UI demands. When you use the same model for both, you end up with complex queries that include unnecessary joins for writes, or overly cautious writes that re-validate data that only reads need. CQRS resolves this by making writes go through a Command object (which captures intent and triggers domain logic) and reads go through a Query object (which retrieves data in the exact shape needed).
The simplest implementation of CQRS does not require separate databases or event sourcing — it just means separating your PHP classes into command handlers and query handlers. Commands are write-only: they accept a Command value object, execute the use case, and return nothing (or just a success signal). Queries return read models — simple data structures or DTOs — optimized for display. This alone reduces coupling dramatically.
The more advanced form uses a separate read database (a denormalized read store, Redis cache, or Elasticsearch index) that is kept in sync via events from the write side. This enables the read side to scale independently and use schemas optimized for querying without affecting write consistency. This level of complexity is only justified for systems with extreme read/write ratio imbalance or massive scale.
| Concern | Commands (Write side) | Queries (Read side) |
|---|---|---|
| Input | Command value object | Query parameters / filters |
| Returns | Nothing / ID | Read model / DTO |
| Validation | Full business rule validation | Schema validation only |
| Model used | Domain/Eloquent model | Optimized read query or DTO |
| Caching | No | Yes |
| Transactions | Yes | No |
Code Example
<?php
declare(strict_types=1);
// ===== WRITE SIDE (Commands) =====
// app/Commands/PublishPostCommand.php — a value object describing intent
namespace App\Commands;
final readonly class PublishPostCommand
{
public function __construct(
public readonly string $title,
public readonly string $body,
public readonly int $authorId,
) {}
}
// app/Handlers/PublishPostHandler.php — handles the command
namespace App\Handlers;
use App\Commands\PublishPostCommand;
use App\Events\PostPublished;
use App\Models\Post;
use Illuminate\Support\Facades\DB;
final class PublishPostHandler
{
// Returns void — commands do not return data.
// Caller gets back nothing; state changed is the evidence of success.
public function handle(PublishPostCommand $command): void
{
DB::transaction(function () use ($command) {
$post = Post::create([
'title' => $command->title,
'body' => $command->body,
'author_id' => $command->authorId,
'status' => 'published',
'published_at' => now(),
]);
event(new PostPublished($post->id));
});
}
}
// ===== READ SIDE (Queries) =====
// app/Queries/PostSummaryQuery.php — a query object, returns a read model
namespace App\Queries;
use App\ReadModels\PostSummary;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class PostSummaryQuery
{
// Read side can bypass Eloquent entirely and use raw SQL for performance.
// No business rules, no events, no transactions — just efficient retrieval.
/** @return Collection<PostSummary> */
public function forPage(int $page, int $perPage = 15): Collection
{
$offset = ($page - 1) * $perPage;
return DB::table('posts as p')
->join('users as u', 'u.id', '=', 'p.author_id')
->select([
'p.id',
'p.title',
'p.published_at',
'u.name as author_name',
DB::raw('COUNT(c.id) as comment_count'),
])
->leftJoin('comments as c', 'c.post_id', '=', 'p.id')
->where('p.status', 'published')
->groupBy('p.id', 'p.title', 'p.published_at', 'u.name')
->orderByDesc('p.published_at')
->offset($offset)
->limit($perPage)
->get()
->map(fn($row) => new PostSummary(
id: $row->id,
title: $row->title,
publishedAt: $row->published_at,
authorName: $row->author_name,
commentCount: (int) $row->comment_count,
));
}
}
// app/ReadModels/PostSummary.php — a DTO for display, not a domain object
namespace App\ReadModels;
final readonly class PostSummary
{
public function __construct(
public readonly int $id,
public readonly string $title,
public readonly string $publishedAt,
public readonly string $authorName,
public readonly int $commentCount,
) {}
}
// ===== CONTROLLER WIRES IT TOGETHER =====
namespace App\Http\Controllers;
use App\Commands\PublishPostCommand;
use App\Handlers\PublishPostHandler;
use App\Queries\PostSummaryQuery;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PostController extends Controller
{
public function index(PostSummaryQuery $query): View
{
return view('posts.index', [
'posts' => $query->forPage(page: (int) request('page', 1)),
]);
}
public function store(Request $request, PublishPostHandler $handler): \Illuminate\Http\RedirectResponse
{
$handler->handle(new PublishPostCommand(
title: $request->validated('title'),
body: $request->validated('body'),
authorId: $request->user()->id,
));
return redirect()->route('posts.index');
}
}Interview Q&A
Q: What is CQRS and what problem does it solve?
CQRS separates the classes that handle write operations (commands) from those that handle read operations (queries). The problem it solves is the tension between reads and writes using the same model: writes need strict validation and transactional consistency; reads need denormalized, performant queries that return data in the exact shape the UI needs. By splitting them, you can optimize each side independently — the write side can use full domain models with business rules, while the read side can use raw SQL queries, joins, and caches without any of that overhead.
Q: Does CQRS require event sourcing or a separate read database?
No. The simplest form of CQRS is just a class separation: command handler classes that write, and query classes that read. Both can use the same database. Adding event sourcing or a separate read store (like Redis or Elasticsearch) is an optional scaling optimization, not a requirement of the pattern. Many teams implement CQRS at the class level and gain significant benefits in testability and clarity without any infrastructure complexity.
Q: What should a command return?
Strictly speaking, a command should return nothing (void) — it changes state as its output. This keeps the separation clean: if you need to know what was created, you can raise a domain event that carries the ID, or issue a subsequent query. In practice, many teams return the created entity's ID from the command handler as a pragmatic compromise, which is acceptable if you understand you are bending the pure form of the pattern. Returning a full model from a command handler is a stronger violation because it causes the write side to start serving read concerns.