1tomany/rich-bundle
Symfony bundle implementing the RICH (Request, Input, Command, Handler) architecture. Encourages single-responsibility actions with explicit Input/Command/Handler classes for clear, safe, and futureproof backend development without heavy DDD/CQRS overhead.
Installation
composer require 1tomany/rich-bundle
Ensure OneToMany\RichBundle\OneToManyRichBundle is registered in config/bundles.php.
Generate Module Structure
Use the provided CLI command to scaffold a module (e.g., Account):
./vendor/bin/create-rich-module Account
This creates the directory structure and basic contracts (RepositoryInterface, ExceptionInterface).
First Use Case: Create a Command
CreateAccountCommand in src/Module/Account/Action/Command/ implementing CommandInterface.final readonly class CreateAccountCommand implements CommandInterface {
public function __construct(
public string $name,
public string $email,
) {}
}
Create an Input Class
CreateAccountInput in src/Module/Account/Action/Input/ implementing InputInterface.#[SourceRequest] to map request data and add validation constraints.toCommand() to convert input to a command.Create a Handler
CreateAccountHandler in src/Module/Account/Action/Handler/:
final readonly class CreateAccountHandler {
public function __construct(
private AccountRepositoryInterface $repository,
) {}
public function __invoke(CreateAccountCommand $command) {
$account = new Account($command->name, $command->email);
$this->repository->save($account);
}
}
Register Handler in Symfony
Add the handler as a service in config/services.yaml:
services:
App\Module\Account\Action\Handler\CreateAccountHandler:
arguments:
$repository: '@App\Repository\AccountRepository'
Create a Controller
#[AsController] and inject the handler:
final readonly class AccountController {
public function __construct(
private CreateAccountHandler $handler,
) {}
#[Route('/accounts', methods: ['POST'])]
public function create(CreateAccountInput $input): JsonResponse {
$this->handler($input->toCommand());
return new JsonResponse(['status' => 'created']);
}
}
Request Handling
#[SourceRequest], #[SourceUser], etc., to map request data to input properties. The bundle automatically validates and converts the input to a command.Command-Handler Binding
$handler($command);
EventDispatcherInterface).Error Handling
ExceptionInterface. Catch them in controllers or middleware:
try {
$handler($command);
} catch (AccountExceptionInterface $e) {
return new JsonResponse(['error' => $e->getMessage()], 400);
}
if (!$input->isValid()) {
return new JsonResponse(['errors' => $input->getErrors()], 400);
}
Testing
HttpClient to test full request-handler workflows:
$client = static::createClient();
$client->request('POST', '/accounts', ['json' => ['name' => 'Test']]);
$this->assertResponseIsSuccessful();
Doctrine Integration
ServiceEntityRepository classes.class AccountRepository implements AccountRepositoryInterface {
public function findOneById(?int $id): ?Account {
return $this->createQueryBuilder('a')
->where('a.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
}
Symfony Forms
#[Assert] constraints for validation:
$form = $this->createForm(CreateAccountInput::class);
Messaging (Async)
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Module\Account\Action\Command\CreateAccountCommand': async
API Platform
#[ApiResource(
operations: [
new Create(
input: CreateAccountInput::class,
processor: CreateAccountHandler::class,
),
],
)]
class Account {}
Immutability Violations
readonly or final may throw errors if modified.final readonly class CreateAccountCommand {
public function __construct(
public string $name,
public string $email,
) {}
}
Circular Dependencies
Validation Overhead
#[SourceRequest] or #[SourceUser] can lead to verbose input classes.AddressInput) and validate them separately.Handler State Management
$entityManager->persist($entity)) may fail in async contexts.EntityManagerInterface to manage the lifecycle:
$account = $this->repository->findOneById($command->id);
if (!$account) {
throw new AccountNotFoundException();
}
$account->update($command->name);
$this->entityManager->flush();
Attribute Conflicts
#[MapRequestPayload] may conflict with bundle attributes.\OneToMany\RichBundle\Attribute\SourceRequest) and prioritize bundle attributes in validation.Validation Errors
php bin/console debug:validator App\Module\Account\Action\Input\CreateAccountInput
#[Assert\Callback] for complex validation logic:
#[Assert\Callback]
public function validate(ExecutionContextInterface $context) {
if ($this->email === $this->name) {
$context->buildViolation('Email and name cannot match.')
->addViolation();
}
}
Handler Not Found
services.yaml:
services:
App\Module\Account\Action\Handler\CreateAccountHandler:
tags: ['controller.service_arguments']
Request Data Mapping
#[SourceRequest(path: 'data.name')] to map nested JSON payloads.AbstractSourceAttribute.Custom Attributes
AbstractSourceAttribute to create new data sources (e.g., #[SourceHeader]):
#[Attribute(Attribute::TARGET_PROPERTY)]
final class SourceHeaderAttribute extends AbstractSourceAttribute {
public function getValue(mixed $request, string $property): mixed {
return $request->headers->get($this->name);
}
}
Command Serialization
JsonSerializable or use Symfony’s SerializerInterface to customize command serialization for async queues:
final readonly class CreateAccountCommand implements CommandInterface, JsonSerializable {
public function jsonSerialize(): array {
return [
'name' => $this->name
How can I help you explore Laravel packages today?