friends-of-ddd/event-driven

Interfaces for event-driven architecture

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Forks: 0

pkg:composer/friends-of-ddd/event-driven

0.1.0 2025-11-03 23:21 UTC

This package is auto-updated.

Last update: 2025-11-03 22:28:59 UTC


README

An abstraction for event bus and command bus patterns following Domain-Driven Design principles.

This library provides a set of interfaces and utilities to implement event-driven architecture in PHP applications, supporting both Command Bus and Event Bus patterns with type-safe message handling.

Requirements

  • PHP: >= 8.2
  • Dependencies:
    • psr/container: ^2.0

Installation

Install the package via Composer:

composer require friends-of-ddd/event-driven

Features

  • 🎯 Command Bus Pattern: Type-safe command dispatching with generic support
  • 📢 Event Bus Pattern: Event-driven architecture support
  • 🧩 Event-Aware Entities: Built-in trait for domain entities to record and dispatch events
  • 📦 Message Collections: Type-safe collections for managing messages with filtering capabilities
  • 🔒 Type Safety: Full PHPStan level 8 compatibility with generics support
  • 🎨 PSR Compliant: Follows PSR standards and best practices

Core Concepts

Messages

All messages (commands and events) implement the base MessageInterface:

<?php

use FriendsOfDdd\EventDriven\Domain\MessageInterface;

interface MessageInterface
{
}

Commands

Commands represent intentions to perform an action in your application:

<?php

use FriendsOfDdd\EventDriven\Application\CommandInterface;

final readonly class CreateTicketCommand implements CommandInterface
{
    public function __construct(
        public string $title,
        public int $clientId,
        public ?int $topicId = null,
    ) {
    }
}

Events

Events represent something that has happened in your domain:

<?php

use FriendsOfDdd\EventDriven\Domain\EventInterface;

final readonly class TicketCreatedEvent implements EventInterface
{
    public function __construct(
        public int $ticketId,
        public string $title,
    ) {
    }
}

Usage Examples

1. Command Bus

Implement the MessageBusInterface to create a command bus:

<?php

use FriendsOfDdd\EventDriven\Application\CommandInterface;
use FriendsOfDdd\EventDriven\Application\MessageBusInterface;
use FriendsOfDdd\EventDriven\Domain\MessageInterface;
use Psr\Container\ContainerInterface;

/**
 * @implements MessageBusInterface<CommandInterface>
 */
final class CommandBus implements MessageBusInterface
{
    /**
     * @var array<class-string<CommandInterface>, class-string>
     */
    private array $handlers = [];

    public function __construct(
        private readonly ContainerInterface $container,
    ) {
    }

    /**
     * @param class-string<CommandInterface> $commandClass
     * @param class-string $handlerClass
     */
    public function register(string $commandClass, string $handlerClass): void
    {
        $this->handlers[$commandClass] = $handlerClass;
    }

    public function dispatch(MessageInterface ...$messages): void
    {
        foreach ($messages as $command) {
            if (!isset($this->handlers[$command::class])) {
                throw new \RuntimeException(
                    'No handler registered for ' . $command::class
                );
            }

            $handler = $this->container->get($this->handlers[$command::class]);
            $handler->handle($command);
        }
    }
}

Using the Command Bus:

<?php

// Create a command
$command = new CreateTicketCommand(
    title: 'Fix login issue',
    clientId: 123,
    topicId: 5,
);

// Dispatch the command
$commandBus->dispatch($command);

// You can also dispatch multiple commands at once
$commandBus->dispatch(
    new CreateTicketCommand('First ticket', 123, 5),
    new CreateTicketCommand('Second ticket', 124, 6),
);

2. Event-Aware Entities

Use the EventAwareTrait to enable domain entities to record events:

<?php

use FriendsOfDdd\EventDriven\Domain\EventAwareTrait;

class TicketEntity
{
    use EventAwareTrait;

    private function __construct(
        public readonly int $id,
        private string $title,
        public readonly int $clientId,
        private ?int $topicId = null,
    ) {
    }

    public static function createNew(
        int $id,
        string $title,
        int $clientId,
        ?int $topicId = null,
    ): self {
        $instance = new self($id, $title, $clientId, $topicId);
        
        // Record domain event
        $instance->recordEvents(
            new TicketCreatedEvent($id, $title)
        );

        return $instance;
    }

    public function updateTitle(string $newTitle): void
    {
        $this->title = $newTitle;
        
        // Record multiple events
        $this->recordEvents(
            new TicketUpdatedEvent($this->id),
            new TitleChangedEvent($this->id, $newTitle),
        );
    }

    public function getTitle(): string
    {
        return $this->title;
    }
}

Retrieving and Dispatching Recorded Events:

<?php

// Create a new ticket
$ticket = TicketEntity::createNew(
    id: 1,
    title: 'Login issue',
    clientId: 123,
);

// Pop recorded events (this clears the internal event collection)
$events = $ticket->popRecordedEvents();

// Dispatch events to an event bus
foreach ($events as $event) {
    $eventBus->dispatch($event);
}

// Or dispatch all at once if your bus supports it
$eventBus->dispatch(...$events->toArray());

3. Message Collections

Work with type-safe collections of messages:

<?php

use FriendsOfDdd\EventDriven\Domain\MessageCollection;
use FriendsOfDdd\EventDriven\Domain\EventInterface;

// Create a collection
$collection = new MessageCollection(
    new TicketCreatedEvent(1, 'First ticket'),
    new TicketCreatedEvent(2, 'Second ticket'),
    new UserNotifiedEvent(123),
);

// Add more messages
$collection = $collection->add(
    new TicketCreatedEvent(3, 'Third ticket')
);

// Filter by specific event types
$ticketEvents = $collection->filterByTypes(
    TicketCreatedEvent::class,
);

// Convert to array
$eventsArray = $collection->toArray();

// Iterate over collection
foreach ($collection as $event) {
    echo get_class($event) . "\n";
}

// Check if collection is not empty
if ($collection->isNotEmpty()) {
    // Get first event or null
    $firstEvent = $collection->firstOrNull();
}

// Compare collections (diff by instance)
$collection1 = new MessageCollection(
    $event1,
    $event2,
);
$collection2 = new MessageCollection(
    $event2,
);
$diff = $collection1->diffInstances($collection2); // Returns collection with $event1 only

4. Complete Example: Ticket System

Here's a complete example showing all components working together:

<?php

namespace App\Ticketing;

use FriendsOfDdd\EventDriven\Application\CommandInterface;
use FriendsOfDdd\EventDriven\Application\MessageBusInterface;
use FriendsOfDdd\EventDriven\Domain\EventInterface;
use FriendsOfDdd\EventDriven\Domain\EventAwareTrait;

// 1. Define Commands
final readonly class CreateTicketCommand implements CommandInterface
{
    public function __construct(
        public string $title,
        public int $clientId,
    ) {
    }
}

// 2. Define Events
final readonly class TicketCreatedEvent implements EventInterface
{
    public function __construct(
        public int $ticketId,
        public string $title,
    ) {
    }
}

// 3. Create Entity
class Ticket
{
    use EventAwareTrait;

    private function __construct(
        public readonly int $id,
        public readonly string $title,
        public readonly int $clientId,
    ) {
    }

    public static function create(int $id, string $title, int $clientId): self
    {
        $ticket = new self($id, $title, $clientId);
        $ticket->recordEvents(new TicketCreatedEvent($id, $title));
        return $ticket;
    }
}

// 4. Create Command Handler
final class CreateTicketCommandHandler
{
    public function __construct(
        private readonly TicketRepository $repository,
        private readonly MessageBusInterface $eventBus,
    ) {
    }

    public function handle(CreateTicketCommand $command): void
    {
        $ticket = Ticket::create(
            id: $this->repository->nextId(),
            title: $command->title,
            clientId: $command->clientId,
        );

        $this->repository->save($ticket);

        // Dispatch domain events
        $this->eventBus->dispatch(...$ticket->popRecordedEvents()->toArray());
    }
}

// 5. Create Event Handler
final class NotifyClientWhenTicketCreated
{
    public function __construct(
        private readonly NotificationService $notificationService,
    ) {
    }

    public function handle(TicketCreatedEvent $event): void
    {
        $this->notificationService->notify(
            "Ticket #{$event->ticketId} has been created"
        );
    }
}

// 6. Usage
$command = new CreateTicketCommand('Fix login bug', 123);
$commandBus->dispatch($command);

Testing

The library includes a test implementation of a command bus that can be useful for testing:

<?php

use FriendsOfDdd\EventDriven\Tests\Kit\Messaging\TestCommandBus;

$commandBus = new TestCommandBus();

// Register handlers
$commandBus->addCommandHandler(
    CreateTicketCommand::class,
    function (CreateTicketCommand $command) {
        // Handle command in test
        $this->assertSame('Expected title', $command->title);
    }
);

// Dispatch command
$commandBus->dispatch(new CreateTicketCommand('Expected title', 123));

Best Practices

  1. Keep Commands and Events Immutable: Use readonly classes to ensure messages cannot be modified after creation.

  2. Record Events in Entities: Use the EventAwareTrait to record domain events within your entities, not in application services.

  3. One Handler Per Command: Each command should have exactly one handler.

  4. Multiple Handlers Per Event: Events can have zero or more handlers (subscribers).

  5. Type Safety: Leverage PHPStan generics to ensure type safety in your message buses.

  6. Command Naming: Commands should be named in imperative form (e.g., CreateTicket, UpdateUser).

  7. Event Naming: Events should be named in past tense (e.g., TicketCreated, UserUpdated).

License

This library is licensed under the MIT License. See the LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Author