Skip to content

Units of Work ​

Every write in a LaravelUi5 application runs through the same frame. Three roles, never mixed:

RoleWhat it isExamples
TriggerStarts a run: who acts, where the input comes from, one correlation key for everything that followsan action (HTTP), an audited command (console), an audited job (queue)
Unit of workOne business step: one transaction, one seal, one actoran action's handler, a sync worker, one dunning step
OperationLogic without a transaction of its own — what a unit of work callsa 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:

php
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:

php
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:

php
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 ​

RuleWhat it saysWhat happens if you break it
A1 — No unit of work inside anotherWhile one is open, the runner refuses to start the next.NestedWorkUnitException, in tests and production alike.
A2 — What a unit dispatches runs after its commitJobs, 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 workFixed 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 commitAn 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:

BaseWho acts#[Act] on its declaration
AbstractAutomationCommandthe automation actor — it never asksnot allowed
AbstractPersonCommandthe person who signs in (e-mail, password; no --no-interaction)checked against them
php
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:

php
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 ​