alexandrebulete / ddd-activity-bundle
Activity journal as a reusable DDD building block — who did what, through which chain of causes, and how it ended.
Package info
github.com/AlexandreBulete/ddd-activity-bundle
Type:symfony-bundle
pkg:composer/alexandrebulete/ddd-activity-bundle
Requires
- php: ^8.4
- alexandrebulete/ddd-doctrine-bridge: ^1.3
- alexandrebulete/ddd-foundation: ^1.4
- alexandrebulete/ddd-sylius-bundle: ^1.0
- alexandrebulete/ddd-symfony-bundle: ^1.5
- doctrine/dbal: ^4.3
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/doctrine-migrations-bundle: ^3.7 || ^4.0
- doctrine/migrations: ^3.8
- doctrine/orm: ^3.3
- pagerfanta/core: ^4.0
- psr/clock: ^1.0
- psr/log: ^3.0
- sylius/admin-ui: >=0.10
- sylius/grid-bundle: ^1.13
- sylius/resource-bundle: >=1.14
- symfony/clock: ^7.0 || ^8.0
- symfony/config: ^7.0 || ^8.0
- symfony/console: ^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/translation: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
Requires (Dev)
- deptrac/deptrac: ^4.7
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The activity journal of a DDD application: who did what, through which chain of causes, and how it ended — for every command, every sensitive read and every effect on the outside world.
"Who deployed lapsa to production on the 12th?" becomes a search in the back office: Pauline from Slack, an AI agent, or the system reacting to an approval — with everything that led to it and everything that followed.
Install
composer require alexandrebulete/ddd-activity-bundle bin/console doctrine:migrations:migrate
Requires alexandrebulete/ddd-symfony-bundle ≥ 1.5, whose tracing provides the
actor and the chain on every message. Symfony Security and an IAM are
optional: without them, the system is the actor of every chain.
The bundle ships its own migration (a service, so it follows
activity.table): never migrations:diff its table.
What gets journaled
| Journaled | |
|---|---|
A command (CommandInterface) |
always — unless marked #[NotJournaled] |
A query (QueryInterface) |
only when marked #[Journaled] — sensitive reads |
| An external effect | when the adapter records it (below) |
| Anything else on the buses | no |
Each entry keeps: when; who (kind, id, the name at that time, and the
credential used — an API token's id, filterable: "everything this token did");
the
action; the outcome (succeeded, failed with the error, refused); the
channel; the chain (correlation_id, causation_id, message_id); the
permission of the use case; and what the message chose to describe.
A use case refused by the authorization middleware is journaled as refused,
with who asked.
Describing a use case
Nothing to do for the defaults. To name the subject, a readable sentence and
the facts worth keeping, a message implements the foundation's
JournaledInterface — the Application layer depends on nothing else:
final readonly class ApproveDeployment implements CommandInterface, JournaledInterface { public function describeActivity(): ActivityDescription { return new ActivityDescription( subjectType: 'mission', subjectId: (string) $this->missionId, summary: 'mission.deployment_approved', // translation key summaryParams: ['release' => $this->release], details: ['release' => $this->release], ); } }
Details are scalars or lists of scalars, picked explicitly: nothing is serialized implicitly, so no secret, entity or client document slips in.
Recording an external effect
$activityJournal->recordEffect( 'github.deployment_triggered', new ActivityDescription(subjectType: 'mission', subjectId: $missionId), );
The effect joins the chain being handled. It is written on its own connection: an effect that happened stays journaled even if the transaction around it is rolled back.
Guarantees
- A success is written in the command's transaction: rolled back with it, the journal never claims what did not happen. The EntityManager is flushed first, so a failure at flush time (a constraint) is a failure.
- A failure is written on a second connection: it survives the rollback, and a closed EntityManager.
- Journaling never hides an error: an entry that cannot be written is logged, and the original exception is what surfaces.
- Once per message: the middleware sits right before the handling — a message sent to a transport is journaled where it is consumed.
Databases. PostgreSQL and MySQL get every guarantee above. SQLite has a single writer: a failure occurring after the command wrote cannot be journaled — it is logged instead, without ever blocking the application.
Who sees what
Every entry carries the permission of its use case (an effect, the one of the
use case that caused it). When the application authorizes use cases (a
PermissionCheckerInterface is registered — the IAM does it), a viewer sees:
- the entries of the use cases they may run themselves,
- the entries of a message marked
#[Journaled(visibleWith: 'mission.supervise')]when they hold that permission — to widen the audience of a sensitive read, - their own actions.
Seeing the journal at all takes activity.find_activity_entries. Without a
checker, everything is visible to whoever reaches the screen.
Back office
With Sylius Admin UI, "Activity journal" lists entries with filters (actor, action, subject, outcome, chain). "Show the chain" narrows the list to one chain, oldest first. Read-only by construction.
Retention
bin/console activity:purge # entries older than activity.retention_days
The purge is a command like any other: the journal records its own purges.
Configuration
activity: table: activity_log # default retention_days: 730 # default: 2 years admin: enabled: true # false = no Sylius screen (headless) grid_limits: [25, 50, 100]
Development
composer install
composer qa # phpstan (max + strict rules), deptrac, phpunit
The integration tests boot a kernel without Sylius nor Security, on the
database given by DDD_TEST_DATABASE_URL (a SQLite file otherwise); CI runs
them on PostgreSQL, MySQL and SQLite.