DEV Community

Cover image for CQRS in PHP: When Reads and Writes Stop Being the Same Thing
Gabriel Anhaia
Gabriel Anhaia

Posted on

CQRS in PHP: When Reads and Writes Stop Being the Same Thing


You opened a controller that lists orders. It runs five Eloquent queries, eager-loads customer, items, items.product, shipments, payments, then maps the whole graph to a flat JSON response. The page takes 800ms on a warm cache and the product manager is asking why.

You open another controller two doors down. It receives a "place order" POST, validates the cart, charges the card, writes a row, dispatches three jobs. That one takes 300ms and you can't make it any faster because every step is a hard requirement.

Same Order model. Same repository. Both controllers ask it for the same shape. And they are paying for each other's complexity.

This is the moment CQRS pays for itself in PHP. The split is much smaller than most write-ups suggest. You do not need event sourcing. You do not need Kafka. You need two interfaces, two folders, and the discipline to stop reusing one model for two jobs that have nothing in common except the table name.

What CQRS actually is, separated from event sourcing

CQRS is one rule:

Reads and writes are different operations. Model them with different types, route them through different code paths, and let each side optimize for what it actually does.

That is it. Greg Young coined the term in 2010. The 2010 talk made it sound enormous because it was paired with event sourcing: append-only events, projections rebuilt from history, snapshots. Those are separate ideas that happen to compose well with CQRS. You can pick CQRS and skip event sourcing. Most PHP services should.

Pragmatic CQRS in PHP looks like this:

  • The write side receives commands, loads aggregates from the database, runs domain logic, persists changes. One row in, one row out. Doctrine, Eloquent, or raw SQL. Your call.
  • The read side receives queries, runs whatever SQL gives the fastest answer (joins, denormalized views, materialized tables, a Redis cache), and returns DTOs shaped for the caller, with no aggregate, no domain model, and no invariants to protect.
  • Both sides talk to the same database in v1. They can drift apart later if the read load justifies it.

The split is conceptual first, physical second. A small service gets the conceptual split on day one and lives there for years. A high-traffic service eventually copies the read model into a denormalized table updated by a worker, and the database stays single until that worker exists.

Two paths splitting off the same HTTP entry point — one labelled commands flowing into aggregates and the write store, the other labelled queries flowing into projections and read DTOs

The write side: commands, use cases, aggregates

The write side is where your domain rules live. It is also where your tests will be slowest if you let the framework into it. Keep it boring.

A command is a plain readonly DTO that names an intent:

<?php

declare(strict_types=1);

namespace App\Order\Command;

final readonly class PlaceOrder
{
    public function __construct(
        public string $orderId,
        public string $customerId,
        /** @var list<array{sku: string, qty: int, priceCents: int}> */
        public array $items,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

No validation rules, no framework attributes, no service-container hints. A command is a verb-shaped data structure. If it grows past five fields, the use case is doing too much.

The use case is the entry point that orchestrates the aggregate:

<?php

declare(strict_types=1);

namespace App\Order\UseCase;

use App\Order\Command\PlaceOrder;
use App\Order\Domain\Order;
use App\Order\Port\Clock;
use App\Order\Port\OrderRepository;

final readonly class PlaceOrderHandler
{
    public function __construct(
        private OrderRepository $orders,
        private Clock $clock,
    ) {}

    public function handle(PlaceOrder $cmd): void
    {
        $order = Order::place(
            id: $cmd->orderId,
            customerId: $cmd->customerId,
            items: $cmd->items,
            now: $this->clock->now(),
        );

        $this->orders->save($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler depends on two ports. It does not touch the database, the HTTP layer, or the framework's container. Inject a FakeOrderRepository and a fixed clock and the test runs in milliseconds.

The aggregate is the type that enforces invariants:

<?php

declare(strict_types=1);

namespace App\Order\Domain;

use DateTimeImmutable;
use DomainException;

final class Order
{
    /**
     * @param list<LineItem> $items
     */
    private function __construct(
        public readonly string $id,
        public readonly string $customerId,
        public readonly array $items,
        public readonly int $totalCents,
        public readonly DateTimeImmutable $placedAt,
    ) {}

    /**
     * @param list<array{sku: string, qty: int, priceCents: int}> $items
     */
    public static function place(
        string $id,
        string $customerId,
        array $items,
        DateTimeImmutable $now,
    ): self {
        if ($items === []) {
            throw new DomainException('order requires at least one item');
        }

        $lines = [];
        $total = 0;
        foreach ($items as $row) {
            $line = new LineItem($row['sku'], $row['qty'], $row['priceCents']);
            $total += $line->subtotalCents();
            $lines[] = $line;
        }

        return new self($id, $customerId, $lines, $total, $now);
    }
}
Enter fullscreen mode Exit fullscreen mode

Private constructor, named factory, validation in one place. The aggregate is the only thing in the codebase that can produce an Order in a valid state. Every write path leads here.

The port is a one-method interface:

<?php

declare(strict_types=1);

namespace App\Order\Port;

use App\Order\Domain\Order;

interface OrderRepository
{
    public function save(Order $order): void;
    public function findById(string $id): ?Order;
}
Enter fullscreen mode Exit fullscreen mode

findById exists on the write side only when a command needs to load an existing aggregate (cancel, refund, add line). It returns the domain type. Read queries do not come through here, and that is the whole point of the split.

The read side: queries, projections, DTOs

The read side has zero interest in your aggregate. Loading an Order with all its invariants when the caller wants ["#ORD-1042", "$32.00", "shipped", "2 days ago"] is theatre. The read side returns the shape the caller asked for, computed by the cheapest SQL that produces it.

A query is also a plain DTO:

<?php

declare(strict_types=1);

namespace App\Order\Query;

final readonly class ListRecentOrdersForCustomer
{
    public function __construct(
        public string $customerId,
        public int $limit = 20,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The handler returns a list of read DTOs — never domain entities:

<?php

declare(strict_types=1);

namespace App\Order\ReadModel;

final readonly class OrderListItem
{
    public function __construct(
        public string $orderId,
        public string $status,
        public int $totalCents,
        public string $placedAtIso,
        public int $itemCount,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode
<?php

declare(strict_types=1);

namespace App\Order\Query;

use App\Order\ReadModel\OrderListItem;
use PDO;

final readonly class ListRecentOrdersForCustomerHandler
{
    public function __construct(private PDO $db) {}

    /**
     * @return list<OrderListItem>
     */
    public function handle(ListRecentOrdersForCustomer $q): array
    {
        $stmt = $this->db->prepare(
            'SELECT o.id, o.status, o.total_cents,
                    o.placed_at, COUNT(li.id) AS item_count
             FROM orders o
             JOIN order_items li ON li.order_id = o.id
             WHERE o.customer_id = :cust
             GROUP BY o.id
             ORDER BY o.placed_at DESC
             LIMIT :lim'
        );
        $stmt->bindValue(':cust', $q->customerId);
        $stmt->bindValue(':lim', $q->limit, PDO::PARAM_INT);
        $stmt->execute();

        $out = [];
        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $out[] = new OrderListItem(
                orderId: $row['id'],
                status: $row['status'],
                totalCents: (int) $row['total_cents'],
                placedAtIso: $row['placed_at'],
                itemCount: (int) $row['item_count'],
            );
        }
        return $out;
    }
}
Enter fullscreen mode Exit fullscreen mode

That handler takes a raw PDO. No repository interface, no aggregate hydration, no ORM. The DTO is shaped for the listing page and nothing else. Need another shape on another page? Write another DTO and another handler. A new read DTO is one file and one SQL query, and nothing in the write side changes.

Two things to notice. The read handler reads from the same orders and order_items tables the write side writes to. That is a shared database, not a separate read store. And the read DTO has a field the write aggregate does not carry: itemCount. The read side computes whatever is useful. The write side stays minimal.

When the read side moves to its own table

The shared-database read side covers most services. You move further only when one of these is true:

  • The read query joins five tables and the listing page is on the hot path.
  • The read side has a different consistency need (search index, analytics rollup) than the write side.
  • You want to render the page from a single row.

The smallest version of this is a denormalized table updated by the same transaction:

CREATE TABLE order_list_view (
    order_id        VARCHAR(36) PRIMARY KEY,
    customer_id     VARCHAR(36) NOT NULL,
    status          VARCHAR(32) NOT NULL,
    total_cents     BIGINT NOT NULL,
    placed_at       TIMESTAMP NOT NULL,
    item_count      INT NOT NULL,
    INDEX (customer_id, placed_at)
);
Enter fullscreen mode Exit fullscreen mode

The write handler writes both rows (canonical orders and the projection) inside the same transaction. Reads hit order_list_view only, and the join disappears.

public function save(Order $order): void
{
    $this->db->beginTransaction();
    try {
        $this->writeCanonical($order);
        $this->writeListProjection($order);
        $this->db->commit();
    } catch (\Throwable $e) {
        $this->db->rollBack();
        throw $e;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is still strongly consistent. You have moved one denormalized table inside the same transaction, with no event bus and no second database. It is cheaper than building an async worker, and you keep strong consistency on the listing page.

If write throughput becomes the bottleneck, the projection moves to a worker that listens to domain events. The read store becomes a separate database. Eventual consistency lands on that view. Three steps, in that order, only when each one is justified by load. Most services never reach step two.

A flowchart: command lands at the write side, persists to the canonical orders table, and in the same transaction writes the denormalized view; read queries pull from the denormalized view only

The folder layout that holds the split

The directory structure is the architecture in PHP. Make the split visible:

src/Order/
├── Command/
│   ├── PlaceOrder.php
│   └── CancelOrder.php
├── Query/
│   ├── ListRecentOrdersForCustomer.php
│   ├── ListRecentOrdersForCustomerHandler.php
│   └── GetOrderDetail.php
├── UseCase/
│   ├── PlaceOrderHandler.php
│   └── CancelOrderHandler.php
├── Domain/
│   ├── Order.php
│   └── LineItem.php
├── ReadModel/
│   ├── OrderListItem.php
│   └── OrderDetail.php
├── Port/
│   ├── OrderRepository.php
│   └── Clock.php
└── Adapter/
    ├── DoctrineOrderRepository.php
    └── PdoOrderListProjection.php
Enter fullscreen mode Exit fullscreen mode

Two rules keep it honest:

  1. Anything in Query/ and ReadModel/ is not allowed to import Domain/. The read side does not know aggregates exist. A grep in CI is enough to enforce that.
  2. Anything in Command/, UseCase/, and Domain/ is not allowed to return read DTOs. A use case returns void, an ID, or throws. It never hands back a render-ready shape.

When those two rules are followed, the split survives every refactor.

When CQRS is not worth it

The split costs you something. Twice the controllers. Twice the route files. Two types for what feels like one operation. If the gain does not exist, you are paying overhead for theatre.

Skip the split when:

  • The write side is a CRUD form that produces the same shape the read page displays. name, email, phone in, name, email, phone out. There is no read-side complexity to optimize.
  • The aggregate has no invariants. If Order::place is a constructor with no rules, you do not have a domain. You have a DTO masquerading as one.
  • The team is one person, the traffic is single-digit requests per minute, and the database is happy joining whatever you throw at it.

Adopt the split when:

  • You have at least one listing or detail page whose query shape is meaningfully different from the write shape (joins, aggregates, computed columns).
  • The write side has invariants that need a real aggregate — multi-field rules, state transitions with named verbs (Confirm, Cancel, Refund), or transactional consistency requirements.
  • Two teams are stepping on each other inside one fat controller because reads keep changing while writes need to stay stable.

You can also mix freely. The Order module gets the full split because orders have rules and the listing page is hot. The Tag module next to it is one controller and a CRUD form because tags do not have rules and nobody cares how fast the tag-list page is. Same codebase, same folder convention, different commitment per module.

What you keep, what you drop

Keep:

  • Two folders per module: Command/UseCase/Domain for writes, Query/ReadModel for reads.
  • Read handlers that hit raw SQL or a thin query builder. No ORM hydration for reads unless you measured it and the difference is in the noise.
  • Write handlers that go through aggregates and ports. No SQL in use cases.
  • A shared database in v1. Move to a denormalized projection table only when the read query justifies it.

Drop, until you have actual evidence you need them:

  • Event sourcing. CQRS does not require it. Use a regular database.
  • A separate read database. Most reads are fine against the same Postgres.
  • Asynchronous projection workers. Same-transaction denormalization is simpler and strongly consistent.
  • A command bus / query bus library. Call the handler directly from the controller. Add the bus when you have three handlers that share middleware, not before.

CQRS earns its keep on the day you stop loading a 12-field Order aggregate to render a 4-field row in a list. That day comes earlier than most write-ups suggest, and you can get there with two folders and an interface. No saga, no Kafka.


If this was useful

The full pragmatic CQRS chapter — including the migration playbook for splitting an existing fat OrderService into write and read sides inside a live Laravel or Symfony app — sits inside Decoupled PHP. It walks the same shape from a CRUD prototype up to a multi-handler service with projections, transactions, and observability. Event-Driven Architecture Pocket Guide is the companion when the read side eventually moves to its own table and an async worker.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)