⚡ Event System Implementation Guide¶
Build decoupled, extensible applications with XOOPS 2026's event system.
Events enable loose coupling between components. When something significant happens, an event is dispatched, and any number of listeners can react without the dispatcher knowing about them.
Why Events?¶
The Problem: Tight Coupling¶
<?php
// ❌ Bad: Direct coupling
class ArticleService
{
public function publish(Article $article): void
{
$article->publish();
$this->repository->save($article);
// Tightly coupled to all these concerns
$this->cache->invalidate('articles');
$this->searchIndexer->index($article);
$this->notificationService->notifySubscribers($article);
$this->analyticsService->trackPublish($article);
$this->webhookService->triggerWebhooks($article);
// ... and more
}
}
The Solution: Events¶
<?php
// ✅ Good: Event-driven
class ArticleService
{
public function publish(Article $article): void
{
$article->publish();
$this->repository->save($article);
// Just dispatch the event
$this->dispatcher->dispatch(new ArticlePublished($article));
}
}
// Listeners handle the rest, completely decoupled
Visualization¶
flowchart LR
subgraph Publisher
SVC[ArticleService]
end
subgraph Event
EVT[ArticlePublished]
end
subgraph Listeners
L1[CacheInvalidator]
L2[SearchIndexer]
L3[NotificationSender]
L4[AnalyticsTracker]
L5[WebhookTrigger]
end
SVC -->|dispatch| EVT
EVT --> L1
EVT --> L2
EVT --> L3
EVT --> L4
EVT --> L5 PSR-14 Event Dispatcher¶
The Standard Interfaces¶
<?php
namespace Psr\EventDispatcher;
interface EventDispatcherInterface
{
/**
* Provide all relevant listeners with an event to process.
*/
public function dispatch(object $event): object;
}
interface ListenerProviderInterface
{
/**
* @return iterable<callable>
*/
public function getListenersForEvent(object $event): iterable;
}
interface StoppableEventInterface
{
/**
* Is propagation stopped?
*/
public function isPropagationStopped(): bool;
}
Creating Events¶
Basic Event¶
<?php
declare(strict_types=1);
namespace MyModule\Event;
final readonly class ArticlePublished
{
public function __construct(
public int $articleId,
public string $title,
public int $authorId,
public \DateTimeImmutable $publishedAt = new \DateTimeImmutable(),
) {}
}
Event with Entity¶
<?php
declare(strict_types=1);
namespace MyModule\Event;
use MyModule\Entity\Article;
final readonly class ArticleCreated
{
public function __construct(
public Article $article,
public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
) {}
public function getArticleId(): int
{
return $this->article->getId();
}
}
Stoppable Event¶
<?php
declare(strict_types=1);
namespace MyModule\Event;
use Psr\EventDispatcher\StoppableEventInterface;
final class ArticleBeforeDelete implements StoppableEventInterface
{
private bool $stopped = false;
private ?string $reason = null;
public function __construct(
public readonly Article $article,
) {}
public function stopPropagation(string $reason): void
{
$this->stopped = true;
$this->reason = $reason;
}
public function isPropagationStopped(): bool
{
return $this->stopped;
}
public function getReason(): ?string
{
return $this->reason;
}
}
Event Naming Conventions¶
flowchart TB
subgraph Naming["Event Naming Pattern"]
direction LR
ENTITY[Entity Name]
ACTION[Action/State]
TENSE[Past Tense]
end
ENTITY --> |"Article"| EX1[ArticleCreated]
ACTION --> |"Create"| EX1
TENSE --> |"Created"| EX1
subgraph Examples["Common Events"]
E1[ArticleCreated]
E2[ArticleUpdated]
E3[ArticlePublished]
E4[ArticleDeleted]
E5[UserRegistered]
E6[OrderPlaced]
end | Pattern | Example | Use Case |
|---|---|---|
{Entity}Created | ArticleCreated | After entity creation |
{Entity}Updated | ArticleUpdated | After entity modification |
{Entity}Deleted | ArticleDeleted | After entity removal |
{Entity}Before{Action} | ArticleBeforeDelete | Before action (cancellable) |
{Entity}{State} | ArticlePublished | State transition |
Creating Listeners¶
Basic Listener¶
<?php
declare(strict_types=1);
namespace MyModule\Listener;
use MyModule\Event\ArticlePublished;
final class InvalidateCacheOnArticlePublish
{
public function __construct(
private readonly CacheInterface $cache,
) {}
public function __invoke(ArticlePublished $event): void
{
$this->cache->delete("article.{$event->articleId}");
$this->cache->delete('articles.list');
$this->cache->delete('articles.recent');
}
}
Listener with Multiple Methods¶
<?php
declare(strict_types=1);
namespace MyModule\Listener;
final class SearchIndexListener
{
public function __construct(
private readonly SearchIndex $index,
) {}
public function onArticleCreated(ArticleCreated $event): void
{
$this->index->add($event->article);
}
public function onArticleUpdated(ArticleUpdated $event): void
{
$this->index->update($event->article);
}
public function onArticleDeleted(ArticleDeleted $event): void
{
$this->index->remove($event->articleId);
}
}
Async Listener¶
<?php
declare(strict_types=1);
namespace MyModule\Listener;
use Xoops\Queue\ShouldQueue;
final class SendNotificationOnPublish implements ShouldQueue
{
public string $queue = 'notifications';
public int $delay = 0;
public function __construct(
private readonly NotificationService $notifications,
private readonly SubscriberRepository $subscribers,
) {}
public function __invoke(ArticlePublished $event): void
{
$subscribers = $this->subscribers->findByArticleCategory(
$event->article->getCategoryId()
);
foreach ($subscribers as $subscriber) {
$this->notifications->send($subscriber, $event->article);
}
}
}
Registering Listeners¶
In module.json¶
{
"events": {
"subscribe": [
{
"event": "MyModule\\Event\\ArticlePublished",
"handler": "MyModule\\Listener\\InvalidateCacheOnArticlePublish",
"priority": 100
},
{
"event": "MyModule\\Event\\ArticlePublished",
"handler": "MyModule\\Listener\\UpdateSearchIndex",
"priority": 50
},
{
"event": "MyModule\\Event\\ArticlePublished",
"handler": "MyModule\\Listener\\SendNotifications",
"priority": 10
}
]
}
}
In Service Provider¶
<?php
declare(strict_types=1);
namespace MyModule\Provider;
use Xoops\Container\ServiceProvider;
use Xoops\Event\ListenerProvider;
final class EventServiceProvider extends ServiceProvider
{
public function boot(ContainerInterface $container): void
{
$provider = $container->get(ListenerProvider::class);
// Register listeners
$provider->addListener(
ArticlePublished::class,
$container->get(InvalidateCacheOnArticlePublish::class),
priority: 100
);
$provider->addListener(
ArticlePublished::class,
$container->get(UpdateSearchIndex::class),
priority: 50
);
// Register subscriber (multiple events)
$provider->addSubscriber(
$container->get(ArticleEventSubscriber::class)
);
}
}
Using Attributes¶
<?php
declare(strict_types=1);
namespace MyModule\Listener;
use Xoops\Event\Attribute\Listener;
#[Listener(ArticlePublished::class, priority: 100)]
final class InvalidateCacheOnArticlePublish
{
public function __invoke(ArticlePublished $event): void
{
// Handle event
}
}
#[Listener(ArticleCreated::class)]
#[Listener(ArticleUpdated::class)]
#[Listener(ArticleDeleted::class)]
final class SearchIndexListener
{
public function __invoke(object $event): void
{
// Handle multiple event types
}
}
Event Subscribers¶
Creating a Subscriber¶
<?php
declare(strict_types=1);
namespace MyModule\Subscriber;
use Xoops\Event\EventSubscriberInterface;
final class ArticleEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly CacheInterface $cache,
private readonly SearchIndex $search,
private readonly LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
ArticleCreated::class => [
['onCreated', 100],
['indexArticle', 50],
],
ArticleUpdated::class => [
['onUpdated', 100],
['indexArticle', 50],
],
ArticleDeleted::class => [
['onDeleted', 100],
['removeFromIndex', 50],
],
];
}
public function onCreated(ArticleCreated $event): void
{
$this->logger->info('Article created', ['id' => $event->articleId]);
$this->cache->delete('articles.count');
}
public function onUpdated(ArticleUpdated $event): void
{
$this->cache->delete("article.{$event->articleId}");
}
public function onDeleted(ArticleDeleted $event): void
{
$this->cache->delete("article.{$event->articleId}");
$this->cache->delete('articles.count');
}
public function indexArticle(ArticleCreated|ArticleUpdated $event): void
{
$this->search->index($event->article);
}
public function removeFromIndex(ArticleDeleted $event): void
{
$this->search->remove($event->articleId);
}
}
Dispatching Events¶
Basic Dispatch¶
<?php
class ArticleService
{
public function __construct(
private readonly ArticleRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
) {}
public function create(CreateArticleCommand $command): Article
{
$article = Article::create(
title: $command->title,
content: $command->content,
authorId: $command->authorId,
);
$this->repository->save($article);
$this->dispatcher->dispatch(new ArticleCreated($article));
return $article;
}
}
Event Flow¶
sequenceDiagram
participant S as Service
participant D as Dispatcher
participant P as Provider
participant L1 as Listener 1
participant L2 as Listener 2
participant L3 as Listener 3
S->>D: dispatch(ArticleCreated)
D->>P: getListenersForEvent()
P-->>D: [L1, L2, L3]
D->>L1: __invoke(event)
L1-->>D: done
D->>L2: __invoke(event)
L2-->>D: done
D->>L3: __invoke(event)
L3-->>D: done
D-->>S: event (possibly modified) Stopping Propagation¶
<?php
class ArticleService
{
public function delete(int $articleId): void
{
$article = $this->repository->findOrFail($articleId);
// Dispatch "before" event - can be stopped
$event = new ArticleBeforeDelete($article);
$this->dispatcher->dispatch($event);
if ($event->isPropagationStopped()) {
throw new CannotDeleteArticleException($event->getReason());
}
$this->repository->delete($article);
// Dispatch "after" event
$this->dispatcher->dispatch(new ArticleDeleted($articleId));
}
}
sequenceDiagram
participant S as Service
participant D as Dispatcher
participant L1 as Validator
participant L2 as Checker
S->>D: dispatch(BeforeDelete)
D->>L1: __invoke(event)
L1->>L1: Check conditions
alt Validation fails
L1->>L1: event.stopPropagation()
L1-->>D: done
D-->>S: event (stopped)
S->>S: Throw exception
else Validation passes
L1-->>D: done
D->>L2: __invoke(event)
L2-->>D: done
D-->>S: event
S->>S: Continue deletion
end Domain Events¶
Collecting Events in Entities¶
<?php
declare(strict_types=1);
namespace MyModule\Entity;
abstract class AggregateRoot
{
/** @var list<object> */
private array $domainEvents = [];
protected function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
/** @return list<object> */
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
}
final class Article extends AggregateRoot
{
public static function create(string $title, string $content): self
{
$article = new self($title, $content);
$article->recordEvent(new ArticleCreated($article));
return $article;
}
public function publish(): void
{
$this->status = ArticleStatus::Published;
$this->publishedAt = new \DateTimeImmutable();
$this->recordEvent(new ArticlePublished($this));
}
public function archive(): void
{
$this->status = ArticleStatus::Archived;
$this->recordEvent(new ArticleArchived($this));
}
}
Dispatching Domain Events¶
<?php
final class ArticleService
{
public function publish(int $articleId): void
{
$article = $this->repository->findOrFail($articleId);
$article->publish();
$this->repository->save($article);
// Dispatch all domain events
foreach ($article->pullDomainEvents() as $event) {
$this->dispatcher->dispatch($event);
}
}
}
flowchart TB
subgraph Entity["Article Entity"]
A[Article]
E1[ArticleCreated]
E2[ArticlePublished]
A -->|records| E1
A -->|records| E2
end
subgraph Service["Article Service"]
S[ArticleService]
S -->|pullDomainEvents| Entity
end
subgraph Dispatcher
D[EventDispatcher]
end
S -->|dispatch each| D Cross-Module Events¶
Publishing Module Events¶
{
"events": {
"publish": [
"mymodule.article.created",
"mymodule.article.published",
"mymodule.article.deleted"
]
}
}
Subscribing to Other Module Events¶
{
"events": {
"subscribe": [
{
"event": "user.deleted",
"handler": "MyModule\\Listener\\CleanupUserArticles"
},
{
"event": "category.deleted",
"handler": "MyModule\\Listener\\HandleCategoryDeletion"
}
]
}
}
Event Bus Architecture¶
flowchart TB
subgraph ModuleA["Module A"]
A1[ArticleService]
A1 -->|dispatch| EA[ArticlePublished]
end
subgraph EventBus["Event Bus"]
BUS[Dispatcher]
end
subgraph ModuleB["Module B"]
B1[CommentListener]
end
subgraph ModuleC["Module C"]
C1[AnalyticsListener]
end
subgraph Core["XOOPS Core"]
CORE1[CacheListener]
CORE2[SearchListener]
end
EA --> BUS
BUS --> B1
BUS --> C1
BUS --> CORE1
BUS --> CORE2 Testing Events¶
Testing Event Dispatch¶
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class ArticleServiceTest extends TestCase
{
public function testPublishDispatchesEvent(): void
{
$repository = $this->createMock(ArticleRepository::class);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$article = new Article(1, 'Test', 'Content');
$repository->method('findOrFail')->willReturn($article);
// Assert event is dispatched
$dispatcher->expects($this->once())
->method('dispatch')
->with($this->callback(function ($event) {
return $event instanceof ArticlePublished
&& $event->articleId === 1;
}));
$service = new ArticleService($repository, $dispatcher);
$service->publish(1);
}
}
Testing Listeners¶
<?php
declare(strict_types=1);
namespace Tests\Unit\Listener;
use PHPUnit\Framework\TestCase;
final class InvalidateCacheOnArticlePublishTest extends TestCase
{
public function testInvalidatesCache(): void
{
$cache = $this->createMock(CacheInterface::class);
$cache->expects($this->exactly(3))
->method('delete')
->withConsecutive(
['article.1'],
['articles.list'],
['articles.recent']
);
$listener = new InvalidateCacheOnArticlePublish($cache);
$listener(new ArticlePublished(
articleId: 1,
title: 'Test',
authorId: 1,
));
}
}
Best Practices¶
Event Design¶
<?php
// ✅ Do: Immutable events
final readonly class ArticlePublished
{
public function __construct(
public int $articleId,
public \DateTimeImmutable $publishedAt,
) {}
}
// ✅ Do: Include enough context
final readonly class ArticlePublished
{
public function __construct(
public int $articleId,
public string $title,
public int $authorId,
public int $categoryId,
public \DateTimeImmutable $publishedAt,
) {}
}
// ❌ Don't: Mutable events
class ArticlePublished
{
public int $articleId; // Can be changed!
public array $metadata = []; // Mutable array
}
Listener Design¶
<?php
// ✅ Do: Single responsibility
final class InvalidateArticleCache
{
public function __invoke(ArticlePublished $event): void
{
// Only handles caching
}
}
// ✅ Do: Fast, non-blocking
final class QueueNotification implements ShouldQueue
{
public function __invoke(ArticlePublished $event): void
{
// Queued for async processing
}
}
// ❌ Don't: Do too much in one listener
final class DoEverything
{
public function __invoke(ArticlePublished $event): void
{
$this->cache->invalidate();
$this->search->index();
$this->notify->send(); // Slow!
$this->analytics->track();
$this->webhooks->trigger(); // Slow!
}
}