Expert in web development and technical team management, I specialize in creating and optimizing high-performance digital solutions. With deep expertise in modern technologies like React.js, Node.js, TypeScript, Symfony, Docker, and FrankenPHP, I ensure the success of complex SaaS projects, from design to production, for companies across various sectors, at offroadLabs.
You keep hearing about CQRS everywhere yet wonder how to apply it without pulling out Symfony or Laravel? Good news: we can explore Command Query Responsibility Segregation in vanilla PHP with nothing more than a clear organization and a handful of well-thought-out classes. Grab your editor, let’s lay the foundations together.
Prerequisites: PHP 8.4 and a minimal setup
PHP 8.4 installed (CLI or web server). Check your version with to benefit from properties, strict types, and native support for first-class callables.
Composer (optional but handy for autoloading) and a lightweight web server ( is enough for experimenting).
If you must stay on an earlier version, make sure the features used here (constructor promotion, properties, etc.) are available or adapt the snippets accordingly.
1. CQRS in two minutes flat
CQRS is all about splitting reads (Query) from writes (Command). You end up with two optimized paths:
Commands mutate state (create an account, publish an article) and don’t return application data.
Queries read state (list articles, fetch an account detail) without side effects.
This separation naturally follows DDD principles, but you can adopt it without heavy formalism. Immediate benefits include:
Simpler tests: you unit test handlers in isolation.
Clearer code: every handler does exactly one thing.
Gradual scalability: you can evolve the read side independently from the write side.
Remember that CQRS adds some structural complexity. For a basic CRUD, it might feel overkill. Use it when business rules or scalability needs justify the extra structure.
2. Minimal folder structure in native PHP
We can stay lightweight with a simple tree:
We clearly separate Domain (business rules), Application (use cases), and Infrastructure (concrete implementations).
3. A minimalist domain model
Let’s create an aggregate and its repository:
4. Command side: creating an article
The command carries the intent. The handler orchestrates the business logic.
Important note: the handler returns nothing. If you want to read the article back, you’ll go through a Query.
5. Query side: listing articles
We expose an array that’s ready to be serialized, without tying the presentation layer to the domain model.
6. An in-memory repository to get started
You can swap this implementation for Doctrine, PDO, or an external API later on without touching the handlers.
7. Wiring ultra-light buses
CQRS really shines once you centralize handler resolution:
php
php
These buses remain naïve but are enough to grasp the mechanics. In a real project, you could plug them into a dependency injection container (PHP-DI, Symfony DI, etc.).
8. Bootstrapping example in public/index.php
php
The (...) operator (first-class callable) available since PHP 8.1 is fully supported in PHP 8.4. If you need to target an older version (8.0 or below), replace it with fn ($command) => $createHandler($command).
9. Going further step by step
Once this foundation is in place, you can enhance your solution:
Validation: add Value Objects (Title, Content) or Symfony Validator checks before creating your aggregate.
Real persistence: implement a MySQL (PDO) or PostgreSQL repository without touching the handlers.
Domain events: publish an ArticlePublished event when the command succeeds, then consume it in another layer.
Dedicated projection: create a read-optimized table for demanding queries (pagination, full-text search).
Tests: write unit tests for each handler and functional tests for the buses.
10. Checklist for your first production release
[ ] Every command has a dedicated handler that returns nothing.
[ ] Every query is isolated and formats data for the front end.
[ ] Technical dependencies are injected from the outside.
[ ] Business errors are handled in the domain (specialized exceptions, Value Objects, etc.).
[ ] Tests cover commands, queries, and buses.
With this skeleton, you can introduce CQRS progressively into an existing project or start a new service without a framework. The key is to keep the read/write boundary clear and evolve your architecture iteratively. Happy experimenting!