Skip to content

Automations

The automations module (app-modules/automations) replaces hard-coded “what happens around” behaviour, such as follow-up tasks, tags and notifications, with rules the business can change. Pipeline modules stay correct and announce events, and the business decides what follows. Staff build rules at System > Automations: see automations.

A rule is When (a trigger), If (conditions, all must hold) and Then (actions, in order), straight away or after a wait.

Other modules register what rules can be built from, at boot, through the Automations facade (Registry\AutomationRegistry, a singleton). Two do so today: Sales\Automations\SalesAutomations and Catalog\Automations\CatalogAutomations.

Call Registers
Automations::subject($class, $label, $fields) A record type rules can be about, with fields for conditions
Automations::trigger($key, $event, $label, $subject, $subjectOf, $dataOf, $fields) An event, and how to get the record from it
Automations::action(Action) Something a rule can do
Automations::notifies($class, NotifyTarget) What Notify staff knows about a record type

$key values, on triggers and actions alike, are stored on rules. Never rename one.

Fields are Fields\ChoiceField and Fields\NumberField. A field’s closure gets (Model $subject, array $eventData) and is read when the rule runs, so conditions see the record as it is then.

  1. An event fires. Registering a trigger attaches Running\QueueRulesForEvent to its event class. Sales events dispatch after commit.
  2. QueueRulesForEvent does nothing if automations are switched off (Models\AutomationSettings) or paused. For each trigger whose record resolves, it queues one Jobs\RunAutomationRule per active rule, by sort_order, or stores an AutomationPendingRun for a rule that waits.
  3. The job re-checks that the rule is on and the record exists, then the conditions (Running\ConditionEvaluator). The first that fails is the skip reason.
  4. The actions run in one transaction, so a failure undoes them all. The job retries 3 times, 30 seconds apart.
  5. An AutomationRun records every outcome: ran, skipped or failed.

Invariants to keep in mind:

  • Loops are capped. An action can fire events that start other rules. The depth travels in Laravel’s Context, so it survives the queue, and at AutomationRegistry::MAX_DEPTH (5) no more rules are queued and a warning is logged.
  • Bulk work pauses them. Wrap imports, seeders and data fixes in Automations::paused(fn () => ...).
  • Attribution. While actions run, Automations::currentRule() gives the rule, and sales’ timeline shows “Rule: name” in place of a person.

automations:run-due (Console\Commands\RunDueAutomations) is scheduled every minute without overlapping, by AutomationsServiceProvider. Production needs the scheduler’s cron entry (queues and scheduling).

It deletes each due AutomationPendingRun first, so an overlapping run can’t start it too, then queues RunAutomationRule, which checks conditions as things are then. So a payment reminder stops by itself once the invoice is paid.

  • There is one pending run per rule and record. A new trigger replaces what waits and restarts the clock.
  • With automations switched off, due runs are dropped, not caught up later.
  • A repeating rule (repeat_every, repeat_unit, repeat_times) stores its next run after each success. A run skipped or failed queues nothing, which ends the repeats.
  • Changing a rule’s wait moves its waiting first runs. Deleting a rule deletes them.

model:prune removes old AutomationRun rows daily, by the retention days in Settings\AutomationsSettings (under Housekeeping on Settings).

Suppose sales gains a QuoteExpired event.

  1. The event. Extend SalesDocumentEvent, fired from the right place. A sales event then dispatches after commit, and carries actorId.

  2. Register it in the owning module’s registration class, called from its service provider’s boot():

    Automations::trigger('quote.expired', QuoteExpired::class, 'Quote expired', Quote::class, $document);

    $document in SalesAutomations returns the event’s document when it is a quote, else null. A closure can return null to say the event isn’t this trigger, so one event can serve several (NoteAdded on a quote is not NoteAdded on a customer).

  3. Event data, if a condition needs it. Pass $dataOf for plain values the queued job needs, and $fields that read them. See catalog.product_changed_by_feed in CatalogAutomations.

  4. A new record type also needs Automations::subject(), and Automations::notifies() for Notify staff to link to it and offer placeholders.

Don’t put a listener in a Listeners/ folder for this: QueueRulesForEvent is deliberately elsewhere so modular’s event discovery doesn’t also register it.

Implement Actions\Action and register it. The sales AddTag action is a short example:

class AddTag implements Action
{
public function key(): string { return 'add_tag'; } // stored on rules; never rename
public function label(): string { return 'Add tag'; }
public function supports(string $subject): bool
{
return in_array($subject, [Quote::class, Customer::class], true);
}
public function schema(string $subject): array // its settings form on the rule screen
{
return [Select::make('tag_id')->label('Tag')->options(/* ... */)->required()];
}
public function execute(Model $subject, array $settings, RunContext $context): string
{
$tag = Tag::query()->findOrFail($settings['tag_id'] ?? null);
return $subject->addTag($tag) ? "Tagged {$tag->name}." : "Already tagged {$tag->name}.";
}
}

execute() returns what it did, for the run log, and runs inside the rule’s transaction. Throw to undo the rule. Then Automations::action(new AddTag).

The rule screen builds its Builder block from schema(), with one block per action that supports the trigger’s record type, so an action needs no Filament code of its own. Settings stored on rules must not change meaning later.

SalesAutomationRulesSeeder seeds the starting rules, switched off.

tests/Feature/AutomationEngineTest.php runs the engine against a throwaway Gadget record, so it depends on nothing from sales. Tests use the sync queue, so a rule runs inline. To test a new action, create a rule with it and fire the event.

AutomationRulePolicy, AutomationRunPolicy and AutomationPendingRunPolicy all check automations.manage.