Skip to content

Domain events

Domain events tell your code that something happened: a record changed, a workflow moved, a task was completed. They are raised for every way of changing data, in panels, actions, editable columns, the API and commands.

app/Config/Anvil.php
public array $domainEventListeners = [
'record.updated' => [DealWon::class],
];
use Anvil\Data\DomainEvent;
final class DealWon
{
public function __invoke(DomainEvent $event): void
{
if ($event->subject === 'deals' && $event->newValue('stage') === 'won') {
$deal = (new DealModel())->findByUuid((string) $event->subjectUuid);
// ...
}
}
}

Closures can listen as well, for example in a service or a test:

service('domainEvents')->on('record.*', $listener, subject: 'deals');
Event When data
record.created an auditable model inserted a row
record.updated an auditable model changed a row; nothing is raised if no value changed
record.deleted an auditable model deleted a row soft
workflow.transitioned a workflow changed its state workflow, transition, from, to, override
task.completed a task was completed workflow, task, outcome
ownership.changed owner or deputies changed owner, added_deputy, removed_deputy or valid_until
ownership.handover the owner has left and nobody can take over owner

Your own events use the same way:

service('domainEvents')->dispatch('invoice.sent', 'invoices', $uuid, ['to' => $email]);

A pattern can be an exact name, record.* or *.

Property Meaning
name, uuid name of the event, and its own id
subject, subjectUuid kind of record (the table or $auditSubject of the model) and its UUID
tenantId the tenant; listeners run in it
actor who did it: user, API client or system
data what the event adds
changes, changed('stage'), oldValue('stage'), newValue('stage') changed fields, masked like in the audit log
occurredAt, toArray() time in UTC, everything as array

Events hold no internal ids. Load the record by its UUID. Masked fields contain the mask, not the value.

After the commit, with on() and $domainEventListeners. The listener only sees changes that were saved. With a transaction around several changes, it runs when the outermost one has ended. An exception is logged and does not reach the user, because the change is already saved. The other listeners still run.

Inside the transaction, with during(). An exception rolls the change back, so the user sees an error. Use it to write something that has to be consistent with the change. Keep it short: rows are locked meanwhile.

service('domainEvents')->during('record.deleted', static function (DomainEvent $event): void {
// ...
}, subject: 'contacts');
  • Listeners that change records raise new events. After five levels the chain is stopped with an entry in the log. Check changed() to avoid such loops.
  • Writes that bypass the model raise no events, and models that are not auditable neither.
  • In tests, listen with service('domainEvents')->on('*', ...). Listeners stay on the shared service until Services::resetSingle('domainEvents').