Install the Package
composer require digital-craftsman/cqs-routing
Ensure your Laravel project uses Symfony’s HttpKernel (via symfony/http-kernel-bundle or spatie/laravel-symfony-support).
Configure the Package
Create config/cqs-routing.php (or merge into an existing config file):
return [
'request_decoder' => \DigitalCraftsman\CQSRouting\RequestDecoder\JsonRequestDecoder::class,
'response_constructor' => \DigitalCraftsman\CQSRouting\ResponseConstructor\SerializerJsonResponseConstructor::class,
'dto_constructor' => \DigitalCraftsman\CQSRouting\DTOConstructor\SerializerDTOConstructor::class,
'handler_wrapper' => \DigitalCraftsman\CQSRouting\HandlerWrapper\ConnectionTransactionWrapper::class,
'dto_validators' => [
// Example: AccessValidator
\App\Application\CQSRouting\DTOValidator\AccessValidator::class => [
'parameters' => \App\Application\CQSRouting\DTOValidator\DTO\AccessValidatorParameters::class,
],
],
];
First Use Case: Command/Query Endpoint
Define a command (e.g., CreateUserCommand) and a handler (e.g., CreateUserCommandHandler).
Register the route in routes/web.php:
use DigitalCraftsman\CQSRouting\CQSRouting;
use App\Application\Commands\CreateUserCommand;
Route::post('/users', function () {
return app(CQSRouting::class)
->handle(CreateUserCommand::class);
});
Ensure your command has a #[AsRequest] attribute (or configure via DI).
#[AsCommand], #[AsQuery]) to tag DTOs.#[AsCommand]
class UpdateProfileCommand {
public function __construct(
public string $userId,
public array $data,
) {}
}
JsonRequestDecoder for JSON payloads).SerializerJsonResponseConstructor for JSON APIs).$routing = app(CQSRouting::class)
->setRequestDecoder(new CustomRequestDecoder())
->setResponseConstructor(new CustomResponseConstructor());
DTOValidator interfaces (e.g., AccessValidator) to enforce rules.
#[AsCommand]
class DeletePostCommand {
public function __construct(
public string $postId,
) {}
}
Register validator in config:
'dto_validators' => [
\App\Application\CQSRouting\DTOValidator\OwnershipValidator::class,
],
Kernel::addMiddleware():
$routing->addMiddleware(new AuthMiddleware());
ConnectionTransactionWrapper (or custom) to manage DB transactions:
'handler_wrapper' => \DigitalCraftsman\CQSRouting\HandlerWrapper\ConnectionTransactionWrapper::class,
class QueueHandlerWrapper implements HandlerWrapperInterface {
public function wrap(HandlerInterface $handler): HandlerInterface {
return new QueuedHandler($handler);
}
}
CQSRouting service in tests:
$routing = $this->createMock(CQSRouting::class);
$routing->method('handle')
->with(CreateUserCommand::class)
->willReturn(new CreateUserResponse('user-123'));
HttpTestCase for full request/response cycles:
$response = $this->postJson('/users', ['name' => 'John']);
$response->assertJson(['id' => 'user-123']);
Attribute vs. Config Conflicts
#[AsRequest] attributes, ensure they align with your config (e.g., JsonRequestDecoder expects JSON payloads).#[AsRequest(decoder: CustomDecoder::class)].Circular Dependencies in Validators
AuthService). Use constructor injection:
public function __construct(private AuthService $auth) {}
$this->app->bind(
\App\Application\CQSRouting\DTOValidator\OwnershipValidator::class,
function ($app) {
return new OwnershipValidator($app->make(AuthService::class));
}
);
Transaction Scope Leaks
ConnectionTransactionWrapper defaults to a single DB connection. For multi-DB setups:
'handler_wrapper' => function () {
return new ConnectionTransactionWrapper(
app('db.connection.primary'),
app('db.connection.secondary')
);
},
Performance with Large DTOs
SerializerDTOConstructor with JMS\Serializer or Symfony\Component\Serializer.Route Caching Incompatibilities
CQSRouting handlers. Disable caching for CQS routes:
Route::post('/users', function () { /* ... */ })
->middleware('cache.off');
Log DTO Validation Errors
DTOValidator to log failures:
public function validate($dto, array $parameters): void {
if (!$this->isValid($dto)) {
\Log::error('Validation failed for ' . get_class($dto), [
'errors' => $this->getErrors(),
]);
throw new ValidationException($this->getErrors());
}
}
Inspect Handler Execution
HandlerWrapper to log input/output:
class LoggingHandlerWrapper implements HandlerWrapperInterface {
public function wrap(HandlerInterface $handler): HandlerInterface {
return new class($handler) implements HandlerInterface {
public function __invoke($command) {
\Log::debug('Handling', ['command' => get_class($command)]);
$result = $this->handler($command);
\Log::debug('Result', ['result' => $result]);
return $result;
}
public function __construct(private HandlerInterface $handler) {}
};
}
}
Symfony vs. Laravel Kernel
spatie/laravel-symfony-support, ensure the Symfony kernel is the primary HTTP kernel:
// config/app.php
'kernel' => \Spatie\SymfonySupport\Laravel\Kernel::class,
Custom Request Decoders
class FormRequestDecoder implements RequestDecoderInterface {
public function decode(Request $request): object {
return (new FormDataDTO())->populate($request->all());
}
}
Dynamic Route Binding
Route::put('/users/{userId}', function (CreateUserCommand $command) {
return app(CQSRouting::class)->handle($command);
})->bind('userId', function ($userId) {
return new CreateUserCommand($userId, [/* ... */]);
});
Event-Driven Handlers
class EventDispatchingHandlerWrapper implements HandlerWrapperInterface {
public function wrap(HandlerInterface $handler): HandlerInterface {
return new class($handler) implements HandlerInterface {
public function __invoke($command) {
$result = $this->handler($command);
event(new CommandHandled($command, $result));
return $result;
}
public function __construct(private HandlerInterface $handler) {}
};
}
}
4
How can I help you explore Laravel packages today?