alexandrebulete / ddd-symfony-bundle
Symfony Bundle for DDD Foundation - Messenger Command/Query Bus integration
Package info
github.com/AlexandreBulete/ddd-symfony-bundle
Language:JavaScript
Type:symfony-bundle
pkg:composer/alexandrebulete/ddd-symfony-bundle
Requires
- php: ^8.4
- alexandrebulete/ddd-foundation: ^1.4
- symfony/config: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/framework-bundle: ^7.0 || ^8.0
- symfony/messenger: ^7.0 || ^8.0
- symfony/security-core: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
Requires (Dev)
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/orm: ^3.3
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
- symfony/form: ^7.0 || ^8.0
- symfony/security-bundle: ^7.0 || ^8.0
Suggests
- symfony/form: For the AutocompleteType and MarkdownType form types
Provides
None
Conflicts
None
Replaces
None
README
Symfony Bundle for DDD Foundation. Provides Messenger integration for Command/Query buses and a base Kernel for DDD projects.
Installation
composer require alexandrebulete/ddd-symfony-bundle
Configuration
Add the bundle to your config/bundles.php:
return [ // ... AlexandreBulete\DddSymfonyBundle\DddSymfonyBundle::class => ['all' => true], ];
Features
DddKernel - Auto-import Bounded Contexts
The bundle provides a DddKernel that automatically imports services, packages, and routes from your Bounded Contexts.
Setup
Extend DddKernel in your application's src/Kernel.php:
<?php declare(strict_types=1); namespace App; use AlexandreBulete\DddSymfonyBundle\DddKernel; class Kernel extends DddKernel { // You can override configureContainer/configureRoutes if needed }
What it does
The DddKernel automatically imports:
- Services:
src/*/Infrastructure/Symfony/config/services.{php,yaml} - Packages:
src/*/Infrastructure/Symfony/config/packages/*.{php,yaml} - Routes:
src/*/Infrastructure/Symfony/routes/*.{php,yaml}
This means each Bounded Context can define its own configuration without modifying the main config/ folder.
Expected BC Structure
src/
├── Post/ # Bounded Context
│ └── Infrastructure/
│ └── Symfony/
│ ├── config/
│ │ ├── services.php # Auto-imported
│ │ └── packages/
│ │ └── doctrine.yaml # Auto-imported
│ └── routes/
│ └── api.yaml # Auto-imported
└── User/ # Another Bounded Context
└── Infrastructure/
└── Symfony/
└── config/
└── services.php # Auto-imported
Command/Query Bus
The bundle automatically configures two Symfony Messenger buses:
command.bus- For write operations (Commands)query.bus- For read operations (Queries)
Automatic Handler Registration
Handlers decorated with #[AsCommandHandler] or #[AsQueryHandler] are automatically registered to their respective buses.
use AlexandreBulete\DddFoundation\Application\Command\AsCommandHandler; use AlexandreBulete\DddFoundation\Application\Command\CommandInterface; readonly class CreatePostCommand implements CommandInterface { public function __construct( public string $title, public string $content, ) {} } #[AsCommandHandler] readonly class CreatePostHandler { public function __invoke(CreatePostCommand $command): void { // Handle the command } }
Using the Buses
use AlexandreBulete\DddFoundation\Application\Command\CommandBusInterface; use AlexandreBulete\DddFoundation\Application\Query\QueryBusInterface; class PostController { public function __construct( private CommandBusInterface $commandBus, private QueryBusInterface $queryBus, ) {} public function create(): Response { $this->commandBus->dispatch(new CreatePostCommand( title: 'My Post', content: 'Content...', )); // ... } public function list(): Response { $posts = $this->queryBus->ask(new GetPostsQuery( page: 1, itemsPerPage: 10, )); // ... } }
Autocomplete Form Field
The bundle provides an Autocomplete Symfony Form field powered by Stimulus + Tom Select (no jQuery).
It is designed for admin back-offices and DDD projects, and works with ID-based values (Value Objects friendly).
Assets setup (Importmap)
If your application uses Importmap / AssetMapper:
php bin/console importmap:require tom-select php bin/console importmap:require tom-select/dist/css/tom-select.default.css
Enable the Stimulus controller in assets/controllers.json:
{
"controllers": {
"@alexandrebulete/ddd-symfony-bundle": {
"autocomplete": {
"enabled": true,
"fetch": "eager",
"path": "@alexandrebulete/ddd-symfony-bundle/autocomplete_controller",
"autoimport": {
"tom-select/dist/css/tom-select.default.css": true
}
}
}
}
}
Usage
use AlexandreBulete\DddSymfonyBundle\Form\Type\AutocompleteType; $builder->add('authorId', AutocompleteType::class, [ 'label' => 'Author', 'remote_url' => $this->urlGenerator->generate('admin_user_autocomplete'), 'placeholder' => 'Search by email…', 'min_length' => 2, ]);
The autocomplete endpoint must return:
{
"results": [
{ "id": "uuid-1", "text": "user@example.com" }
]
}
Options
| Option | Description |
|---|---|
| remote_url | AJAX endpoint URL |
| placeholder | Input placeholder |
| min_length | Minimum characters before search |
| limit | Maximum results returned |
| initial_text | Preselected label (edit forms) |
Tracing: who acts, and what caused what
Every message on command.bus and query.bus carries two stamps, set when it
is dispatched:
ActorStamp— who asks: auser, anagent, or thesystem;TraceStamp— its place in a chain of causes: its ownmessageId, thecorrelationIdof the whole chain, thecausationIdof the message that sent it, and thechannelthe chain entered through (http,cli, …).
Who acts is decided once, at the entry point:
| Situation | Actor |
|---|---|
| A signed-in user dispatches (back office, API) | that user |
| A message is sent while another is handled | system, same chain |
TraceContext::runAs($actor, $channel, fn) (agent runtime, webhook) |
$actor |
An explicit ActorStamp |
that actor |
| A message consumed from a transport | what it was sent with |
| CLI, cron, nobody signed in | system |
Read the current trace from anywhere — an audit writer, a logger:
$trace = $traceContext->current(); // ?Trace $trace->actor->label; // "Pauline Martin" $trace->stamp->correlationId; // the whole chain
A security user tells which account it is by implementing
ActorAwareInterface (SecurityUser accepts an Actor); otherwise its
identifier is used. An actor may also name the credential it came with —
an API token's id, never the token — so that everything one token did can be
found: Actor::agent($id, $name, $tokenId). Actors serialized before that
field existed are still read. Symfony Security is optional: without it, the system is
the actor of every chain.
Stamps are set by a decorator of each bus, at dispatch time: it is the only
moment a message sent with DispatchAfterCurrentBusStamp still knows its
cause. A bus of your own gets the same behaviour by adding
ddd.messenger.tracing_middleware, minus that deferred case.
Authorization: one permission per use case
Every command and query is a permission, discovered from its handler when the container is built — nothing to declare:
App\Client\Application\Query\FindClients\FindClientsQuery → client.find_clients
#[Permission('client.read')] (from ddd-foundation) on a message keeps the id
stable across a rename, or groups several use cases under one permission.
PermissionRegistry lists them all (a role screen reads it);
PermissionProviderInterface adds those no message carries
(backoffice.access, checked by the firewall).
Enforcement is opt-in: it turns on when a PermissionCheckerInterface exists —
an IAM provides one (ddd-iam-bundle). Then:
- a middleware, right before the handling, refuses a command or query its
actor has no permission for (
PermissionDenied, anAccessDeniedException: 403 over HTTP); the system actor is not checked; an async message is checked again in the worker; - the build fails if a use case has no usable permission (outside the
<Context>\Application\…convention without#[Permission], or two messages deriving the same id by accident) — none can escape silently.
Without a checker, nothing is enforced and nothing is required: an application that does not use authorization is not asked to comply.
Development
composer install
composer qa # phpstan (max + strict rules), then phpunit
The integration test boots a kernel with Framework and Doctrine only — no Security, no AssetMapper: the minimum the bundle must work with.
Customization
Override Messenger Configuration
You can override the messenger configuration by creating your own config/packages/messenger.yaml:
framework: messenger: buses: command.bus: middleware: - doctrine_transaction query.bus: ~
Extend DddKernel
If you need custom container or routes configuration, override the methods:
<?php declare(strict_types=1); namespace App; use AlexandreBulete\DddSymfonyBundle\DddKernel; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; class Kernel extends DddKernel { protected function configureContainer(ContainerConfigurator $container): void { parent::configureContainer($container); // Add your custom imports here $container->import($this->getProjectDir().'/config/custom/*.yaml'); } }