Automations
Actions
Register an automation step that does something to a contact, and tell the engine what happened with StepResult.
# Actions An action is a step an automation runs for a contact: tag them, call your API, grant a reward. Register it on the `marker_crm/automation_actions` filter, from inside `marker_crm/loaded`. ```php use MarkerCRM\App\Models\Contact; use MarkerCRM\App\Services\Automation\RunContext; use MarkerCRM\App\Services\Automation\StepResult; add_filter('marker_crm/automation_actions', function (array $actions) { $actions['acme_grant'] = [ 'label' => __('Grant a reward', 'acme'), 'description' => __('Gives the member a reward.', 'acme'), 'group' => 'Acme', 'integration' => ['key' => 'acme', 'label' => 'Acme'], 'icon' => 'gift', 'fields' => [['key' => 'points', 'label' => __('Points', 'acme'), 'type' => 'number', 'required' => true]], 'sanitize' => fn (array $settings) => ['points' => absint($settings['points'] ?? 0)], 'run' => function (Contact $contact, array $settings, RunContext $run) { return acme_grant($contact, $settings['points'], $run->get('level')) ? StepResult::done() : StepResult::fail(__('Acme refused the grant.', 'acme')); }, ]; return $actions; }); ``` ## Keys | Key | Type | Required | | |---|---|---|---| | `run` | `callable` | Yes | Does the work. See [Running](#running) | | `label` | `string` | No (defaults to the key) | Name in the step picker and on the canvas | | `description` | `string` | No | One line in the picker: what the step does | | `group` | `string` | No (defaults to *Other*) | Heading inside your integration's section | | `integration` | `array` | No | `['key' => …, 'label' => …, 'logo' => …]`, as for [triggers](/developers/automations/triggers/#keys) | | `category` | `string` | No (defaults to `apps`) | Section of the **Actions** tab: `contact`, `email`, `sms`, `timing`, `automation`, `wordpress` or `apps`. In `apps`, steps are headed by their integration | | `icon` | `string` | No | A Lucide name from the [icon list](/developers/automations/triggers/#icons) | | `fields` | `array` | No | The settings panel. See [Settings fields](/developers/automations/fields/) | | `sanitize` | `callable(array $settings): array` | No | Cleans the settings on save. When given, it owns them outright | | `available` | `bool` or `callable(): bool` | No | Whether the step can run right now | | `capability` | `string` | No | A WordPress capability the person saving the automation must hold to add this step or change its settings | | `message` | `bool` | No | `true` when the step sends something to the contact, so the email's sending window applies | Leave out `category` and your action lands under **Apps**, headed by your integration's name and logo — which is usually what you want. ## Running ```php function (Contact $contact, array $settings, RunContext $run): ?StepResult ``` - `$contact` is the contact the run is for. - `$settings` is what the site owner saved in your fields, after `sanitize`. - `$run` is the run it is working inside. `run` executes on Marker CRM's queue, never in the request that started the automation. There is no signed-in user and no request to read. Keep it under a few seconds; for anything slower, queue a job of your own and return `done()`. ### StepResult Return a `StepResult`, or `null` for done: | Result | What happens | |---|---| | `StepResult::done()` | Moves on to the next step | | `StepResult::wait(int\|DateTimeInterface $until)` | Holds the contact until then (seconds from now, at least 60, or a moment), then moves on. The action is not run again | | `StepResult::skipped(string $reason)` | Moves on as done without retrying. The reason is logged and passed to `marker_crm/automation_step_executed`. Use it when the step must not act on this contact — they have no account, they opted out of that list | | `StepResult::stop()` | Ends the run as completed. Not an error | | `StepResult::fail(string $reason)` | Retries after 1 minute, then 5. The third failure ends the run as **Failed**, with the reason kept as its last error | A thrown exception counts as `fail()` with the exception's message. Choose `skipped()` over `fail()` when retrying cannot help. A failure retries twice and then throws the contact out of the automation; a skip records why and lets the rest of the flow carry on. ### RunContext | Member | Returns | |---|---| | `$run->get(string $key, $default = null)` | One value from the trigger's context, dot notation allowed | | `$run->context()` | The whole context array | | `$run->trigger()` | The name of the trigger that started the run | | `$run->mergeData(Contact $contact)` | What merge tags read for this run, for rendering text yourself | | `$run->subscriber` | The run (`Models\AutomationSubscriber`) | | `$run->step` | The step being run (`Models\AutomationStep`) | | `$run->automation` | The automation (`Models\Automation`) | To fill merge tags in your own text — a note, a value you send elsewhere — render it with the run's data: ```php use MarkerCRM\App\Services\Sending\MergeTagService; $text = MergeTagService::text((string) $settings['message'], $run->mergeData($contact)); ``` `MergeTagService::text()` leaves values as plain text. Use `MergeTagService::render()` only for HTML: it escapes every value for HTML. ## Writing to the contact If your step changes the contact, write through the same service every other part of the CRM uses, so automations and webhooks hear about it: ```php use MarkerCRM\App\Services\Contact\ContactService; ContactService::createOrUpdate(['id' => $contact->id, 'custom' => ['plan' => 'pro']]); ``` The contact you are handed is the one the run loaded. If an earlier step in the same chain changed it, re-read what you depend on rather than trusting the object. ## Availability When `available` says no — the plugin your step needs is inactive — the step is left out of the picker, and an automation using it is listed as unavailable and cannot be turned on. A run that reaches it waits 15 minutes at a time, without using up an attempt, until it is available again. The step's settings are kept as stored when the automation is saved. `available` runs on every request: keep it to a `class_exists()` or a constant. ## Capabilities The runner acts as nobody, so a step that hands out access is guarded when it is saved. With `'capability' => 'promote_users'`, a person without that capability cannot add the step or change its settings — the whole save is refused with a 422 and nothing is written. They may still keep, move or delete one somebody else built. ## Sending something to the contact A step that sends a message to the contact says `'message' => true`, and the builder is told so. For such a step, a `window` in its settings holds the contact until the window opens, in the shape the core email step stores: | `window` | Means | |---|---| | `null` or missing | Run as soon as it is reached | | `['mode' => 'custom', 'days' => […], 'from' => 'HH:MM', 'to' => 'HH:MM']` | Only within these hours and days, site time. `days` are `0` (Sunday) to `6`; empty means any day | | `['mode' => 'date', 'at' => 'Y-m-d H:i', 'late' => 'send'\|'skip']` | Not before `at`, site time; if that had passed on arrival, send at once or skip | The builder draws the window control only for the core **Send an email** step, so an add-on that wants one provides `window` itself, for example through `sanitize`. ## Required fields A field marked `required` that is left empty stops the automation being turned on or a draft being published. The check runs on the server, with the same rules as the builder's checklist, so the list page's **Activate** cannot get round it. See [Settings fields](/developers/automations/fields/#required). ## When an action is rejected An action without `run`, or with `run`, `sanitize` or `available` that is not callable, a `capability` that is not a string, or a key that is not lowercase letters, numbers, dashes and underscores, is skipped with a `_doing_it_wrong()` notice naming it. Every other action still loads. A new step naming an action nobody registers is dropped when the automation is saved. A step already saved keeps its settings while its add-on is away, and a run that reaches it fails rather than silently skipping a step nobody can see. ## Changing a core action Core actions are registered at priority 5. An add-on at the default priority 10 may change one or `unset()` it.An action is a step an automation runs for a contact: tag them, call your API, grant a reward.
Register it on the marker_crm/automation_actions filter, from inside marker_crm/loaded.
use MarkerCRM\App\Models\Contact;use MarkerCRM\App\Services\Automation\RunContext;use MarkerCRM\App\Services\Automation\StepResult;
add_filter('marker_crm/automation_actions', function (array $actions) { $actions['acme_grant'] = [ 'label' => __('Grant a reward', 'acme'), 'description' => __('Gives the member a reward.', 'acme'), 'group' => 'Acme', 'integration' => ['key' => 'acme', 'label' => 'Acme'], 'icon' => 'gift', 'fields' => [['key' => 'points', 'label' => __('Points', 'acme'), 'type' => 'number', 'required' => true]], 'sanitize' => fn (array $settings) => ['points' => absint($settings['points'] ?? 0)], 'run' => function (Contact $contact, array $settings, RunContext $run) { return acme_grant($contact, $settings['points'], $run->get('level')) ? StepResult::done() : StepResult::fail(__('Acme refused the grant.', 'acme')); }, ];
return $actions;});| Key | Type | Required | |
|---|---|---|---|
run |
callable |
Yes | Does the work. See Running |
label |
string |
No (defaults to the key) | Name in the step picker and on the canvas |
description |
string |
No | One line in the picker: what the step does |
group |
string |
No (defaults to Other) | Heading inside your integration’s section |
integration |
array |
No | ['key' => …, 'label' => …, 'logo' => …], as for triggers |
category |
string |
No (defaults to apps) |
Section of the Actions tab: contact, email, sms, timing, automation, wordpress or apps. In apps, steps are headed by their integration |
icon |
string |
No | A Lucide name from the icon list |
fields |
array |
No | The settings panel. See Settings fields |
sanitize |
callable(array $settings): array |
No | Cleans the settings on save. When given, it owns them outright |
available |
bool or callable(): bool |
No | Whether the step can run right now |
capability |
string |
No | A WordPress capability the person saving the automation must hold to add this step or change its settings |
message |
bool |
No | true when the step sends something to the contact, so the email’s sending window applies |
Leave out category and your action lands under Apps, headed by your integration’s name
and logo — which is usually what you want.
Running
Section titled “Running”function (Contact $contact, array $settings, RunContext $run): ?StepResult$contactis the contact the run is for.$settingsis what the site owner saved in your fields, aftersanitize.$runis the run it is working inside.
run executes on Marker CRM’s queue, never in the request that started the automation. There
is no signed-in user and no request to read. Keep it under a few seconds; for anything slower,
queue a job of your own and return done().
StepResult
Section titled “StepResult”Return a StepResult, or null for done:
| Result | What happens |
|---|---|
StepResult::done() |
Moves on to the next step |
StepResult::wait(int|DateTimeInterface $until) |
Holds the contact until then (seconds from now, at least 60, or a moment), then moves on. The action is not run again |
StepResult::skipped(string $reason) |
Moves on as done without retrying. The reason is logged and passed to marker_crm/automation_step_executed. Use it when the step must not act on this contact — they have no account, they opted out of that list |
StepResult::stop() |
Ends the run as completed. Not an error |
StepResult::fail(string $reason) |
Retries after 1 minute, then 5. The third failure ends the run as Failed, with the reason kept as its last error |
A thrown exception counts as fail() with the exception’s message.
Choose skipped() over fail() when retrying cannot help. A failure retries twice and then
throws the contact out of the automation; a skip records why and lets the rest of the flow
carry on.
RunContext
Section titled “RunContext”| Member | Returns |
|---|---|
$run->get(string $key, $default = null) |
One value from the trigger’s context, dot notation allowed |
$run->context() |
The whole context array |
$run->trigger() |
The name of the trigger that started the run |
$run->mergeData(Contact $contact) |
What merge tags read for this run, for rendering text yourself |
$run->subscriber |
The run (Models\AutomationSubscriber) |
$run->step |
The step being run (Models\AutomationStep) |
$run->automation |
The automation (Models\Automation) |
To fill merge tags in your own text — a note, a value you send elsewhere — render it with the run’s data:
use MarkerCRM\App\Services\Sending\MergeTagService;
$text = MergeTagService::text((string) $settings['message'], $run->mergeData($contact));MergeTagService::text() leaves values as plain text. Use MergeTagService::render() only
for HTML: it escapes every value for HTML.
Writing to the contact
Section titled “Writing to the contact”If your step changes the contact, write through the same service every other part of the CRM uses, so automations and webhooks hear about it:
use MarkerCRM\App\Services\Contact\ContactService;
ContactService::createOrUpdate(['id' => $contact->id, 'custom' => ['plan' => 'pro']]);The contact you are handed is the one the run loaded. If an earlier step in the same chain changed it, re-read what you depend on rather than trusting the object.
Availability
Section titled “Availability”When available says no — the plugin your step needs is inactive — the step is left out of
the picker, and an automation using it is listed as unavailable and cannot be turned on.
A run that reaches it waits 15 minutes at a time, without using up an attempt, until it is
available again. The step’s settings are kept as stored when the automation is saved.
available runs on every request: keep it to a class_exists() or a constant.
Capabilities
Section titled “Capabilities”The runner acts as nobody, so a step that hands out access is guarded when it is saved. With
'capability' => 'promote_users', a person without that capability cannot add the step or
change its settings — the whole save is refused with a 422 and nothing is written. They may
still keep, move or delete one somebody else built.
Sending something to the contact
Section titled “Sending something to the contact”A step that sends a message to the contact says 'message' => true, and the builder is told
so. For such a step, a window in its settings holds the contact until the window opens, in
the shape the core email step stores:
window |
Means |
|---|---|
null or missing |
Run as soon as it is reached |
['mode' => 'custom', 'days' => […], 'from' => 'HH:MM', 'to' => 'HH:MM'] |
Only within these hours and days, site time. days are 0 (Sunday) to 6; empty means any day |
['mode' => 'date', 'at' => 'Y-m-d H:i', 'late' => 'send'|'skip'] |
Not before at, site time; if that had passed on arrival, send at once or skip |
The builder draws the window control only for the core Send an email step, so an add-on
that wants one provides window itself, for example through sanitize.
Required fields
Section titled “Required fields”A field marked required that is left empty stops the automation being turned on or a draft
being published. The check runs on the server, with the same rules as the builder’s
checklist, so the list page’s Activate cannot get round it. See
Settings fields.
When an action is rejected
Section titled “When an action is rejected”An action without run, or with run, sanitize or available that is not callable, a
capability that is not a string, or a key that is not lowercase letters, numbers, dashes and
underscores, is skipped with a _doing_it_wrong() notice naming it. Every other action still
loads.
A new step naming an action nobody registers is dropped when the automation is saved. A step already saved keeps its settings while its add-on is away, and a run that reaches it fails rather than silently skipping a step nobody can see.
Changing a core action
Section titled “Changing a core action”Core actions are registered at priority 5. An add-on at the default priority 10 may change one
or unset() it.