diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ff377ac
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,49 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ ci:
+ name: CI
+ uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1
+ with:
+ dynamic_matrix: false
+ extra_jobs: |
+ - php: '8.1'
+ db: mysql80
+ phpunit: true
+ installer_version: ^4
+ - php: '8.2'
+ db: mysql80
+ phpunit: true
+ installer_version: ^5
+ - php: '8.3'
+ db: mariadb
+ phpunit: true
+ installer_version: ^5
+
+ coding-standards:
+ name: Coding Standards
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+ coverage: none
+ tools: composer:v2, php-cs-fixer
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Check coding standards
+ run: php-cs-fixer fix --dry-run --diff
+
+ - name: Static Analysis
+ run: vendor/bin/phpstan analyse
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3d2bf30
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+/vendor/
+.phpunit.result.cache
+.php-cs-cache
+.env
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+composer.lock
+/public/
+.php-cs-fixer.cache
+phpstan.cache/
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..a9aeee2
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,32 @@
+in([
+ __DIR__ . '/src',
+ __DIR__ . '/tests',
+ ])
+ ->name('*.php')
+ ->ignoreDotFiles(true)
+ ->ignoreVCS(true);
+
+$config = new PhpCsFixer\Config();
+
+return $config
+ ->setRules([
+ '@PSR12' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'ordered_imports' => ['sort_algorithm' => 'alpha'],
+ 'no_unused_imports' => true,
+ 'trailing_comma_in_multiline' => true,
+ 'phpdoc_align' => true,
+ 'phpdoc_order' => true,
+ 'phpdoc_separation' => true,
+ 'phpdoc_single_line_var_spacing' => true,
+ 'phpdoc_trim' => true,
+ 'phpdoc_var_without_name' => true,
+ 'return_type_declaration' => ['space_before' => 'none'],
+ 'single_quote' => true,
+ 'ternary_operator_spaces' => true,
+ 'unary_operator_spaces' => true,
+ ])
+ ->setFinder($finder);
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..42061ce
--- /dev/null
+++ b/README.md
@@ -0,0 +1,304 @@
+# Silverstripe CMS RevoltEvent Dispatcher Module (experimental)
+[![CI](https://github.com/archiprocode/silverstripe-revolt-event-dispatcher/actions/workflows/ci.yml/badge.svg)](https://github.com/archiprocode/silverstripe-revolt-event-dispatcher/actions/workflows/ci.yml)
+
+This module adds the ability to dispatch and listen for events in Silverstripe CMS. It's built around Revolt PHP
+and AMPHP. It aims to process events asynchronously. It also provides some abstraction to help managing event around
+common DataObject operations.
+
+## Installation
+
+```bash
+composer require archipro/silverstripe-revolt-event-dispatcher
+```
+
+## Features
+- Automatic event dispatching for DataObject operations (create, update, delete)
+- Support for versioned operations (publish, unpublish, archive, restore)
+- Asynchronous event handling using Revolt Event Loop
+
+## Setting up the Event Loop
+
+Because we are using Revolt PHP, you need to run the event loop to process the events.
+
+Somewhere in your code you need to start the event loop by running `\Revolt\EventLoop::run()`. This will process all the events up to that point.
+
+A simple approach is to put it at the end of your `public/index.php` file in a `try-finally` block. You can also add a `fastcgi_finish_request()` call to ensure all output is sent before processing the events.
+
+```php
+try {
+ $kernel = new CoreKernel(BASE_PATH);
+ $app = new HTTPApplication($kernel);
+ $response = $app->handle($request);
+ $response->output();
+} finally {
+ // This call will complete the request without closing the PHP worker. A nice side effect of this is that your
+ // event listeners won't block your request from being sent to the client. So you can use them to run slow
+ // operations like sending emails or doing API calls without delaying the response.
+ fastcgi_finish_request();
+
+ // Many methods in Silverstripe CMS rely on having a current controller with a request.
+ $controller = new Controller();
+ $controller->setRequest($request);
+ $controller->pushCurrent();
+
+ // Now we can process the events in the event loop
+ \Revolt\EventLoop::run();
+}
+```
+
+### TODO
+
+- Need to find a an elegant way to run the event loop on `sake` commands. This won't hit `public/index.php`.
+
+## Basic Usage
+
+### Firing a Custom Event
+
+```php
+use SilverStripe\Core\Injector\Injector;
+use ArchiPro\Silverstripe\EventDispatcher\Service\EventService;
+
+// Create your event class
+class MyCustomEvent
+{
+ public function __construct(
+ private readonly string $message
+ ) {}
+
+ public function getMessage(): string
+ {
+ return $this->message;
+ }
+}
+
+// Dispatch the event
+$event = new MyCustomEvent('Hello World');
+$service = Injector::inst()->get(EventService::class);
+$service->dispatch($event);
+```
+
+### Adding a Simple Event Listener
+
+```php
+use SilverStripe\Core\Injector\Injector;
+use ArchiPro\Silverstripe\EventDispatcher\Service\EventService;
+
+// Add a listener
+$service = Injector::inst()->get(EventService::class);
+$service->addListener(MyCustomEvent::class, function(MyCustomEvent $event) {
+ error_log('MyCustomEventListener::handleEvent was called');
+});
+```
+
+### Configuration-based Listeners
+
+You can register listeners via YAML configuration:
+
+```yaml
+ArchiPro\Silverstripe\EventDispatcher\Service\EventService:
+ listeners:
+ MyCustomEvent:
+ - ['MyApp\EventListener', 'handleEvent']
+```
+
+## Registering many listeners at once with loaders
+
+You can use listeners loaders to register many listeners at once.
+
+```php
+selfRegister($provider);
+ }
+
+ public function onMemberCreated(DataObjectEvent $event): void
+ {
+ $member = $event->getObject();
+ Email::create()
+ ->setTo($member->Email)
+ ->setSubject('Welcome to our site')
+ ->setFrom('no-reply@example.com')
+ ->setBody('Welcome to our site')
+ ->send();
+ }
+}
+```
+
+Loaders can be registered in your YAML configuration file:
+```yaml
+ArchiPro\Silverstripe\EventDispatcher\Service\EventService:
+ loaders:
+ - MemberListenerLoader
+```
+
+## DataObject Event Handling
+
+This module automatically dispatches events for DataObject operations. You can listen for these events using the
+`DataObjectEventListener` class.
+
+### Firing DataObject Events
+
+Applying the `EventDispatchExtension` to a DataObject will automatically fire events when changes are made to an
+instance of that DataObject.
+
+```yaml
+
+## This will fire events for SiteTree instances only
+SilverStripe\SiteTree\SiteTree:
+ extensions:
+ - ArchiPro\Silverstripe\EventDispatcher\Extension\EventDispatchExtension
+
+## This will fire events for all DataObjects
+SilverStripe\ORM\DataObject:
+ extensions:
+ - ArchiPro\Silverstripe\EventDispatcher\Extension\EventDispatchExtension
+```
+
+### Listening for DataObject Events
+
+```php
+use ArchiPro\Silverstripe\EventDispatcher\Event\DataObjectEvent;
+use ArchiPro\Silverstripe\EventDispatcher\Event\Operation;
+use ArchiPro\Silverstripe\EventDispatcher\Listener\DataObjectEventListener;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\Security\Member;
+
+// Create a listener for all Member operations
+DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ echo "Operation {$event->getOperation()->value} performed on Member {$event->getObjectID()}";
+ },
+ [Member::class]
+)->selfRegister();
+
+// Listen for specific operations on multiple classes
+DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ // Handle create/update operations
+ },
+ [Member::class, Group::class],
+ [Operation::CREATE, Operation::UPDATE]
+)->selfRegister();
+```
+
+### Available Operations
+
+The following operations are automatically tracked:
+
+- `Operation::CREATE` - When a DataObject is first written
+- `Operation::UPDATE` - When an existing DataObject is modified
+- `Operation::DELETE` - When a DataObject is deleted
+- `Operation::PUBLISH` - When a versioned DataObject is published
+- `Operation::UNPUBLISH` - When a versioned DataObject is unpublished
+- `Operation::ARCHIVE` - When a versioned DataObject is archived
+- `Operation::RESTORE` - When a versioned DataObject is restored from archive
+
+### Accessing Event Data
+
+The `DataObjectEvent` class provides several methods to access information about the event:
+
+```php
+DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ $object = $event->getObject(); // Get the affected DataObject
+ $class = $event->getObjectClass(); // Get the class name
+ $operation = $event->getOperation(); // Get the operation type
+ $version = $event->getVersion(); // Get version number (if versioned)
+ $member = $event->getMember(); // Get the Member who performed the action
+ $time = $event->getTimestamp(); // Get when the event occurred
+ },
+ [DataObject::class]
+)->selfRegister();
+```
+
+`DataObjectEvent` is configured to be serializable so it can easily be stored for later use.
+
+Note that `DataObjectEvent` doesn't store the actual DataObject instance that caused the event to be fired.
+`DataObjectEvent::getObject()` will refetch the latest version of the DataObject from the database ... which will
+return `null` if the DataObject has been deleted.
+
+`DataObjectEvent::getObject(true) will attempt to retrieve the exact version of the DataObject that fired the event,
+assuming it was versioned.
+
+## Testing Your Events
+
+### Writing Event Tests
+
+When testing your event listeners, you'll need to:
+1. Dispatch your events
+2. Run the event loop
+3. Assert the expected outcomes
+
+Here's an example test:
+
+```php
+use Revolt\EventLoop;
+use SilverStripe\Dev\SapphireTest;
+use SilverStripe\Core\Injector\Injector;
+use ArchiPro\Silverstripe\EventDispatcher\Service\EventService;
+
+class MyEventTest extends SapphireTest
+{
+ public function testMyCustomEvent(): void
+ {
+ // Create your test event
+ $event = new MyCustomEvent('test message');
+
+ // Get the event service
+ $service = Injector::inst()->get(EventService::class);
+
+ // Add your test listener ... or if you have already
+ $wasCalled = false;
+ $service->addListener(
+ MyCustomEvent::class,
+ [MyCustomEventListener::class, 'handleEvent']
+ );
+
+ // Dispatch the event
+ $service->dispatch($event);
+
+ // Run the event loop to process events
+ EventLoop::run();
+
+ // Assert your listener was called
+ $this->assertTrue(
+ MyCustomEventListener::wasCalled(),
+ 'Assert some side effect of the event being handled'
+ );
+ }
+}
+```
+
+### Disabling event dispatching
+
+You can disable event dispatching for test to avoid side affects from irrelevant events that might be fired while
+scaffolding fixtures.
+
+Call `EventService::singleton()->disableDispatch()` to disable event dispatching while setting up your test.
+
+When you are ready to start running your test, call `EventService::singleton()->enableDispatch()` to start listening for
+events again.
+
+### Important Testing Notes
+
+- Events are processed asynchronously by default. You can force processing of events by:
+ - calling `EventLoop::run()` or
+ - calling `await()` on the returned value of the `dispatch` method. e.g.: `EventService::dispatch($event)->await()`.
+- For DataObject events, make sure your test class applies the `EventDispatchExtension` to the relevant DataObject
+ classes.
diff --git a/_config/events.yml b/_config/events.yml
new file mode 100644
index 0000000..6579fbe
--- /dev/null
+++ b/_config/events.yml
@@ -0,0 +1,23 @@
+---
+Name: events
+After:
+ - '#coreservices'
+---
+SilverStripe\Core\Injector\Injector:
+ # Define the listener provider
+ ArchiPro\EventDispatcher\ListenerProvider:
+ class: ArchiPro\EventDispatcher\ListenerProvider
+
+ # Default event dispatcher
+ ArchiPro\EventDispatcher\AsyncEventDispatcher:
+ class: ArchiPro\EventDispatcher\AsyncEventDispatcher
+ constructor:
+ listenerProvider: '%$ArchiPro\EventDispatcher\ListenerProvider'
+ Psr\EventDispatcher\EventDispatcherInterface:
+ alias: '%$ArchiPro\EventDispatcher\AsyncEventDispatcher'
+
+ # Bootstrap the event service
+ ArchiPro\Silverstripe\EventDispatcher\Service\EventService:
+ constructor:
+ dispatcher: '%$ArchiPro\EventDispatcher\AsyncEventDispatcher'
+ listenerProvider: '%$ArchiPro\EventDispatcher\ListenerProvider'
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..1a898be
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,47 @@
+{
+ "name": "archipro/silverstripe-revolt-event-dispatcher",
+ "description": "A Revolt Event Dispatcher integration for Silverstripe CMS",
+ "type": "silverstripe-vendormodule",
+ "license": "MIT",
+ "require": {
+ "php": "^8.1",
+ "silverstripe/framework": "^4.13 || ^5.0",
+ "silverstripe/versioned": "^1.13 || ^2.0",
+ "psr/event-dispatcher": "^1.0",
+ "psr/event-dispatcher-implementation": "^1.0",
+ "archipro/revolt-event-dispatcher": "^0.0.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5",
+ "squizlabs/php_codesniffer": "^3.0",
+ "friendsofphp/php-cs-fixer": "^3.0",
+ "phpstan/phpstan": "^1.10"
+ },
+ "autoload": {
+ "psr-4": {
+ "ArchiPro\\Silverstripe\\EventDispatcher\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "ArchiPro\\Silverstripe\\EventDispatcher\\Tests\\": "tests/php/"
+ }
+ },
+ "scripts": {
+ "lint": "php-cs-fixer fix --dry-run --diff",
+ "lint-fix": "php-cs-fixer fix",
+ "analyse": "phpstan analyse",
+ "test": "phpunit"
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "extra": {
+ "expose": []
+ },
+ "config": {
+ "allow-plugins": {
+ "composer/installers": true,
+ "silverstripe/vendor-plugin": true
+ }
+ }
+}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..562c415
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,12 @@
+parameters:
+ level: 8
+ paths:
+ - src
+ - tests
+ tmpDir: phpstan.cache
+ excludePaths:
+ analyseAndScan:
+ - vendor
+ ignoreErrors:
+ - '#Static property .* is never read, only written\.#'
+ - '#Offset \d+ does not exist on array\{\}#'
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..3801dc3
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,8 @@
+
+
+
+
+ tests/php
+
+
+
diff --git a/src/Contract/ListenerLoaderInterface.php b/src/Contract/ListenerLoaderInterface.php
new file mode 100644
index 0000000..413ff80
--- /dev/null
+++ b/src/Contract/ListenerLoaderInterface.php
@@ -0,0 +1,25 @@
+ID,
+ * Operation::UPDATE,
+ * $dataObject->Version,
+ * Security::getCurrentUser()?->ID
+ * );
+ * ```
+ *
+ * @template T of DataObject
+ */
+class DataObjectEvent
+{
+ use Injectable;
+
+ /**
+ * @var int Unix timestamp when the event was created
+ */
+ private readonly int $timestamp;
+
+ /**
+ * @param class-string $objectClass The class name of the affected DataObject
+ * @param int $objectID The ID of the affected DataObject
+ * @param Operation $operation The type of operation performed
+ * @param int|null $version The version number (for versioned objects)
+ * @param int|null $memberID The ID of the member who performed the operation
+ */
+ public function __construct(
+ private readonly string $objectClass,
+ private readonly int $objectID,
+ private readonly Operation $operation,
+ private readonly ?int $version = null,
+ private readonly ?int $memberID = null
+ ) {
+ $this->timestamp = time();
+ }
+
+ /**
+ * Get the ID of the affected DataObject
+ */
+ public function getObjectID(): int
+ {
+ return $this->objectID;
+ }
+
+ /**
+ * Get the class name of the affected DataObject
+ */
+ public function getObjectClass(): string
+ {
+ return $this->objectClass;
+ }
+
+ /**
+ * Get the type of operation performed
+ */
+ public function getOperation(): Operation
+ {
+ return $this->operation;
+ }
+
+ /**
+ * Get the version number (for versioned objects)
+ */
+ public function getVersion(): ?int
+ {
+ return $this->version;
+ }
+
+ /**
+ * Get the ID of the member who performed the operation
+ */
+ public function getMemberID(): ?int
+ {
+ return $this->memberID;
+ }
+
+ /**
+ * Get the timestamp when the event was created
+ */
+ public function getTimestamp(): int
+ {
+ return $this->timestamp;
+ }
+
+ /**
+ * Get the DataObject associated with this event
+ *
+ * @phpstan-return T|null
+ *
+ * @param bool $useVersion If true and the object is versioned, retrieves the specific version that was affected
+ * Note: This may return null if the object has been deleted since the event was created
+ */
+ public function getObject(bool $useVersion = false): ?DataObject
+ {
+ if (!$this->objectID) {
+ return null;
+ }
+
+ if (!$useVersion || empty($this->version)) {
+ /** @var T|null $object */
+ $object = DataObject::get($this->objectClass)->byID($this->objectID);
+ return $object;
+ }
+
+ return Versioned::get_version($this->objectClass, $this->objectID, $this->version);
+ }
+
+ /**
+ * Get the Member who performed the operation
+ *
+ * Note: This may return null if the member has been deleted since the event was created
+ * or if the operation was performed by a system process
+ */
+ public function getMember(): ?Member
+ {
+ if (!$this->memberID) {
+ return null;
+ }
+
+ return Member::get()->byID($this->memberID);
+ }
+
+ /**
+ * Serialize the event to a string
+ */
+ public function serialize(): string
+ {
+ return serialize([
+ 'objectID' => $this->objectID,
+ 'objectClass' => $this->objectClass,
+ 'operation' => $this->operation,
+ 'version' => $this->version,
+ 'memberID' => $this->memberID,
+ 'timestamp' => $this->timestamp,
+ ]);
+ }
+
+ /**
+ * Unserialize the event from a string
+ *
+ * @param string $data
+ */
+ public function unserialize(string $data): void
+ {
+ $unserialized = unserialize($data);
+
+ // Use reflection to set readonly properties
+ $reflection = new \ReflectionClass($this);
+
+ foreach ($unserialized as $property => $value) {
+ $prop = $reflection->getProperty($property);
+ $prop->setAccessible(true);
+ $prop->setValue($this, $value);
+ }
+ }
+}
diff --git a/src/Event/Operation.php b/src/Event/Operation.php
new file mode 100644
index 0000000..b0975a8
--- /dev/null
+++ b/src/Event/Operation.php
@@ -0,0 +1,28 @@
+
+ */
+class EventDispatchExtension extends Extension
+{
+ /**
+ * Fires an event after the object is written (created or updated)
+ */
+ public function onAfterWrite(): void
+ {
+ $owner = $this->getOwner();
+ $event = DataObjectEvent::create(
+ get_class($owner),
+ $owner->ID,
+ // By this point isInDB() will return true even for new records since the ID is already set
+ // Instead check if the ID field was changed which indicates this is a new record
+ $owner->isChanged('ID') ? Operation::CREATE : Operation::UPDATE,
+ $this->getVersion(),
+ Security::getCurrentUser()?->ID
+ );
+
+ $this->dispatchEvent($event);
+ }
+
+ /**
+ * Fires before a DataObject is deleted from the database
+ */
+ public function onBeforeDelete(): void
+ {
+ $owner = $this->getOwner();
+ $event = DataObjectEvent::create(
+ get_class($owner),
+ $owner->ID,
+ Operation::DELETE,
+ $this->getVersion(),
+ Security::getCurrentUser()?->ID
+ );
+
+ $this->dispatchEvent($event);
+ }
+
+ /**
+ * Fires when a versioned DataObject is published
+ */
+ public function onAfterPublish(): void
+ {
+ $owner = $this->getOwner();
+ if (!$owner->hasExtension(Versioned::class)) {
+ return;
+ }
+
+ $event = DataObjectEvent::create(
+ get_class($owner),
+ $owner->ID,
+ Operation::PUBLISH,
+ $this->getVersion(),
+ Security::getCurrentUser()?->ID
+ );
+
+ $this->dispatchEvent($event);
+ }
+
+ /**
+ * Fires when a versioned DataObject is unpublished
+ */
+ public function onAfterUnpublish(): void
+ {
+ $owner = $this->getOwner();
+ if (!$owner->hasExtension(Versioned::class)) {
+ return;
+ }
+
+ $event = DataObjectEvent::create(
+ get_class($owner),
+ $owner->ID,
+ Operation::UNPUBLISH,
+ $this->getVersion(),
+ Security::getCurrentUser()?->ID
+ );
+
+ $this->dispatchEvent($event);
+ }
+
+ /**
+ * Fires when a versioned DataObject is archived
+ */
+ public function onAfterArchive(): void
+ {
+ $owner = $this->getOwner();
+ if (!$owner->hasExtension(Versioned::class)) {
+ return;
+ }
+
+ $event = DataObjectEvent::create(
+ get_class($owner),
+ $owner->ID,
+ Operation::ARCHIVE,
+ $this->getVersion(),
+ Security::getCurrentUser()?->ID
+ );
+
+ $this->dispatchEvent($event);
+ }
+
+ /**
+ * Fires when a versioned DataObject is restored from archive
+ */
+ public function onAfterRestore(): void
+ {
+ $owner = $this->getOwner();
+ if (!$owner->hasExtension(Versioned::class)) {
+ return;
+ }
+
+ $event = DataObjectEvent::create(
+ get_class($owner),
+ $owner->ID,
+ Operation::RESTORE,
+ $this->getVersion(),
+ Security::getCurrentUser()?->ID
+ );
+
+ $this->dispatchEvent($event);
+ }
+
+ /**
+ * Dispatches an event using the EventService
+ *
+ * @phpstan-param DataObjectEvent $event
+ *
+ * @phpstan-return Future>
+ */
+ protected function dispatchEvent(DataObjectEvent $event): Future
+ {
+ return Injector::inst()->get(EventService::class)->dispatch($event);
+ }
+
+ private function getVersion(): ?int
+ {
+ $owner = $this->getOwner();
+ if (!$owner->hasExtension(Versioned::class)) {
+ return null;
+ }
+
+ /** @var Versioned $owner */
+ return $owner->Version;
+ }
+}
diff --git a/src/Listener/DataObjectEventListener.php b/src/Listener/DataObjectEventListener.php
new file mode 100644
index 0000000..eb9b6cc
--- /dev/null
+++ b/src/Listener/DataObjectEventListener.php
@@ -0,0 +1,100 @@
+): void $callback Callback to execute when an event matches
+ * @param class-string[] $classes Array of DataObject class names to listen for
+ * @param Operation[] $operations Array of operations to listen for. If null, listens for all operations.
+ */
+ public function __construct(
+ private readonly Closure $callback,
+ private readonly array $classes,
+ array $operations = null
+ ) {
+ $this->operations = $operations ?? Operation::cases();
+ }
+
+ /**
+ * Registers this listener with the given provider.
+ *
+ * If no provider is provided, the global EventService will be used.
+ */
+ public function selfRegister(ListenerProvider|EventService $provider = null): void
+ {
+ if (empty($provider)) {
+ $provider = Injector::inst()->get(EventService::class);
+ }
+ $provider->addListener(DataObjectEvent::class, $this);
+ }
+
+ /**
+ * Handles a DataObject event.
+ *
+ * Checks if the event matches the configured operations and classes,
+ * and executes the callback if it does.
+ *
+ * @param DataObjectEvent $event The event to handle
+ */
+ public function __invoke(DataObjectEvent $event): void
+ {
+ // Check if we should handle this class
+ if (!$this->shouldHandleClass($event->getObjectClass())) {
+ return;
+ }
+
+ // Check if we should handle this operation
+ if (!in_array($event->getOperation(), $this->operations)) {
+ return;
+ }
+
+ // Execute callback
+ call_user_func($this->callback, $event);
+ }
+
+ /**
+ * Checks if the given class matches any of the configured target classes.
+ *
+ * A match occurs if the class is either the same as or a subclass of any target class.
+ *
+ * @param string $class The class name to check
+ *
+ * @return bool True if the class should be handled, false otherwise
+ */
+ private function shouldHandleClass(string $class): bool
+ {
+ foreach ($this->classes as $targetClass) {
+ if (is_a($class, $targetClass, true)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/Service/EventService.php b/src/Service/EventService.php
new file mode 100644
index 0000000..cf64f92
--- /dev/null
+++ b/src/Service/EventService.php
@@ -0,0 +1,151 @@
+> Map of event class names to arrays of listener callbacks
+ */
+ private static array $listeners = [];
+
+ /**
+ * @config
+ *
+ * @var array Array of listener loaders
+ */
+ private static array $loaders = [];
+
+ /** Whether events should be suppressed from being dispatched. Used for testing. */
+ private bool $suppressDispatch = false;
+
+ public function __construct(
+ private readonly AsyncEventDispatcher $dispatcher,
+ private readonly ListenerProvider $listenerProvider
+ ) {
+ $this->registerListeners();
+ $this->loadListeners();
+ }
+
+ /**
+ * Registers listeners from the configuration
+ */
+ private function registerListeners(): void
+ {
+ $listeners = $this->config()->get('listeners');
+ if (empty($listeners)) {
+ return;
+ }
+
+ foreach ($listeners as $eventClass => $listeners) {
+ foreach ($listeners as $listener) {
+ if (is_string($listener)) {
+ $listener = Injector::inst()->get($listener);
+ }
+ $this->addListener($eventClass, $listener);
+ }
+ }
+ }
+
+ /**
+ * Loads listeners from the configuration
+ */
+ private function loadListeners(): void
+ {
+ foreach ($this->config()->get('loaders') as $loader) {
+ if (is_string($loader)) {
+ $loader = Injector::inst()->get($loader);
+ }
+ $this->addListenerLoader($loader);
+ }
+ }
+
+ /**
+ * Adds a listener to the event service
+ *
+ * @template T of object
+ *
+ * @param class-string $event The event class name
+ * @param callable(T): void $listener The listener callback
+ */
+ public function addListener(string $event, callable $listener): void
+ {
+ $this->listenerProvider->addListener($event, $listener);
+ }
+
+ /**
+ * Adds a listener loader to the event service
+ *
+ * @throws \RuntimeException If the loader does not implement ListenerLoaderInterface
+ */
+ public function addListenerLoader(ListenerLoaderInterface $loader): void
+ {
+ if (!$loader instanceof ListenerLoaderInterface) {
+ throw new \RuntimeException(sprintf(
+ 'Listener loader class "%s" must implement ListenerLoaderInterface',
+ get_class($loader)
+ ));
+ }
+ $loader->loadListeners($this->listenerProvider);
+ }
+
+ /**
+ * Dispatches an event to all registered listeners
+ *
+ * @template T of object
+ *
+ * @param T $event
+ *
+ * @return Future
+ */
+ public function dispatch(object $event): Future
+ {
+ if ($this->suppressDispatch) {
+ return Future::complete($event);
+ }
+ return $this->dispatcher->dispatch($event);
+ }
+
+ /**
+ * Gets the listener provider instance
+ */
+ public function getListenerProvider(): ListenerProvider
+ {
+ return $this->listenerProvider;
+ }
+
+ /**
+ * Enables event dispatching. Use when testing to avoid side effects.
+ */
+ public function enableDispatch(): void
+ {
+ $this->suppressDispatch = false;
+ }
+
+ /**
+ * Disables event dispatching. Use when testing to avoid side effects.
+ */
+ public function disableDispatch(): void
+ {
+ $this->suppressDispatch = true;
+ }
+}
diff --git a/tests/php/Event/DataObjectEventTest.php b/tests/php/Event/DataObjectEventTest.php
new file mode 100644
index 0000000..6c159b1
--- /dev/null
+++ b/tests/php/Event/DataObjectEventTest.php
@@ -0,0 +1,102 @@
+assertEquals(1, $event->getObjectID());
+ $this->assertEquals(SimpleDataObject::class, $event->getObjectClass());
+ $this->assertEquals(Operation::CREATE, $event->getOperation());
+ $this->assertNull($event->getVersion());
+ $this->assertEquals(1, $event->getMemberID());
+ $this->assertGreaterThan(0, $event->getTimestamp());
+ }
+
+ public function testGetObject(): void
+ {
+ /** @var SimpleDataObject $object */
+ $object = $this->objFromFixture(SimpleDataObject::class, 'object1');
+
+ $event = DataObjectEvent::create(SimpleDataObject::class, $object->ID, Operation::UPDATE);
+
+ $this->assertNotNull($event->getObject());
+ $this->assertEquals($object->ID, $event->getObject()->ID);
+ }
+
+ public function testGetVersionedObject(): void
+ {
+ /** @var VersionedDataObject $object */
+ $object = $this->objFromFixture(VersionedDataObject::class, 'versioned1');
+
+ // Create a new version
+ $object->Title = 'Updated Title';
+ $object->write();
+
+ /** @var DataObjectEvent $event */
+ $event = DataObjectEvent::create(VersionedDataObject::class, $object->ID, Operation::UPDATE, $object->Version);
+
+ // Get current version
+ /** @var VersionedDataObject $currentObject */
+ $currentObject = $event->getObject(false);
+ $this->assertEquals('Updated Title', $currentObject->Title);
+
+ // Get specific version
+ /** @var VersionedDataObject $versionedObject */
+ $versionedObject = $event->getObject(true);
+ $this->assertEquals('Updated Title', $versionedObject->Title);
+
+ // Get previous version
+ /** @var DataObjectEvent $previousEvent */
+ $previousEvent = DataObjectEvent::create(VersionedDataObject::class, $object->ID, Operation::UPDATE, $object->Version - 1);
+ /** @var VersionedDataObject $previousVersion */
+ $previousVersion = $previousEvent->getObject(true);
+ $this->assertEquals('Original Title', $previousVersion->Title);
+ }
+
+ public function testGetMember(): void
+ {
+ /** @var Member $member */
+ $member = $this->objFromFixture(Member::class, 'member1');
+
+ $event = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE, null, $member->ID);
+
+ $this->assertNotNull($event->getMember());
+ $this->assertEquals($member->ID, $event->getMember()->ID);
+ }
+
+ public function testSerialization(): void
+ {
+ $event = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE, 2, 3);
+
+ $serialized = serialize($event);
+ /** @var DataObjectEvent $unserialized */
+ $unserialized = unserialize($serialized);
+
+ $this->assertEquals(1, $unserialized->getObjectID());
+ $this->assertEquals(SimpleDataObject::class, $unserialized->getObjectClass());
+ $this->assertEquals(Operation::CREATE, $unserialized->getOperation());
+ $this->assertEquals(2, $unserialized->getVersion());
+ $this->assertEquals(3, $unserialized->getMemberID());
+ $this->assertEquals($event->getTimestamp(), $unserialized->getTimestamp());
+ }
+}
diff --git a/tests/php/Event/DataObjectEventTest.yml b/tests/php/Event/DataObjectEventTest.yml
new file mode 100644
index 0000000..deb1782
--- /dev/null
+++ b/tests/php/Event/DataObjectEventTest.yml
@@ -0,0 +1,13 @@
+ArchiPro\Silverstripe\EventDispatcher\Tests\Mock\SimpleDataObject:
+ object1:
+ Title: 'Test Object'
+
+ArchiPro\Silverstripe\EventDispatcher\Tests\Mock\VersionedDataObject:
+ versioned1:
+ Title: 'Original Title'
+
+SilverStripe\Security\Member:
+ member1:
+ FirstName: 'Test'
+ Surname: 'User'
+ Email: 'test@example.com'
\ No newline at end of file
diff --git a/tests/php/Extension/EventDispatchExtensionTest.php b/tests/php/Extension/EventDispatchExtensionTest.php
new file mode 100644
index 0000000..746350d
--- /dev/null
+++ b/tests/php/Extension/EventDispatchExtensionTest.php
@@ -0,0 +1,123 @@
+[] */
+ protected static $extra_dataobjects = [
+ SimpleDataObject::class,
+ VersionedDataObject::class,
+ ];
+
+ /** @var DataObjectEvent[] */
+ protected static array $events = [];
+
+ public static function setUpBeforeClass(): void
+ {
+ parent::setUpBeforeClass();
+
+ DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ static::$events[] = $event;
+ },
+ [SimpleDataObject::class, VersionedDataObject::class]
+ )->selfRegister();
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ static::$events = [];
+ }
+
+
+ public function testWriteEvents(): void
+ {
+ // Test create
+ $object = SimpleDataObject::create(['Title' => 'Test']);
+ $object->write();
+ EventLoop::run();
+
+ $this->assertCount(1, static::$events);
+ $this->assertEquals(Operation::CREATE, static::$events[0]->getOperation());
+
+ // Clear events
+ static::$events = [];
+
+ // Test update
+ $object->Title = 'Updated';
+ $object->write();
+
+ EventLoop::run();
+
+ $this->assertCount(1, static::$events);
+ $this->assertEquals(Operation::UPDATE, static::$events[0]->getOperation());
+ }
+
+ public function testDeleteEvent(): void
+ {
+ $object = SimpleDataObject::create(['Title' => 'Test']);
+ $object->write();
+ EventLoop::run();
+
+ static::$events = [];
+ $object->delete();
+ EventLoop::run();
+
+ $this->assertCount(1, static::$events);
+ $this->assertEquals(Operation::DELETE, static::$events[0]->getOperation());
+ }
+
+ public function testVersionedEvents(): void
+ {
+ /** @var Member $member */
+ $member = $this->objFromFixture(Member::class, 'member1');
+ Security::setCurrentUser($member);
+
+ /** @var VersionedDataObject $object */
+ $object = VersionedDataObject::create(['Title' => 'Test']);
+ $object->write();
+
+ EventLoop::run();
+ static::$events = [];
+
+ // Test publish
+ $object->publishRecursive();
+ EventLoop::run();
+
+ $this->assertCount(2, static::$events, 'Expected 2 events, 1 for create and 1 for publish');
+ $this->assertEquals(Operation::PUBLISH, static::$events[1]->getOperation());
+ $this->assertEquals($member->ID, static::$events[1]->getMemberID());
+
+ // Test unpublish
+ static::$events = [];
+ $object->doUnpublish();
+ EventLoop::run();
+
+ $this->assertCount(2, static::$events, 'Expected 2 events, 1 for deleting the live version and 1 for unpublish');
+ $this->assertEquals(Operation::UNPUBLISH, static::$events[1]->getOperation());
+
+ // Test archive
+ static::$events = [];
+ $object->doArchive();
+ EventLoop::run();
+
+ $this->assertCount(2, static::$events, 'Expected 2 events, 1 for deleting the draft version version and 1 for archive');
+ $this->assertEquals(Operation::ARCHIVE, static::$events[1]->getOperation());
+ }
+}
diff --git a/tests/php/Extension/EventDispatchExtensionTest.yml b/tests/php/Extension/EventDispatchExtensionTest.yml
new file mode 100644
index 0000000..874fe2e
--- /dev/null
+++ b/tests/php/Extension/EventDispatchExtensionTest.yml
@@ -0,0 +1,5 @@
+SilverStripe\Security\Member:
+ member1:
+ FirstName: 'Test'
+ Surname: 'User'
+ Email: 'test@example.com'
\ No newline at end of file
diff --git a/tests/php/Listener/DataObjectEventListenerTest.php b/tests/php/Listener/DataObjectEventListenerTest.php
new file mode 100644
index 0000000..9467958
--- /dev/null
+++ b/tests/php/Listener/DataObjectEventListenerTest.php
@@ -0,0 +1,150 @@
+[] */
+ protected static $extra_dataobjects = [
+ SimpleDataObject::class,
+ VersionedDataObject::class,
+ ];
+
+ /** @var DataObjectEvent[] */
+ protected array $receivedEvents = [];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->receivedEvents = [];
+ }
+
+ public function testListenerFiltersByClass(): void
+ {
+ // Create listener that only handles SimpleDataObject events
+ $listener = DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ $this->receivedEvents[] = $event;
+ },
+ [SimpleDataObject::class]
+ );
+
+ // Should handle SimpleDataObject event
+ $simpleEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE);
+ $listener($simpleEvent);
+ $this->assertCount(1, $this->receivedEvents, 'Listener should handle SimpleDataObject events');
+
+ // Should not handle VersionedDataObject event
+ $versionedEvent = DataObjectEvent::create(VersionedDataObject::class, 1, Operation::CREATE);
+ $listener($versionedEvent);
+ $this->assertCount(1, $this->receivedEvents, 'Listener should not handle VersionedDataObject events');
+ }
+
+ public function testListenerHandlesInheritedClasses(): void
+ {
+ // Create listener that handles all DataObject events
+ $listener = DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ $this->receivedEvents[] = $event;
+ },
+ [DataObject::class]
+ );
+
+ // Should handle both SimpleDataObject and VersionedDataObject events
+ $simpleEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE);
+ $versionedEvent = DataObjectEvent::create(VersionedDataObject::class, 1, Operation::CREATE);
+
+ $listener($simpleEvent);
+ $listener($versionedEvent);
+
+ $this->assertCount(2, $this->receivedEvents, 'Listener should handle events from DataObject subclasses');
+ }
+
+ public function testListenerFiltersByOperation(): void
+ {
+ // Create listener that only handles CREATE and UPDATE operations
+ $listener = DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ $this->receivedEvents[] = $event;
+ },
+ [SimpleDataObject::class],
+ [Operation::CREATE, Operation::UPDATE]
+ );
+
+ // Should handle CREATE event
+ $createEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE);
+ $listener($createEvent);
+ $this->assertCount(1, $this->receivedEvents, 'Listener should handle CREATE events');
+
+ // Should handle UPDATE event
+ $updateEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::UPDATE);
+ $listener($updateEvent);
+ $this->assertCount(2, $this->receivedEvents, 'Listener should handle UPDATE events');
+
+ // Should not handle DELETE event
+ $deleteEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::DELETE);
+ $listener($deleteEvent);
+ $this->assertCount(2, $this->receivedEvents, 'Listener should not handle DELETE events');
+ }
+
+ public function testListenerHandlesAllOperationsWhenNotSpecified(): void
+ {
+ // Create listener without specifying operations
+ $listener = DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ $this->receivedEvents[] = $event;
+ },
+ [SimpleDataObject::class]
+ );
+
+ // Should handle all operations
+ foreach (Operation::cases() as $operation) {
+ $event = DataObjectEvent::create(SimpleDataObject::class, 1, $operation);
+ $listener($event);
+ }
+
+ $this->assertCount(
+ count(Operation::cases()),
+ $this->receivedEvents,
+ 'Listener should handle all operations when none specified'
+ );
+ }
+
+ public function testSelfRegister(): void
+ {
+ // Create a mock event service
+ /** @var MockObject&ListenerProvider $provider */
+ $provider = $this->createMock(ListenerProvider::class);
+ $provider->expects($this->once())
+ ->method('addListener')
+ ->with(
+ DataObjectEvent::class,
+ $this->isInstanceOf(DataObjectEventListener::class)
+ );
+
+ // Create listener and register with mock service
+ $listener = DataObjectEventListener::create(
+ function (DataObjectEvent $event) {
+ $this->receivedEvents[] = $event;
+ },
+ [SimpleDataObject::class]
+ );
+ $listener->selfRegister($provider);
+ }
+}
diff --git a/tests/php/Mock/SimpleDataObject.php b/tests/php/Mock/SimpleDataObject.php
new file mode 100644
index 0000000..164b267
--- /dev/null
+++ b/tests/php/Mock/SimpleDataObject.php
@@ -0,0 +1,26 @@
+ */
+ private static array $db = [
+ 'Title' => 'Varchar',
+ ];
+
+ /** @var class-string>[] */
+ private static array $extensions = [
+ EventDispatchExtension::class,
+ ];
+}
diff --git a/tests/php/Mock/VersionedDataObject.php b/tests/php/Mock/VersionedDataObject.php
new file mode 100644
index 0000000..b9e46b3
--- /dev/null
+++ b/tests/php/Mock/VersionedDataObject.php
@@ -0,0 +1,30 @@
+ */
+ private static array $db = [
+ 'Title' => 'Varchar',
+ ];
+
+ /** @var class-string>[] */
+ private static array $extensions = [
+ EventDispatchExtension::class,
+ Versioned::class,
+ ];
+}
diff --git a/tests/php/Service/EventServiceTest.php b/tests/php/Service/EventServiceTest.php
new file mode 100644
index 0000000..6e253fd
--- /dev/null
+++ b/tests/php/Service/EventServiceTest.php
@@ -0,0 +1,117 @@
+get(EventService::class);
+ }
+
+ public function testEventDispatch(): void
+ {
+ // Create test event
+ $event = new class () {
+ public bool $handled = false;
+ };
+
+ $service = $this->getService();
+
+ // Add test listener
+ $service->addListener(get_class($event), function ($event) {
+ $event->handled = true;
+ });
+
+ // Dispatch event
+ $result = $service->dispatch($event)->await();
+
+ // Assert listener was called
+ $this->assertTrue($result->handled, 'Event listener should have been called');
+ }
+
+ public function testEventDispatchWithConfiguredListener(): void
+ {
+ // Create test event
+ $event = new class () {
+ public bool $handled = false;
+ };
+ // Configure listener via config
+ $eventClass = get_class($event);
+ EventService::config()->set('listeners', [
+ $eventClass => [
+ function ($event) {
+ $event->handled = true;
+ },
+ ],
+ ]);
+
+ $service = $this->getService();
+
+ // Dispatch event
+ $result = $service->dispatch($event)->await();
+
+ // Assert listener was called
+ $this->assertTrue($result->handled, 'Configured event listener should have been called');
+ }
+
+ public function testEventDispatchWithConfiguredLoader(): void
+ {
+ // Create test event
+ $event = new class () {
+ public bool $handled = false;
+ };
+
+ // Create test loader
+ $loader = new TestListenerLoader(get_class($event));
+
+ // Configure loader via config
+ EventService::config()->set('loaders', [$loader]);
+
+ $service = $this->getService();
+ $this->assertTrue($loader->loaded, 'Loader should have been used');
+
+ // Dispatch event
+ $result = $service->dispatch($event);
+
+ EventLoop::run();
+
+ // Assert loader was used and listener was called
+ $this->assertTrue($loader->eventFired, 'Configured event listener should have been called');
+ }
+
+ public function testEventDispatchWithDisabledDispatch(): void
+ {
+ // Create test event
+ $event = new class () {
+ public bool $handled = false;
+ };
+
+ $service = $this->getService();
+
+ // Add test listener
+ $service->addListener(get_class($event), function ($event) {
+ $event->handled = true;
+ });
+
+ // Dispatch event
+ $service->disableDispatch();
+ $result = $service->dispatch($event)->await();
+
+ // Assert listener was called
+ $this->assertFalse($result->handled, 'Event listener should not have been called when dispatch is disabled');
+
+ // Re-enabled dispatch
+ $service->enableDispatch();
+ $result = $service->dispatch($event)->await();
+
+ // Assert listener was called
+ $this->assertTrue($result->handled, 'Event listener should have been called when dispatch is re-enabled');
+ }
+}
diff --git a/tests/php/TestListenerLoader.php b/tests/php/TestListenerLoader.php
new file mode 100644
index 0000000..6310f8f
--- /dev/null
+++ b/tests/php/TestListenerLoader.php
@@ -0,0 +1,37 @@
+ $eventName
+ */
+ public function __construct(
+ private string $eventName
+ ) {
+ }
+
+ public function loadListeners(ListenerProvider $provider): void
+ {
+ $this->loaded = true;
+ $provider->addListener($this->eventName, [$this, 'handleEvent']);
+ }
+
+ public function handleEvent(object $event): void
+ {
+ $this->eventFired = true;
+ }
+}