Extension Events Since 3.5.0
Extensions can subscribe to events fired by Total CMS after core operations complete. Events are synchronous and fire after the operation succeeds. If a listener throws an exception, it is caught and logged without affecting the core operation or other listeners.
Subscribing to Events
Section titled “Subscribing to Events”public function register(ExtensionContext $context): void{ $context->addEventListener('object.created', function (array $payload): void { // Handle the event }, priority: 0);}The priority parameter controls execution order (lower numbers run first). Default is 0.
Available Events
Section titled “Available Events”object.created
Section titled “object.created”Fired after a new object is saved to a collection.
| Key | Type | Description |
|---|---|---|
collection | string | Collection name |
id | string | Object ID |
$context->addEventListener('object.created', function (array $payload): void { $collection = $payload['collection']; $objectId = $payload['id']; // e.g., send a webhook notification});object.updated
Section titled “object.updated”Fired after an existing object is updated.
| Key | Type | Description |
|---|---|---|
collection | string | Collection name |
id | string | Object ID |
$context->addEventListener('object.updated', function (array $payload): void { // e.g., clear a CDN cache for this object});object.deleted
Section titled “object.deleted”Fired after an object is deleted from a collection.
| Key | Type | Description |
|---|---|---|
collection | string | Collection name |
id | string | Object ID |
$context->addEventListener('object.deleted', function (array $payload): void { // e.g., clean up related data in your extension});collection.created
Section titled “collection.created”Fired after a new collection is created.
| Key | Type | Description |
|---|---|---|
collection | string | Collection ID |
$context->addEventListener('collection.created', function (array $payload): void { // e.g., set up default content for a new collection});collection.updated
Section titled “collection.updated”Fired after a collection’s settings are updated.
| Key | Type | Description |
|---|---|---|
collection | string | Collection ID |
$context->addEventListener('collection.updated', function (array $payload): void { // e.g., react to collection configuration changes});collection.deleted
Section titled “collection.deleted”Fired after a collection is deleted.
| Key | Type | Description |
|---|---|---|
collection | string | Collection ID |
$context->addEventListener('collection.deleted', function (array $payload): void { // e.g., clean up extension data related to this collection});import.created
Section titled “import.created”Fired per object when an importer creates a new object. Replaces object.created for the duration of an import — during import, object.created is suppressed for the importing collection so listeners can distinguish import-time writes from user-driven ones.
| Key | Type | Description |
|---|---|---|
collection | string | Collection ID |
id | string | Object ID |
object | ObjectData | Newly created object |
$context->addEventListener('import.created', function (array $payload): void { // e.g., index the newly imported object in an external search service});Fired by: ObjectImporter (used by CSV, JSON, RSS, WordPress, URL, Alloy, Total CMS 1 imports), JumpStartImporter, UrlImporter.
import.updated
Section titled “import.updated”Fired per object when an importer updates an existing object. Replaces object.updated during import (same suppression model as import.created).
| Key | Type | Description |
|---|---|---|
collection | string | Collection ID |
id | string | Object ID |
object | ObjectData | Updated object |
previous | ?ObjectData | Object state before the update (when available) |
$context->addEventListener('import.updated', function (array $payload): void { // e.g., diff payload['object'] against payload['previous']});Fired by: ObjectImporter (CSV/JSON update mode), DeckJsonImporter, DeckCsvImporter.
import.completed
Section titled “import.completed”Fired ONCE per batch import (CSV, JSON, or URL) after all objects in the batch are processed. Triggers a single index rebuild and auto-resumes the object.* event suppression for the collection.
| Key | Type | Description |
|---|---|---|
collection | string | Collection ID |
count | int | Number of objects imported |
created | string[] | IDs of newly created objects |
updated | string[] | IDs of updated objects |
$context->addEventListener('import.completed', function (array $payload): void { $collection = $payload['collection']; $count = $payload['count']; // e.g., send a notification that import is done
// Act on specific updated objects foreach ($payload['updated'] as $id) { // e.g., send an email to updated members }});Why subscribe to
import.*instead ofobject.*? When a 10,000-row CSV imports,object.createdwould fire 10,000 times — but it doesn’t, because the dispatcher suspends it. Subscribe toimport.createdif you want per-object import notifications; subscribe toimport.completedif you only care about the batch summary; subscribe to both if you need both.
schema.saved
Section titled “schema.saved”Fired after a schema is created or updated.
| Key | Type | Description |
|---|---|---|
schema | string | Schema ID |
$context->addEventListener('schema.saved', function (array $payload): void { // e.g., regenerate a search index});schema.deleted
Section titled “schema.deleted”Fired after a schema is deleted.
| Key | Type | Description |
|---|---|---|
schema | string | Schema ID |
$context->addEventListener('schema.deleted', function (array $payload): void { // e.g., remove cached data for this schema});template.saved
Section titled “template.saved”Fired after a Builder template (.twig file) is written via TemplateSaver. Powers the Builder live-reload feature internally — extensions can listen too, e.g., to re-warm a template cache or trigger a downstream rebuild.
| Key | Type | Description |
|---|---|---|
id | string | Template id without folder prefix (e.g., about, partials/header) |
folder | string|null | Optional sub-folder (pages, layouts, etc.) or null for root |
path | string | Full path including folder (e.g., pages/about) |
$context->addEventListener('template.saved', function (array $payload): void { // e.g., flush a template-derived cache key});user.login
Section titled “user.login”Fired after a user successfully logs in.
| Key | Type | Description |
|---|---|---|
user | string | User ID or email |
$context->addEventListener('user.login', function (array $payload): void { // e.g., track login activity});user.logout
Section titled “user.logout”Fired after a user logs out.
| Key | Type | Description |
|---|---|---|
user | string | User ID or email |
$context->addEventListener('user.logout', function (array $payload): void { // e.g., clean up temporary data});extension.enabled
Section titled “extension.enabled”Fired after an extension is enabled.
| Key | Type | Description |
|---|---|---|
id | string | Extension ID (e.g. vendor/name) |
extension.disabled
Section titled “extension.disabled”Fired after an extension is disabled.
| Key | Type | Description |
|---|---|---|
id | string | Extension ID (e.g. vendor/name) |
devmode.enabled
Section titled “devmode.enabled”Fired after development mode is enabled.
| Key | Type | Description |
|---|---|---|
duration | int | Duration in seconds |
$context->addEventListener('devmode.enabled', function (array $payload): void { // e.g., enable verbose logging in your extension});devmode.disabled
Section titled “devmode.disabled”Fired after development mode is disabled.
The payload array is empty.
$context->addEventListener('devmode.disabled', function (array $payload): void { // e.g., disable debug features in your extension});cache.cleared
Section titled “cache.cleared”Fired after all caches are cleared.
| Key | Type | Description |
|---|---|---|
success | bool | Whether all caches cleared successfully |
The payload also includes per-service results (e.g. filesystem, redis, apcu).
$context->addEventListener('cache.cleared', function (array $payload): void { // e.g., clear your extension's own cache});Listener Isolation
Section titled “Listener Isolation”Each listener is wrapped in a try/catch. If your listener throws:
- The exception is logged to
logs/extensions.log - Other listeners for the same event continue executing
- The core operation that triggered the event is not affected (it already completed)
This means you should not rely on events for critical side effects that must succeed. Events are best for notifications, caching, analytics, and other non-critical reactions to content changes.
Priority
Section titled “Priority”Listeners with lower priority numbers execute first:
// Runs first$context->addEventListener('object.created', $listenerA, priority: 10);
// Runs second$context->addEventListener('object.created', $listenerB, priority: 20);If two listeners have the same priority, they execute in the order they were registered.
Sending Notifications from Event Listeners
Section titled “Sending Notifications from Event Listeners”Extensions can use events to trigger notifications automatically when content changes. The built-in PushoverService and EmailService are available via the DI container in the boot() phase.
Pushover Notification on Object Created
Section titled “Pushover Notification on Object Created”use TotalCMS\Domain\Notification\Service\PushoverService;
public function boot(ExtensionContext $context): void{ $pushover = $context->get(PushoverService::class);
$context->addEventListener('object.created', function (array $payload) use ($pushover): void { $pushover->send( message: "New {$payload['collection']} object: {$payload['id']}", title: 'Content Created', ); });}Email Notification on Import Completed
Section titled “Email Notification on Import Completed”use TotalCMS\Domain\Mailer\Service\EmailService;
public function boot(ExtensionContext $context): void{ $mailer = $context->get(EmailService::class);
$context->addEventListener('import.completed', function (array $payload) use ($mailer): void { $mailer->sendEmail('import-notification', [ 'collection' => $payload['collection'], 'count' => $payload['count'], ]); });}These listeners run after the core operation completes. If sending fails, the exception is caught and logged without affecting the content operation.