Units of Work
Every write in a LaravelUi5 application runs through the same frame. Three roles, never mixed:
| Role | What it is | Examples |
|---|---|---|
| Trigger | Starts a run: who acts, where the input comes from, one correlation key for everything that follows | an action (HTTP), an audited command (console), an audited job (queue) |
| Unit of work | One business step: one transaction, one seal, one actor | an action's handler, a sync worker, one dunning step |
| Operation | Logic without a transaction of its own — what a unit of work calls | a pricing service, TextsWriter, a validator |
A trigger composes units of work; a unit of work composes operations. That is the whole grammar.
A unit of work
A unit of work implements WorkUnitInterface — the same signature an action's handler has, because every handler is one:
public function handle(
BusinessTransaction $transaction,
BusinessContextInterface $context,
SdkContext $sdk,
): Outcome;It seals the transaction it is handed — commit() or rollback() — and returns that seal. The runner opens the database transaction before it starts and commits or rolls back by the outcome; a thrown exception rolls back and is rethrown. A unit of work never opens a transaction of its own. A deadlock gets one more attempt with a fresh seal — the first one left nothing half done.
A unit of work that is no action's handler is declared in its module, like any artifact. Its name in the change log comes from that declaration; the runner refuses a unit of work nobody declared:
final class DunInvoice extends AbstractWorkUnit
{
public const string NAMESPACE = 'com.example.billing.units.dun_invoice';
public const string VERSION = '1.0.0';
public const string TITLE = 'Dun an invoice';
public const string DESCRIPTION = 'Sends the next reminder for one overdue invoice.';
public const string UNIT = DunInvoiceUnit::class;
}An audited command is declared the same way, with AbstractCommand and COMMAND. Its namespace carries one commands segment, a unit's one units segment.
Running one
A trigger resolves who acts before it runs anything — the session's person for an action, the initiator for a job, the automation actor for an automation command — and hands that to the runner as the SdkContext:
foreach ($this->dueInvoices() as $invoice) {
$runner->run($this->dunInvoice, new BusinessContext(parameters: ['invoice' => $invoice]), $sdk);
}Four hundred invoices, four hundred units of work, four hundred transactions. One failing invoice does not undo the others.
The rules
| Rule | What it says | What happens if you break it |
|---|---|---|
| A1 — No unit of work inside another | While one is open, the runner refuses to start the next. | NestedWorkUnitException, in tests and production alike. |
| A2 — What a unit dispatches runs after its commit | Jobs, queued listeners and mail go out once the transaction committed, never after a rollback. Dispatch with afterCommit(); queue connections set after_commit. | A job that runs on data that was rolled back. |
| A3 — One actor per unit of work | Fixed when it opens. A step under other authority is a follow-up unit of work, not a branch inside this one. | A change in the audit attributed to the wrong person. |
| K1 — Effects outside the database after the commit | An operation opens no unit of work and seals nothing. HTTP calls, files and mail happen after the commit, not inside the transaction. | A mail about an order that does not exist. |
Two compositions that look tempting and are not available: two existing units of work cannot be made atomic together, and a step under other authority is never atomic with the initiator's. If you need all-or-nothing over many rows, that is one unit of work.
Catalogue units of work
A unit of work that rewrites a catalogue the code declares — the sync workers mirroring artifacts, abilities, roles, settings declarations, slots and Customizing catalogs — implements CatalogWorkUnitInterface. It declares the tables it writes in tables() and writes no positions to the change log: one head records the run, with a summary() of what it did. The code is the evidence; a position per row would add nothing.
The opt-out has two limits, both enforced:
- Only an automation command may run one. Actions always run audited; the runner refuses a catalogue unit of work under any other trigger (
CatalogWorkUnitRefusedException). - None of its tables carries
#[Audited]. A table holds either what code declares or what people decide, never both — see the audit.
Audited commands
Scaffold it — php artisan ui5:command {App}/{Name}, with --act and --role for a person command or --automation; ui5:unit and ui5:job do the same for the other two. Each writes the class and its declaration and prints what you add: the declaration in the module, the command in bootstrap/app.php, the texts.
A command is declared with AbstractCommand (one commands segment) and extends one of two bases — the kind is a property of the command, not a switch:
| Base | Who acts | #[Act] on its declaration |
|---|---|---|
AbstractAutomationCommand | the automation actor — it never asks | not allowed |
AbstractPersonCommand | the person who signs in (e-mail, password; no --no-interaction) | checked against them |
final class LockTenant extends AbstractPersonCommand
{
protected $signature = 'tenant:lock {tenant}';
public function handleCommand(WorkUnitRunnerInterface $runner, SdkContext $sdk, LockUnit $lock): int
{
$runner->run($lock, new BusinessContext(validated: ['tenant' => $this->argument('tenant')]), $sdk);
return self::SUCCESS;
}
}Every audited command takes --language. Its signature, arguments and options — redacted — stand in the head's input; a person command adds the console it ran from (cli; user@host).
Audited jobs
A job is declared like a command — AbstractJob, one jobs segment in its namespace, JOB naming the class — and extends AbstractAuditedJob, whose hull opens the job's run:
final class DunInvoicesJob extends AbstractAuditedJob
{
public function handle(WorkUnitRunnerInterface $runner, SdkContext $sdk, DunInvoiceUnit $dun): void
{
foreach ($this->dueInvoices() as $invoice) {
$runner->run($dun, new BusinessContext(parameters: ['invoice' => $invoice]), $sdk);
}
}
}Dispatched from an action, a command or another job, it keeps that run's correlation key and acts for the person who started it — the change log reads as one story. A scheduled job acts as the automation actor. A job carries no #[Act] of its own: the gate is its trigger's. Dispatch it after the commit (afterCommit()), so it never runs for a change that was rolled back.
The automation actor
What runs without anyone at the keyboard — ui5:sync, ui5:ready, scheduled jobs — runs as the automation actor: the partner the SDK setting automation_actor names by e-mail — by default Pragmatiqu IT's support department, which the operator replaces with its own on scope Installation. Any partner type. If no partner has that e-mail, the first run that needs it fails with an AutomationActorException. Rebind AutomationActorResolverInterface to resolve it differently.
Sync workers and blockers
ui5:sync is an automation command composing its workers as units of work, each in its own transaction — the sync pipeline has the order and the contract. A row the code no longer declares but people's data still uses is a blocker: the worker keeps the row (and its texts), reports it as an error message with the blocking object as its target, and finishes the rest. ui5:ready runs every worker in one transaction that is rolled back and lists every blocker; ui5:sync runs that check first and does not start while one stands.
See also
- Mutating Actions — the trigger you write most
- The Audit — what the frame records, and the write rule
- Sync Pipeline — the units of work behind
ui5:sync