Workflow Extensions
Workflows can be extended from an Allegro package in two ways:
- Events: list the package's events in the workflow event picker, and send them to the engine so they can trigger workflows, resume waiting runs, and end runs.
- Steps: add a step type to the editor's step menu, with fields the editor renders for you, and the code that runs it.
Both hooks live on Allegro\PackageServiceProvider and are called from the
package's register(). Both respect package enablement: a tenant without the
package enabled never sees its events or steps, and the engine never runs its
steps for that tenant.
Adding events
Registering them in the catalog
registerWorkflowEvents() takes a list of events, each with a name, a label,
and the data keys it carries:
public function register(): void
{
$this->registerWorkflowEvents([
['name' => 'fundraiseup.donation.received', 'label' => 'Donation received', 'payload_keys' => ['amount', 'currency', 'campaign_id']],
['name' => 'fundraiseup.donation.refunded', 'label' => 'Donation refunded', 'payload_keys' => ['amount', 'currency', 'campaign_id']],
]);
}
The events appear in the workflow event picker under the package's label.
payload_keys is optional and is returned to MCP clients by
get-workflow-event-catalog-tool, so use the dotted paths a workflow can
reference (cart.total).
Event names must match [A-Za-z0-9_-]+ in one or more dot-separated parts, and
can't be page_view, page_ping, or journey_activity. A name already in the
catalog, such as a platform event, keeps its existing entry.
Registering only lists the event. Nothing triggers until the package sends it.
Sending events
Send an event through Allegro\Workflows\WorkflowEventBus. It returns straight
away when Workflows are off for the tenant or no published workflow uses the
event name, so call it unconditionally:
use Allegro\Enums\WorkflowEventOrigin;
use Allegro\Workflows\Events\WorkflowEvent;
use Allegro\Workflows\WorkflowEventBus;
use Carbon\CarbonImmutable;
$bus = app(WorkflowEventBus::class);
$bus->dispatch(new WorkflowEvent(
name: 'fundraiseup.donation.received',
tenantId: (string) tenantOrFail()->getKey(),
audienceMemberId: $donation->audience_member_id,
deviceId: null,
propertySlug: $propertySlug,
origin: $bus->currentOrigin(WorkflowEventOrigin::Server),
occurredAt: CarbonImmutable::now(),
payload: ['amount' => $donation->amount, 'currency' => $donation->currency],
));
| Argument | Notes |
|---|---|
audienceMemberId | The member the event is about. When null, the engine looks up the member signed in on deviceId. Events with neither are dropped. |
propertySlug | The property the event happened on. Workflows limited to some properties only enroll and resume on events from those properties. |
origin | Server for something a member just did, such as a webhook for their donation. Wrap bulk work in asSystem() instead (below). |
payload | The event's data. Workflows read it in conditions (event.data.<key>) and emails ({% event.<key> %}); nested arrays are addressed with dots. |
The bus queues the work after the current database transaction commits. Send each event once per real-world change: a second delivery of the same event can count twice toward a multi-occurrence wait or start a second run in an Every time workflow. Deduplicate in the package if its source can redeliver.
Imports, syncs, and backfills
Run bulk work inside asSystem(), so the events it raises carry the System
origin:
app(WorkflowEventBus::class)->asSystem(fn () => $this->importPage($rows));
currentOrigin() returns System anywhere inside the callback, including in
code that doesn't know it is part of an import. System events only enroll
members in workflows that opt in with Include imports and syncs, so loading
historical records doesn't email everyone at once. They still resume waiting
runs and end runs through an exit event.
Adding a step
A step has two parts: a step type that describes it to the editor and validates its settings, and an executor that runs it.
public function register(): void
{
$this->registerWorkflowStep(TagInCrmStep::class, TagInCrmExecutor::class);
}
The executor argument is optional when the step type class implements both interfaces.
The step type
Implement Allegro\Contracts\WorkflowStepType:
namespace Acme\Workflows;
use Allegro\Contracts\WorkflowStepType;
use Allegro\Workflows\Steps\StepField;
final class TagInCrmStep implements WorkflowStepType
{
public function type(): string
{
return 'acme_crm_tag';
}
public function label(): string
{
return 'Tag in CRM';
}
public function group(): string
{
return 'actions';
}
public function fields(): array
{
return [
new StepField('tag', 'text', 'Tag'),
new StepField('note', 'textarea', 'Note', required: false, help: 'Shown on the contact in the CRM.'),
];
}
public function rules(): array
{
return [
'tag' => ['required', 'string', 'max:64'],
'note' => ['nullable', 'string', 'max:500'],
];
}
public function branches(): array
{
return [];
}
public function summarize(array $config): string
{
return 'Tag as ' . ($config['tag'] ?? '…');
}
}
| Method | Returns |
|---|---|
type() | The key stored in the workflow definition. Prefix it with the package name; it must not collide with another step type. |
label() | The name in the step menu and on the card. |
group() | The step menu group: flow, actions, or your own group name, shown capitalized. |
fields() | The editor fields, in order. Each field's name is the config key it writes. |
rules() | Laravel validation rules for the config, keyed relative to the config. They run on every save and publish, and errors are shown on the field. |
branches() | [] for a step that continues to the next one, or branch key ⇒ default label (['yes' => 'Yes', 'no' => 'No']) for a step that splits. A branching step is always last in its list. |
summarize() | The one-line summary on the card. It must not touch the database. |
StepField types, all rendered by the editor with no package JavaScript:
| Type | Writes |
|---|---|
text, textarea | A string. |
number | An integer, bounded by the field's min and max. |
boolean | true or false. |
select | One of the field's options ([['value' => 'a', 'label' => 'A'], …]). |
event | An event name, with the event catalog as suggestions. |
email_template | A workflow email's id. |
segment | A programmatic segment's id. |
user_attribute | A user attribute slug. |
duration | {value: int, unit: 'minutes'|'hours'|'days'}. Spread ...WorkflowDuration::rules('key') (Allegro\Rules) into rules() to cap it at 90 days. |
condition_groups | Targeting condition groups, built with the workflow attribute catalog. |
The executor
Implement Allegro\Contracts\ExecutesWorkflowStep. Executors are resolved
through the container, so constructor injection works:
namespace Acme\Workflows;
use Acme\Crm\CrmClient;
use Acme\Exceptions\CouldNotTagInCrm;
use Allegro\Contracts\ExecutesWorkflowStep;
use Allegro\Workflows\Steps\StepInput;
use Allegro\Workflows\Steps\StepResult;
final class TagInCrmExecutor implements ExecutesWorkflowStep
{
public function __construct(private readonly CrmClient $crm) {}
public function execute(StepInput $input): StepResult
{
$email = $input->member->email;
if ($email === null || $email === '') {
return StepResult::skip('no_email');
}
$tag = (string) $input->step->config['tag'];
if (! $this->crm->tag($email, $tag)) {
throw CouldNotTagInCrm::rejected($input->member, $tag);
}
return StepResult::continue(['email' => $email, 'tag' => $tag]);
}
public function describe(StepInput $input): string
{
return 'Would tag the CRM contact as ' . $input->step->config['tag'];
}
}
StepInput carries the run, the step definition (id, type, config),
the member, the trigger event, the matched event after a wait (or null),
and now.
Return one of:
| Result | Effect |
|---|---|
StepResult::continue(array $output = []) | Records the step as succeeded and moves to the next step. |
StepResult::skip(string $reason) | Records the step as skipped, with the reason, and moves on. |
StepResult::branch(string $key, array $output = []) | Enters the first step of that branch. Only for step types that declare branches. |
StepResult::wait(CarbonImmutable $until, ?string $event = null, int $count = 1, array $output = []) | Parks the run until $until, or until $event happens $count times first. |
StepResult::end(WorkflowRunStatus $status, ?string $reason = null) | Finishes the run. |
$output is stored on the step and shown under Output in the run timeline,
so include what someone debugging the run needs.
Failures
A step that throws marks the step and the run Failed, and the run timeline
shows the exception's class and message. Operators can fix the cause and use
Retry from this step, which calls execute() again, so keep it safe to
repeat.
For expected failures caused by configuration, such as a record that was
deleted, throw a subclass of Allegro\Exceptions\WorkflowStepException. It
implements ShouldntReport, so it stays out of error reporting. Any other
exception is reported, since it means a bug or an outage.
Dry runs
The Test panel and test-workflow-tool never call execute(). They call
describe() and show its sentence with the outcome Would run, so
describe() must not have side effects. A branching package step can't be dry
run yet: the Test panel shows it as Would fail.
Related
- Audience Member Sync, another package extension point.
- Workflow Events for how operators use events.