revision-ten/cqrs
A lightweight CQRS package for Laravel that separates writes from reads using commands, queries, handlers, and buses. It helps organize business logic into clear, testable actions with simple Laravel-friendly integration.
Install and inspect before wiring anything up.
composer require revision-ten/cqrs
Laravel's package discovery should register the service provider automatically; if not, add it to config/app.php manually. Immediately after install, read vendor/revision-ten/cqrs/README.md and skim src/ — with a package this small (2 stars, no dependents) the source is the documentation, and you need to confirm the exact class names (CommandBus, QueryBus, handler interfaces, middleware contracts) before writing code against them.
Check for a publishable config.
php artisan vendor:publish --provider="RevisionTen\Cqrs\CqrsServiceProvider"
Look for config/cqrs.php — typically this holds handler-to-command mappings, bus middleware stacks, and default queue connections. If no config is published, handler registration is probably done in a service provider or via auto-resolution from the container.
Start with one vertical slice: a single command.
Before touching queries or events, take one write operation in your app — e.g. RegisterUser — and convert it:
// app/Users/Commands/RegisterUser.php
final class RegisterUser
{
public function __construct(
public readonly string $email,
public readonly string $name,
) {}
}
// app/Users/Handlers/RegisterUserHandler.php
class RegisterUserHandler
{
public function __construct(private UserRepository $users) {}
public function handle(RegisterUser $command): User
{
return $this->users->create($command->email, $command->name);
}
}
Register the handler per the package's convention, then dispatch from a controller:
public function store(RegisterUserRequest $request, CommandBus $bus)
{
$user = $bus->dispatch(new RegisterUser(
$request->validated('email'),
$request->validated('name'),
));
return UserResource::make($user);
}
Where to look first when something breaks: the bus implementation in src/ (to see how handlers are resolved and whether middleware runs), then your handler registration (the #1 cause of "no handler found" errors), then bootstrap/cache/ (stale config).
Feature-folder over layer-folder. Group by domain, not by type. app/Users/Commands/, app/Users/Queries/, app/Users/Handlers/ beats scattering Commands/ across the whole app. It keeps the command/handler pair adjacent and makes deletion trivial.
One handler, one public handle() method. Keep handlers thin orchestrators. Business logic lives in domain services or aggregates the handler composes. This is what makes handlers testable without a bus at all.
Commands return identifiers, not entities (pragmatically). Strict CQRS says a command returns void and the client re-queries. In a Laravel HTTP context that means two round trips and a race condition. Returning the new aggregate's ID (or the aggregate itself) from handle() is the common compromise — just be consistent across the codebase so nobody guesses.
Queries stay read-only and side-effect free. A query handler should be safe to call twice, safe to retry, and safe to cache. If it touches a write model, it's a command in disguise.
final class GetUserProfile
{
public function __construct(public readonly int $userId) {}
}
Middleware is where cross-cutting concerns live. The bus almost certainly supports a middleware stack (transaction wrapping, logging, validation, authorization). Wrap commands in a DB transaction at the bus level, not in each handler:
class TransactionMiddleware
{
public function __construct(private DatabaseManager $db) {}
public function handle($command, Closure $next)
{
return $this->db->transaction(fn () => $next($command));
}
}
Register it once in the config/provider; every command becomes atomic for free. Use the same mechanism for an audit-log middleware that records command class + payload.
Dispatch domain events from within the command's transaction, but queue listeners outside it. If the package provides an event dispatcher alongside the bus, publish events after the transaction commits — otherwise a listener fires on data that rolls back.
Reuse the bus inside queued jobs. If a command is slow, wrap it: dispatch(new ProcessOrderJob($orderId)) where the job constructs and dispatches the command. Don't make the command itself ShouldQueue unless the package explicitly supports serializable command payloads — most don't handle closures or model instances safely.
Test handlers directly, test dispatch through the bus. new RegisterUserHandler($repo)->handle(new RegisterUser(...)) for logic; $bus->dispatch(...) + Queue::fake()/Event::fake() for wiring.
Bind the bus to an interface. $this->app->bind(CommandBusInterface::class, PackageCommandBus::class) lets you swap in a fake or a decorated bus later without touching call sites.
config:cache will bake that map in — adding a new command in production means re-running php artisan config:cache. Confirm this early; it's the most common source of "works locally, fails in prod.""revision-ten/cqrs": "1.x" with a committed composer.lock), and if you depend on it heavily, consider vendoring the bus into app/Infrastructure/ — it's likely a few hundred lines, and owning it removes an upgrade risk.new changes how you write every handler. Don't assume Laravel-style DI.public readonly properties (PHP 8.1+), no setters, no Eloquent models as constructor params (they serialize badly and mutate). Pass scalars/IDs.Request values in the controller and pass primitives. This keeps commands replayable from a queue or an audit log.get_class($command) and timing. Since there's no Laravel debug integration for this package, a two-line logging middleware will save you hours versus dd() inside handlers.app/**/Handlers if you find the manual map tedious).dispatch() signature declares void but handlers return values, PHP will silently swallow or error depending on strictness. Check declare(strict_types=1) in the package's files.App\Cqrs\Bus you own that mirrors the same dispatch() API: $handler = app($this->map[$command::class] ?? ...). Swapping is a one-line container binding change if you bound to an interface from day one — which is the strongest argument for doing that on step one.How can I help you explore Laravel packages today?