Skip to main content

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],
));
ArgumentNotes
audienceMemberIdThe member the event is about. When null, the engine looks up the member signed in on deviceId. Events with neither are dropped.
propertySlugThe property the event happened on. Workflows limited to some properties only enroll and resume on events from those properties.
originServer for something a member just did, such as a webhook for their donation. Wrap bulk work in asSystem() instead (below).
payloadThe 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'] ?? '…');
}
}
MethodReturns
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:

TypeWrites
text, textareaA string.
numberAn integer, bounded by the field's min and max.
booleantrue or false.
selectOne of the field's options ([['value' => 'a', 'label' => 'A'], …]).
eventAn event name, with the event catalog as suggestions.
email_templateA workflow email's id.
segmentA programmatic segment's id.
user_attributeA 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_groupsTargeting 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:

ResultEffect
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.