CQRS in Laravel: Commands, Handlers, Query Objects | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. CQRS in Laravel Without a Framework: Commands, Handlers, and Query Objects

 CQRS in Laravel Without a Framework: Commands, Handlers, and Query Objects
===========================================================================

 Skip the heavy CQRS libraries. Learn how to implement commands, command handlers, and query objects in plain Laravel using the service container, making your application testable and intent-revealing.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 6 Jul 2026 · Updated 6 Jul 2026 · 3 min read

ShareCopy linkCopied

 ![CQRS in Laravel Without a Framework: Commands, Handlers, and Query Objects](https://cdn.msaied.com/376/bec9da4b7a7ddeee26dac3df6f5d6c44.png) 

  On this page +1. [Why Roll Your Own CQRS Layer?](#why-roll-your-own-cqrs-layer)
2. [The Command Bus](#the-command-bus)
3. [A Concrete Command and Handler](#a-concrete-command-and-handler)
4. [Query Objects Instead of Fat Repositories](#query-objects-instead-of-fat-repositories)
5. [Testing with Pest](#testing-with-pest)
6. [Middleware on the Bus](#middleware-on-the-bus)
7. [Key Takeaways](#key-takeaways)

 Why Roll Your Own CQRS Layer?
-----------------------------

Heavy CQRS packages bring conventions you may not need and abstractions that obscure what is actually happening. For most Laravel applications a thin, explicit layer — commands, handlers, and query objects wired through the service container — delivers 90% of the benefit with none of the magic.

This article builds that layer from scratch.

---

The Command Bus
---------------

A command is a plain DTO expressing intent. A handler executes it. A bus resolves the handler and calls it.

```php
// app/CQRS/CommandBus.php
namespace App\CQRS;

use Illuminate\Contracts\Container\Container;

final class CommandBus
{
    public function __construct(private readonly Container $container) {}

    public function dispatch(object $command): mixed
    {
        $handlerClass = $this->resolveHandler($command);
        $handler = $this->container->make($handlerClass);

        return $handler->handle($command);
    }

    private function resolveHandler(object $command): string
    {
        // Convention: App\Commands\FooCommand → App\Handlers\FooHandler
        $base = class_basename($command);
        $handler = str_replace('Command', 'Handler', $base);
        $class = 'App\\Handlers\\' . $handler;

        if (! class_exists($class)) {
            throw new \RuntimeException("No handler found for {$base}");
        }

        return $class;
    }
}

```

Register it as a singleton in `AppServiceProvider`:

```php
$this->app->singleton(CommandBus::class);

```

---

A Concrete Command and Handler
------------------------------

```php
// app/Commands/RegisterUserCommand.php
namespace App\Commands;

final readonly class RegisterUserCommand
{
    public function __construct(
        public string $email,
        public string $name,
        public string $plainPassword,
    ) {}
}

```

```php
// app/Handlers/RegisterUserHandler.php
namespace App\Handlers;

use App\Commands\RegisterUserCommand;
use App\Models\User;
use Illuminate\Support\Facades\Hash;

final class RegisterUserHandler
{
    public function handle(RegisterUserCommand $command): User
    {
        return User::create([
            'email'    => $command->email,
            'name'     => $command->name,
            'password' => Hash::make($command->plainPassword),
        ]);
    }
}

```

In a controller:

```php
$user = $bus->dispatch(new RegisterUserCommand(
    email: $request->email,
    name:  $request->name,
    plainPassword: $request->password,
));

```

The controller knows nothing about hashing, model creation, or side effects.

---

Query Objects Instead of Fat Repositories
-----------------------------------------

Query objects encapsulate a single read concern. They return data, never modify state.

```php
// app/Queries/ActiveSubscribersQuery.php
namespace App\Queries;

use App\Models\User;
use Illuminate\Pagination\CursorPaginator;

final class ActiveSubscribersQuery
{
    public function __construct(
        private readonly int $perPage = 50,
        private readonly ?string $cursor = null,
    ) {}

    public function get(): CursorPaginator
    {
        return User::query()
            ->whereHas('subscription', fn ($q) => $q->active())
            ->orderBy('id')
            ->cursorPaginate($this->perPage, cursor: $this->cursor);
    }
}

```

Call it directly — no bus needed for queries:

```php
$page = (new ActiveSubscribersQuery(
    perPage: 25,
    cursor: $request->query('cursor'),
))->get();

```

---

Testing with Pest
-----------------

Handlers are plain classes, so testing is straightforward:

```php
it('creates a user with a hashed password', function () {
    $command = new RegisterUserCommand(
        email: 'alice@example.com',
        name: 'Alice',
        plainPassword: 'secret',
    );

    $user = app(RegisterUserHandler::class)->handle($command);

    expect($user)
        ->email->toBe('alice@example.com')
        ->and(Hash::check('secret', $user->password))->toBeTrue();
});

```

No HTTP overhead, no mocking the bus — just the handler under test.

---

Middleware on the Bus
---------------------

Add cross-cutting concerns (logging, transactions, authorization) by wrapping `dispatch`:

```php
public function dispatch(object $command): mixed
{
    return DB::transaction(
        fn () => $this->resolveAndCall($command)
    );
}

```

For finer control, accept an array of middleware callables and pipe the command through them before reaching the handler — the same pattern Laravel's own `Pipeline` uses.

---

Key Takeaways
-------------

- **Commands are write-only DTOs**; handlers own all mutation logic.
- **Query objects** replace ad-hoc repository methods with focused, testable read concerns.
- **Convention-based handler resolution** keeps the bus tiny; swap it for explicit mapping when you need more control.
- **No package dependency** means you own the abstraction and can evolve it freely.
- Handlers are plain classes — Pest tests are fast and require no HTTP layer.
- Wrapping `dispatch` in a DB transaction is a one-liner that protects every command uniformly.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [cqrs](https://www.msaied.com/public/articles?search=cqrs)
- [architecture](https://www.msaied.com/public/articles?search=architecture)
- [clean-code](https://www.msaied.com/public/articles?search=clean-code)
- [testing](https://www.msaied.com/public/articles?search=testing)

 Frequently asked questions 
---------------------------

  Should every controller action go through the command bus?No. Simple CRUD with no domain logic can stay in the controller or a single action class. Introduce the bus where you have meaningful side effects, multiple steps, or logic you want to test in isolation.

   How do I handle commands that need to dispatch jobs or fire events?Do it inside the handler. Handlers can inject the Queue, EventDispatcher, or any other service via the container. Keeping side effects inside the handler keeps the controller ignorant of them.

   Is this pattern compatible with Filament actions?Yes. Filament's action closures receive the record and form data, so you can instantiate a command and call the bus directly inside the action's `action` callback without any extra wiring.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

[Mohamed Said](https://www.msaied.com/public#person)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleEvent Sourcing in Laravel: Aggregates, Projectors, and Rebuilding State from Events](https://www.msaied.com/public/articles/event-sourcing-in-laravel-aggregates-projectors-and-rebuilding-state-from-events) [Next articleWhat's Missing from Your PHP Development Environment: Meet DDLess](https://www.msaied.com/public/articles/whats-missing-from-your-php-development-environment-meet-ddless)  

   On this page
-------------

1. [Why Roll Your Own CQRS Layer?](#why-roll-your-own-cqrs-layer)
2. [The Command Bus](#the-command-bus)
3. [A Concrete Command and Handler](#a-concrete-command-and-handler)
4. [Query Objects Instead of Fat Repositories](#query-objects-instead-of-fat-repositories)
5. [Testing with Pest](#testing-with-pest)
6. [Middleware on the Bus](#middleware-on-the-bus)
7. [Key Takeaways](#key-takeaways)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
