Extension Events
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Fired after an extension is enabled.
| Key | Type | Description |
|---|---|---|
id |
string |
Extension ID (e.g. vendor/name) |
extension.disabled
Fired after an extension is disabled.
| Key | Type | Description |
|---|---|---|
id |
string |
Extension ID (e.g. vendor/name) |
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
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
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
Each listener is wrapped in a try/catch. If your listener throws:
- The exception is logged to
extensions.login the logs directory - 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
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
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
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
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.