different-universe/laravel-cqbus-mediator

Lightweight, modern CQS Mediator for Laravel using PHP 8 attributes and auto-discovery.

Maintainers

Package info

github.com/different-universe/laravel-cqbus-mediator

pkg:composer/different-universe/laravel-cqbus-mediator

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-05-05 19:44 UTC

This package is auto-updated.

Last update: 2026-05-06 14:46:06 UTC


README

Lightweight CQS/CQRS-style mediator for Laravel applications. The package lets you describe application use cases as small command, query, and internal handler classes, route messages to handlers automatically via PHP attributes, and wrap execution with Laravel pipelines.

Instead of calling large service classes from controllers, you dispatch an explicit message:

use App\Mediator\Orders\CreateOrder;
use DifferentUniverse\CqbusMediator\Contracts\Mediator;

final class OrderController
{
    public function __construct(
        private readonly Mediator $mediator,
    ) {
    }

    public function store(StoreOrderRequest $request): JsonResponse
    {
        $result = $this->mediator->send(new CreateOrder(
            userId: $request->user()->id,
            items: $request->validated('items'),
        ));

        return response()->json($result, 201);
    }
}

Motivation

Controllers are usually a poor place for business logic. They should deal with the HTTP layer: request data, validation, authentication context, response shape, status codes, redirects, headers, and framework-specific concerns.

Business operations are easier to understand and test when they are modeled as separate application units. A CreateOrderHandler, RefundPaymentHandler, or LookupCustomerBalanceHandler represents one concrete use case that can be reused from controllers, console commands, queue jobs, event listeners, tests, or any other entry point.

The common "fat controller" problem appears when HTTP details, application flow, persistence, domain rules, authorization checks, logging, and side effects grow together in the same method. The result is hard to test, hard to reuse outside HTTP, and hard to change without pulling on hidden dependencies.

Classic service classes help, but they often drift into broad buckets such as OrderService, UserService, or PaymentService. Over time they collect many unrelated methods, their names stop describing a single operation, and cross-cutting behavior such as transactions, tracing, tenant context, or query caching becomes inconsistent.

This package pushes the code toward smaller use-case handlers:

  • one message maps to one command/query handler;
  • handlers are discovered automatically from configured paths;
  • command and query flows can have different configured pipelines;
  • local pipelines can be declared directly on handlers;
  • internal handlers can build explicit post-processing chains after the main handler returns.

Features

  • Command, query, and internal handlers via PHP attributes.
  • Automatic handler discovery from configured paths.
  • Message class routing and explicit string channels.
  • Laravel container resolution for handlers and pipelines.
  • Laravel Illuminate\Pipeline\Pipeline integration.
  • Global, command-specific, query-specific, and local handler pipelines.
  • Internal post-processing handler chains through output channels.
  • Cache mode for production.
  • Artisan generators for handler classes.
  • Laravel package auto-discovery and facade support.
  • Laravel Octane friendly mediator singleton.

Requirements

  • PHP ^8.2
  • Laravel ^11.0, ^12.0, or ^13.0

Installation

Install the package:

composer require different-universe/laravel-cqbus-mediator

Publish the config file:

php artisan vendor:publish --tag=mediator-config

Optionally publish stubs for generator customization:

php artisan vendor:publish --tag=mediator-stubs

The service provider is registered automatically through Laravel package discovery.

If package discovery is disabled, register the provider manually:

// bootstrap/providers.php

return [
    App\Providers\AppServiceProvider::class,
    DifferentUniverse\CqbusMediator\MediatorServiceProvider::class,
];

Configuration

After publishing, the config is available at config/mediator.php:

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Discovery Paths
    |--------------------------------------------------------------------------
    |
    | These paths are scanned for mediator handlers when the discovery data
    | provider builds the command, query, and internal handler maps. You may
    | add any application or package paths that contain handler attributes.
    |
    */
    'discovery_paths' => [
        app_path(),
    ],

    /*
    |--------------------------------------------------------------------------
    | Cache File Path
    |--------------------------------------------------------------------------
    |
    | This file stores the compiled mediator handler map generated by the
    | cache command. The default location follows Laravel's convention for
    | framework bootstrap cache files.
    |
    */
    'cache_file_path' => base_path('bootstrap/cache/mediator.php'),

    /*
    |--------------------------------------------------------------------------
    | Global Pipelines
    |--------------------------------------------------------------------------
    |
    | These pipeline classes are applied to every discovered command and query
    | handler before any handler-specific pipelines. Individual handlers may
    | opt out with the available pipeline exclusion attributes.
    |
    */
    'global_pipelines' => [],

    /*
    |--------------------------------------------------------------------------
    | Command Pipelines
    |--------------------------------------------------------------------------
    |
    | These pipeline classes are applied only to command handlers. They are
    | appended after the global pipelines and before any pipelines declared
    | directly on a command handler.
    |
    */
    'command_pipelines' => [],

    /*
    |--------------------------------------------------------------------------
    | Query Pipelines
    |--------------------------------------------------------------------------
    |
    | These pipeline classes are applied only to query handlers. They are
    | appended after the global pipelines and before any pipelines declared
    | directly on a query handler.
    |
    */
    'query_pipelines' => [],

    /*
    |--------------------------------------------------------------------------
    | Generated Handler Namespace
    |--------------------------------------------------------------------------
    |
    | This namespace is used by the mediator generator commands as the default
    | location for new command, query, and internal handler classes. Relative
    | namespaces are resolved beneath the application's root namespace.
    |
    */
    'root_namespace' => 'App\\Mediator',

    /*
    |--------------------------------------------------------------------------
    | Generated Handler Suffix
    |--------------------------------------------------------------------------
    |
    | This suffix is appended by the mediator generator commands when a new
    | handler class name does not already end with it. Set this value to an
    | empty string if you prefer to provide full class names manually.
    |
    */
    'handler_suffix' => 'Handler',
];

Basic Usage

Create a message:

namespace App\Mediator\Orders;

final readonly class CreateOrder
{
    public function __construct(
        public int $userId,
        public array $items,
    ) {
    }
}

Create a handler:

namespace App\Mediator\Orders;

use App\Models\Order;
use DifferentUniverse\CqbusMediator\Attributes\Handlers\CommandHandler;

#[CommandHandler]
final class CreateOrderHandler
{
    public function handle(CreateOrder $command): Order
    {
        return Order::create([
            'user_id' => $command->userId,
            'items' => $command->items,
        ]);
    }
}

Dispatch through the contract:

use App\Mediator\Orders\CreateOrder;
use DifferentUniverse\CqbusMediator\Contracts\Mediator;

final class CheckoutController
{
    public function __construct(
        private readonly Mediator $mediator,
    ) {
    }

    public function __invoke(): JsonResponse
    {
        $order = $this->mediator->send(new CreateOrder(
            userId: 1,
            items: [['sku' => 'book', 'quantity' => 1]],
        ));

        return response()->json($order, 201);
    }
}

Or dispatch through the facade:

use App\Mediator\Orders\CreateOrder;
use DifferentUniverse\CqbusMediator\Facades\Mediator;

$order = Mediator::send(new CreateOrder(1, [
    ['sku' => 'book', 'quantity' => 1],
]));

The value returned by handle() is returned from the mediator. If an internal handler chain is configured through an output channel, the final internal handler result is returned.

Commands And Queries

#[CommandHandler] and #[QueryHandler] work the same way at the API level. Both mark a class as a discoverable handler, both support automatic or explicit input channels, both can define an output channel, and both can be wrapped with pipelines.

The difference is mostly business-level:

  • a command expresses an intention to change state;
  • a query expresses an intention to read or calculate data without changing state.

The technical benefit of separating them is that commands and queries can have different configured pipelines. For example, commands can run through transactions and retries, while queries can run through cache lookup.

Command example:

use DifferentUniverse\CqbusMediator\Attributes\Handlers\CommandHandler;

#[CommandHandler]
final class CapturePaymentHandler
{
    public function __construct(
        private PaymentGateway $payments,
    ) {
    }

    public function handle(CapturePayment $command): Receipt
    {
        return $this->payments->capture($command->paymentId);
    }
}

Query example:

use DifferentUniverse\CqbusMediator\Attributes\Handlers\QueryHandler;

#[QueryHandler]
final class LookupOrderHandler
{
    public function __construct(
        private OrderRepository $orders,
    ) {
    }

    public function handle(LookupOrder $query): ?Order
    {
        return $this->orders->find($query->orderNumber);
    }
}

Handlers are resolved through the Laravel container, so constructor injection works normally.

Handler requirements:

  • the class must be instantiable;
  • the class must have a handle() method;
  • handle() must be public and non-static;
  • handle() must have no more than one parameter;
  • a class cannot have more than one handler attribute.

Input Channels

There are two channel styles.

Automatic typed channel:

#[CommandHandler]
final class CreateOrderHandler
{
    public function handle(CreateOrder $command): Order
    {
        // ...
    }
}

When no channel is passed to the attribute, the channel is derived from the type of the single handle() parameter. In this example, the input channel is CreateOrder::class.

Union types are supported and register the same handler for each message class:

#[CommandHandler]
final class UpsertPostHandler
{
    public function handle(CreatePost|UpdatePost $message): Post
    {
        // ...
    }
}

Restrictions for automatic typed channels:

  • handle() must have exactly one parameter;
  • the parameter must be typed;
  • the parameter cannot be nullable;
  • the parameter type must be a concrete message class, not an interface or abstract class;
  • builtin types such as array, string, int, or mixed are not allowed;
  • intersection types are not supported;
  • every referenced class must exist.

The channel is the exact class name from the type declaration. Inheritance is not considered: a handler registered for BaseCommand::class will not be found by send(new CreateOrder(...)) unless the actual message class is also registered as an input channel.

Explicit named channel:

#[CommandHandler('orders.create')]
final class CreateOrderHandler
{
    public function handle(array $payload): Order
    {
        // ...
    }
}

When an explicit channel is provided, it takes precedence over the handle() parameter type. This is useful for string channels, generic payloads, integration-style messages, or cases where a dedicated message class would be unnecessary.

For named channels, the parameter type does not affect routing. A handler may have no handle() parameter at all, or one parameter with any type you need for that channel. If a parameter is present, it must still be the only parameter.

Dispatching

Dispatch by message class:

$result = $mediator->send(new CreateOrder(
    userId: 1,
    items: $items,
));

send() accepts only an object and routes it by $message::class.

Dispatch by explicit channel:

$result = $mediator->sendToChannel('orders.create', [
    'user_id' => 1,
    'items' => $items,
]);

sendToChannel() accepts any payload.

Both methods are available on the contract and the facade:

use DifferentUniverse\CqbusMediator\Facades\Mediator;

Mediator::send(new LookupOrder('ORD-100'));
Mediator::sendToChannel('orders.lookup', ['number' => 'ORD-100']);

Internal Handlers

Internal handlers run after the main command/query handler. They receive the result of the previous handler and may transform it before passing it to the next internal handler.

They are useful for explicit post-processing chains: audit trail storage, normalization, enrichment, or a final response transformation.

Main handler with output channel:

use DifferentUniverse\CqbusMediator\Attributes\Handlers\CommandHandler;

#[CommandHandler(outputChannel: 'audit.store')]
final class CreateOrderHandler
{
    public function handle(CreateOrder $command): array
    {
        return [
            'order_id' => 123,
            'status' => 'created',
        ];
    }
}

Internal handler chain:

use DifferentUniverse\CqbusMediator\Attributes\Handlers\InternalHandler;

#[InternalHandler('audit.store', outputChannel: 'response.enrich')]
final class StoreAuditTrail
{
    public function handle(array $data): array
    {
        AuditTrail::create($data);

        return $data;
    }
}

#[InternalHandler('response.enrich')]
final class EnrichResponse
{
    public function handle(array $data): array
    {
        $data['processed_at'] = now()->toISOString();

        return $data;
    }
}

Important rules:

  • internal handlers cannot be called directly through send() or sendToChannel();
  • the mediator resolves internal handlers only from an outputChannel of the main handler or another internal handler;
  • internal handlers are resolved through the Laravel container;
  • #[Pipeline], #[WithoutGlobalPipeline], and #[WithoutAllGlobalPipelines] are intended for command/query handlers only;
  • configured and local pipelines wrap the main handler; internal handlers execute after the main handler inside that flow.

The chain stops when a handler has no outputChannel. In that case, the value returned by that handler becomes the final mediator result.

If an output channel points back to an already visited internal channel, the mediator throws RuntimeException with a circular output channel message.

Pipelines

The package uses Laravel Illuminate\Pipeline\Pipeline. Conceptually, pipelines work like middleware: a pipe receives the payload, calls $next($payload), and can run logic before or after the next pipe/handler.

namespace App\Mediator\Pipelines;

use Closure;
use Illuminate\Support\Facades\DB;

final class TransactionPipeline
{
    public function handle(mixed $payload, Closure $next): mixed
    {
        return DB::transaction(fn () => $next($payload));
    }
}

Pipelines can be configured globally:

// config/mediator.php

use App\Mediator\Pipelines\LoggingPipeline;
use App\Mediator\Pipelines\QueryCachePipeline;
use App\Mediator\Pipelines\TransactionPipeline;

'global_pipelines' => [
    LoggingPipeline::class,
    // ...
],

'command_pipelines' => [
    TransactionPipeline::class,
    // ...
],

'query_pipelines' => [
    QueryCachePipeline::class,
    // ...
],

Or locally on a command/query handler:

use App\Mediator\Pipelines\EnsureTenantContext;
use DifferentUniverse\CqbusMediator\Attributes\Handlers\CommandHandler;
use DifferentUniverse\CqbusMediator\Attributes\Pipelines\Pipeline;

#[CommandHandler]
#[Pipeline(EnsureTenantContext::class)]
final class CreateOrderHandler
{
    public function handle(CreateOrder $command): Order
    {
        // ...
    }
}

Multiple local pipelines can be declared with repeatable attributes:

#[CommandHandler]
#[Pipeline(EnsureTenantContext::class)]
#[Pipeline(ValidateInventory::class)]
final class CreateOrderHandler
{
    public function handle(CreateOrder $command): Order
    {
        // ...
    }
}

Or with several pipes in one attribute:

#[CommandHandler]
#[Pipeline(EnsureTenantContext::class, ValidateInventory::class)]
final class CreateOrderHandler
{
    // ...
}

Pipeline order:

  1. global_pipelines
  2. command_pipelines or query_pipelines
  3. local pipelines from #[Pipeline(...)]

Because Laravel pipelines wrap the next stage, "before" logic runs from first to last, while "after" logic unwinds from last to first.

Parameterized Pipelines

Laravel pipeline strings with parameters are supported.

final class LoggingPipeline
{
    public function handle(mixed $payload, Closure $next, ?string $channel = null): mixed
    {
        logger()->channel($channel)->debug('Mediator dispatch', [
            'payload' => $payload,
        ]);

        return $next($payload);
    }
}

Via attribute:

#[CommandHandler]
#[Pipeline(LoggingPipeline::class . ':slack')]
final class CreateOrderHandler
{
    // ...
}

Via config:

'global_pipelines' => [
    LoggingPipeline::class . ':slack',
],

Excluding Configured Pipelines

Use #[WithoutGlobalPipeline] to remove selected configured pipeline entries for a handler:

use DifferentUniverse\CqbusMediator\Attributes\Pipelines\WithoutGlobalPipeline;

#[CommandHandler]
#[WithoutGlobalPipeline(LoggingPipeline::class)]
final class CreateOrderHandler
{
    // ...
}

Several configured pipelines can be excluded with a repeatable attribute:

#[CommandHandler]
#[WithoutGlobalPipeline(LoggingPipeline::class)]
#[WithoutGlobalPipeline(MetricsPipeline::class)]
final class CreateOrderHandler
{
    // ...
}

Or with several pipes in one attribute:

#[CommandHandler]
#[WithoutGlobalPipeline(LoggingPipeline::class, MetricsPipeline::class)]
final class CreateOrderHandler
{
    // ...
}

The exclusion is applied to the configured pipeline list resolved for the handler type: global_pipelines plus command_pipelines for commands, or global_pipelines plus query_pipelines for queries.

WithoutGlobalPipeline compares the complete pipeline entry. If config contains a parameterized entry, you must exclude the same string:

'global_pipelines' => [
    LoggingPipeline::class . ':slack',
],

This will not remove it:

#[WithoutGlobalPipeline(LoggingPipeline::class)]
final class CreateOrderHandler
{
}

Use the full entry instead:

#[WithoutGlobalPipeline(LoggingPipeline::class . ':slack')]
final class CreateOrderHandler
{
}

Use #[WithoutAllGlobalPipelines] to remove all configured pipelines for this handler and keep only local #[Pipeline] entries:

use DifferentUniverse\CqbusMediator\Attributes\Pipelines\WithoutAllGlobalPipelines;

#[QueryHandler]
#[WithoutAllGlobalPipelines]
#[Pipeline(LocalOnlyPipeline::class)]
final class LookupOrderHandler
{
    // ...
}

Pipeline classes should be valid handler-like classes: instantiable classes with public non-static handle() methods.

Pipelines are supported for command and query handlers only. Internal handlers are not individually wrapped with pipelines.

Cache And Production Mode

In development, runtime discovery is convenient: the mediator scans configured paths and builds handler maps automatically.

In production, cache mode is always preferred. It compiles discovered handlers, internal handlers, output channels, and pipeline lists into one PHP cache file, so the application can load the prepared map instead of running discovery and reflection during runtime.

Create the cache:

php artisan mediator:cache

Clear the cache:

php artisan mediator:clear

These commands are also wired into Laravel optimization:

php artisan optimize
php artisan optimize:clear

optimize runs mediator cache generation, and optimize:clear removes the mediator cache.

Recommended production deploy flow:

php artisan config:cache
php artisan route:cache
php artisan mediator:cache

If handlers, attributes, output channels, pipeline attributes, configured pipelines, or discovery paths change, rebuild the mediator cache.

Console Commands

Generator commands:

php artisan make:mediator:command-handler Orders/CreateOrder
php artisan make:mediator:query-handler Orders/LookupOrder
php artisan make:mediator:internal-handler Audit/PublishAudit

By default, generated handlers are placed under App\Mediator and receive the Handler suffix. Both behaviors can be changed in config/mediator.php.

Cache commands:

php artisan mediator:cache
php artisan mediator:clear

Published stubs can be customized in the application's stubs directory:

php artisan vendor:publish --tag=mediator-stubs

Laravel Octane

The package works with Laravel Octane. The mediator is registered as a singleton and can be warmed when an Octane worker starts.

To warm the mediator service, add it to warm in config/octane.php:

// config/octane.php

use DifferentUniverse\CqbusMediator\Contracts\Mediator;
use Laravel\Octane\Facades\Octane;

'warm' => [
    ...Octane::defaultServicesToWarm(),
    Mediator::class,
],

This makes the worker resolve the mediator up front instead of doing it on the request.

Facade And Contract

For application code, prefer injecting the contract:

use DifferentUniverse\CqbusMediator\Contracts\Mediator;

final class CreateOrderController
{
    public function __construct(
        private readonly Mediator $mediator,
    ) {
    }

    public function __invoke(): JsonResponse
    {
        $order = $this->mediator->send(new CreateOrder(/* ... */));

        return response()->json($order, 201);
    }
}

The facade is convenient for examples, tests, seeders, small scripts, or places where facade style is already used:

use DifferentUniverse\CqbusMediator\Facades\Mediator;

$result = Mediator::send(new LookupOrder('ORD-100'));

Both expose:

public function send(object $message): mixed;

public function sendToChannel(string $inputChannel, mixed $payload = null): mixed;

Error Handling

Common exceptions:

DifferentUniverse\CqbusMediator\Exceptions\HandlerNotFoundException

Thrown when no command/query handler exists for an input channel or message class, or when an output channel references a missing internal handler.

Typical causes:

  • handler is outside discovery_paths;
  • cache is stale;
  • message class does not match the handler input channel;
  • string channel has a typo;
  • output channel points to a missing internal handler.

DifferentUniverse\CqbusMediator\Exceptions\InvalidHandlerException

Thrown during discovery/cache generation when a handler or pipeline class is structurally invalid.

Typical causes:

  • handler class is abstract or otherwise not instantiable;
  • missing handle() method;
  • handle() is private, protected, or static;
  • command/query handle() has more than one parameter;
  • automatic typed channel uses nullable, builtin, interface, missing, or intersection types;
  • duplicate input channel;
  • duplicate internal handler input channel;
  • multiple handler attributes on one class;
  • internal handler has an empty input channel.

RuntimeException

Thrown by the mediator when an internal output channel chain becomes circular.

Testing

Feature-test a full mediator flow:

use App\Mediator\Orders\CreateOrder;
use DifferentUniverse\CqbusMediator\Contracts\Mediator;

public function test_it_creates_an_order_through_the_mediator(): void
{
    $result = $this->app->make(Mediator::class)->send(new CreateOrder(
        userId: 1,
        items: [['sku' => 'book', 'quantity' => 1]],
    ));

    self::assertInstanceOf(Order::class, $result);
}

Unit-test a handler directly when you want a narrow test:

public function test_it_creates_an_order(): void
{
    $repository = Mockery::mock(OrderRepository::class);
    $repository->shouldReceive('create')->once()->andReturn(new OrderView('ORD-100'));

    $handler = new CreateOrderHandler($repository);

    $result = $handler->handle(new CreateOrder(1, []));

    self::assertSame('ORD-100', $result->number);
}

Replace handler dependencies through the Laravel container:

$this->app->instance(PaymentGateway::class, new FakePaymentGateway());

$result = $this->app->make(Mediator::class)->send(new CapturePayment('PAY-100'));

Test a flow with pipelines by configuring discovery paths and pipeline config in the test:

use DifferentUniverse\CqbusMediator\Support\MediatorConfig;

protected function tearDown(): void
{
    MediatorConfig::flushCachedPipelines();

    parent::tearDown();
}

public function test_it_runs_command_pipelines(): void
{
    config()->set('mediator.discovery_paths', [app_path('Mediator')]);
    config()->set('mediator.command_pipelines', [TransactionPipeline::class]);

    $result = $this->app->make(Mediator::class)->send(new CreateOrder(1, []));

    self::assertNotNull($result);
}

For internal handler chains, assert the final result or observable side effects:

$result = app(Mediator::class)->send(new CreateOrder(1, []));

self::assertSame([
    'handler:create-order',
    'internal:audit',
    'internal:enrich-response',
], $result['trace']);

When tests modify mediator config or cache files, reset that state between tests. In particular, call MediatorConfig::flushCachedPipelines() after changing configured pipelines, clear facade/container instances when needed, and remove bootstrap/cache/mediator.php if a test creates it.

Best Practices

  • Keep controllers thin and HTTP-focused.
  • Model one handler as one application use case.
  • Use commands for operations that change state.
  • Use queries for read operations.
  • Prefer typed message classes for main application flows.
  • Use string channels for integration, generic payload, or special scenarios where a message class would add noise.
  • Use pipelines for cross-cutting concerns: transactions, logging, retries, tenancy, metrics, and query caching.
  • Keep handlers small enough to name after the use case.
  • Avoid broad service classes that collect unrelated behavior.
  • Do not overuse internal handler chains. They are useful for clear post-processing, but long hidden workflows become hard to read.
  • Use mediator cache in production.
  • In Octane applications, warm the mediator service when you want it resolved during worker startup.

License

Laravel CQBus Mediator is open-sourced software licensed under the MIT license.