Search by

alexandrebulete / ddd-symfony-bundle

Alexandre Buleté

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

Statistics

Installs: 147

Dependents: 5

Suggesters: 0

Stars: 13

Open Issues: 2

1.5.0 2026-09-29 15:36 UTC

This package is auto-updated.

Last update: 2026-09-29 16:09:41 UTC


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: a user, an agent, or the system;
  • TraceStamp — its place in a chain of causes: its own messageId, the correlationId of the whole chain, the causationId of the message that sent it, and the channel the 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, an AccessDeniedException: 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');
    }
}