Plugin SDK vs Workflows vs Webhooks
A decision guide for choosing between the Plugin SDK, the Workflow Engine, and Webhooks when extending Signals.
Overview
Signals has three first-class ways to extend behaviour. They share one event catalogue — they differ in who writes the extension, where it runs, and what it is allowed to do next.
The Plugin SDK is trusted in-process PHP. A developer ships a Composer package with a signals.yaml manifest; the class extends App\Sdk\PluginBase and registers through PluginRegistrar (~40 registration methods). The framework mediates every boundary: exact-hostname HTTP allowlist, plugin_* table confinement, no raw Eloquent against core models, and no plugin-shipped Blade views. UI is slots, nav pages, and palette commands. listen() attaches synchronous catalogue listeners that auto-disable the plugin after repeated failure and never fire during Verbs replay. Plugins also extend the other two mechanisms: workflowHandler() adds workflow action types, and webhookEvent() / event(..., webhook: true) adds webhook-subscribable events.
The Workflow Engine is the admin/no-code visual editor at /workflows. A workflow has one of four trigger types — manual, schedule (cron), domain_event, or an inbound HMAC-verified POST /api/v1/workflows/{workflow}/inbound/{credentialKey} — then an ordered graph of the 16 core action handlers (context.set, approvals.request, log, activities.create, communications.send, notifications.dispatch_internal, event.fire, external.http, webhook.signed, rentals.change_status, rentals.convert_to_order, rentals.allocate_asset, rentals.quick_allocate, documents.generate, invoices.generate_from_rental, exports.start) plus any plugin-registered handler types. Conditional branching, delays, and first-class approvals (suspend / resume) are built in. External systems resume a suspended run with a one-time token at POST /api/v1/workflow-resumes. Operator-authored definitions cannot contain inline arbitrary code. Execution is queued by default; a definition explicitly configured as synchronous runs its immediately eligible steps inline when it passes WorkflowSynchronousPolicy, which rejects handlers whose declared capabilities match the installation's workflows.execution.synchronous_disallowed_capabilities list. Delays and approvals can still suspend either kind of run for days.
Webhooks keep custom code off the Signals process. Outbound: subscribe a URL and secret to any of 245+ catalogued events (wildcard * supported), including workflow.run_completed, workflow.run_failed, and workflow.run_cancelled. Deliveries carry HMAC-SHA256 in X-Signals-Signature, retry six times (1m / 5m / 30m / 2h / 6h / 12h), skip retry on 4xx, auto-disable after 18 consecutive failures, and write a WebhookLog per attempt (manual redelivery from the API Console). The delivery intent is written in the same transaction as the triggering change. Inbound, independently of workflows: an InboundWebhookSource slug receives HMAC (sha256 or sha512) at POST webhooks/receive/{source} and fires InboundWebhookReceived — a plugin or custom listener must act on it. No application code runs for outbound consumers.
Decision table
| Axis | Plugin SDK | Workflow Engine | Webhooks |
|---|---|---|---|
| Where it runs | In-process, same PHP workers as core | In-app engine; visual editor at /workflows |
Outbound HTTPS to the subscriber; inbound receiver only accepts and emits an event |
| Who builds it | Developers — Composer packages with signals.yaml |
Operators / admins — no-code editor | Integrators on any stack; Signals ships no consumer code |
| Custom UI | Slots, nav pages, palette — config-driven, no plugin-shipped Blade | The workflow editor itself; no custom operator screens | None in Signals (the consumer's own app) |
| Custom code | Trusted PHP via PluginBase + PluginRegistrar |
No inline code in operator definitions — 16 core handlers plus plugin-registered handler types | None in-app for outbound; inbound needs a listener elsewhere |
| Sync vs async | Synchronous listen() on the publishing request |
Queued by default; eligible definitions explicitly configured as synchronous run immediate steps inline; delays and approvals can suspend either mode |
Async queued delivery (afterCommit) |
| Inbound trigger | No dedicated inbound HTTP; listen to InboundWebhookReceived or catalogue events |
HMAC-verified POST /api/v1/workflows/{workflow}/inbound/{credentialKey} |
HMAC-verified POST webhooks/receive/{source} (fires an event only) |
| Approvals integration | Indirect — code can request or react; not a suspend/resume primitive | First-class approvals.request suspends the run until a decision; request changes is unavailable for workflow-originated requests |
Subscribe to catalogued approval events, including approval.changes_requested; no in-app orchestration |
| Failure / retry | Isolated from the business action; consecutive hook failures auto-disable the plugin | Per-handler retry declarations; suspend/resume rather than a fixed backoff | 6 attempts, 1m/5m/30m/2h/6h/12h; 4xx no retry; auto-disable at 18 consecutive failures |
Where they overlap
A rental changing status (rental.status_changed) is visible to all three. That event is in config/events.php with plugin, workflow, and webhook flags, so EventRegistry offers it to plugin listen(), to workflow domain_event triggers, and to WebhookEventRegistry subscriptions.
| Mechanism | What you do with rental.status_changed |
|---|---|
| Plugin SDK | $registrar->listen('rental.status_changed', …) — run PHP in-process (write a plugin_* row, call an allowlisted host, update a custom field through the data facade). |
| Workflow Engine | Trigger on domain_event = rental.status_changed, then chain fixed handlers: notify, request approval, change a further status, call external.http. |
| Webhooks | Subscribe the URL to rental.status_changed (or *). Signals POSTs the signed payload; your server decides what happens. |
Mostly, the differentiator is not “can it see this event”. All three key off the same catalogue (
config/events.phpviaEventRegistry). Choose based on what happens next: trusted PHP and UI (plugin), a no-code graph that may wait on humans (workflow), or a signed POST to a system you operate (webhook).
The workflow engine is the exception, and it matters. For plugins and webhooks, catalogue membership is the capability. For workflows it is not: seeing an event in the editor and being able to start a run from it are two different things. rental.status_changed happens to be both, but many catalogued events are visible-only. See “What a workflow can actually trigger on” below before assuming an event is usable.
A common composition uses all three together — a plugin exportable model, the exports.start workflow action, and the export.completed webhook — to mirror Signals data into an external database.
When to use the Plugin SDK
- You need PHP that core does not ship. New workflow action types, webhook-visible events, rental transition validators, rate modifiers, document resolvers, channel drivers, or any other
PluginRegistrarseam. - You need UI inside Signals. Dashboard/detail slots, plugin nav pages, palette commands, or DataTables — without forking Blade in core.
- You must mutate Signals data under guard. Custom-field writes,
Signals::operation(), or your ownplugin_*tables. Core Eloquent is not the plugin API. - The reaction must be synchronous with the domain action (and must stay off the Verbs replay path). A listener that stamps an external id on the same request is a plugin; a fire-and-forget POST is a webhook.
When to use the Workflow Engine
- An operator should author and change the process without a deploy: “when this event, if this condition, then these steps.”
- The process waits. Approvals (
approvals.request), delays, or an external resume via one-time token atPOST /api/v1/workflow-resumes— runs may sit suspended for days. - The steps are already in the 16-handler set (status changes, allocation, documents, invoices, notifications, signed outbound HTTP, exports). If you need a 17th behaviour, write a plugin handler rather than stretching
external.http. - The trigger is inbound but the next steps are Signals-native. HMAC
POST /api/v1/workflows/{workflow}/inbound/{credentialKey}starts a run that can change a rental, generate a document, or request approval — unlike the generic inbound webhook receiver, which only emits an event.
What a workflow can actually trigger on
A catalogued event is offered to plugin listen() and to webhook subscriptions on the strength of
its plugin / webhook flags alone. The workflow engine asks a further question, because starting a
run means resolving a subject, authorising the service principal against it, redacting its payload
and pinning a contract — none of which the catalogue entry describes.
That capability comes from a per-family capability provider (app/Services/Workflows/Events/).
Each provider owns a set of events and answers for them: which it supports, whether they can trigger
or be emitted, how to resolve the subject, how to authorise, what to redact, and how to adapt the
audit fact into an envelope payload. An event with no registered provider is catalogue-only —
visible in the editor, permanently disabled, never executable. There is no generic fallback, because
a fallback would have to guess at authorisation and payload shape.
So a workflow-flagged event is in one of these states:
| State | Meaning |
|---|---|
| trigger | A registered provider accepts it. Selectable in the editor; a published workflow starts runs from it. |
| catalogue | Visible in the editor and disabled. Either no family provider covers it yet, or it has no stable subject to run against. |
| emit | A workflow's Fire Event step may emit it. Only complete workflow.custom.* events. |
| none | Not offered to workflow authoring at all — system_protected events, which are also absent from every other discovery accessor. |
Two consequences worth knowing before you plan an integration:
- No domain fact is emittable.
invoice.paid,approval.completed,shortage.detectedand every other domain event describe something that already happened, and a workflow must never be able to fabricate one. The Fire Event step is limited toworkflow.custom.*signals. To make a workflow cause a domain change, use the typed handler for it — the handler enforces policy and the real event is emitted as a side effect. If no handler exists for what you need, write a plugin handler; do not reach for Fire Event. - Publishing pins the provider. A published workflow records the provider key, its contract version and the payload version. If the provider is later removed, or its contract moves on, the run is refused with a reason code at publish time and again at runtime — rather than running against a payload shape it was never authored for.
If the event you want is catalogue-only, the fix is to add a provider for its family, not to loosen
the trigger filter. docs/development/workflows.md describes what a family needs.
When to use Webhooks
- The work lives in another system. ERP, warehouse, BI, or a custom app that should learn about Signals changes without running PHP inside Signals.
- You want at-least-once, signed, logged delivery. Transactional outbox, HMAC
X-Signals-Signature, six-attempt backoff, per-attemptWebhookLog, and manual redelivery — without writing a plugin. - You subscribe broadly. Wildcard
*expands as the catalogue grows, including plugin-registered names andworkflow.run_completed/failed/cancelled. - An external system must push into Signals, but you do not want a workflow inbound credential. Register an
InboundWebhookSourceandPOST webhooks/receive/{source}. That path only firesInboundWebhookReceived; pair it with a pluginlisten()(or a custom listener) to do anything afterwards.
How they compose
The three mechanisms are designed to stack, not compete.
| Composition | How |
|---|---|
| Plugin → Workflow | workflowHandler() registers a new action type; it appears in the /workflows editor under the plugin's vendor-prefixed identifier. |
| Plugin → Webhooks | webhookEvent() or event($name, ['webhook' => true]) adds a catalogue name that outbound subscriptions (including *) can receive. |
| Workflow → Webhooks | webhook.signed sends a governed signed POST from a step; completing/failing/cancelling a run also publishes workflow.run_* for ordinary webhook subscribers. |
| Workflow → Plugin | event.fire publishes a custom catalogue event that plugin listen() handlers and webhook subscribers can both see. |
| Webhooks → Plugin | Generic inbound POST webhooks/receive/{source} fires InboundWebhookReceived; a plugin listener is the in-process reaction. |
| Webhooks → Workflow | Prefer the workflow inbound trigger when the POST should start a run. Use the generic inbound receiver when multiple consumers (plugin + others) should see the same payload. |
Credential-vaulted external.http on a workflow is for governed, operator-configured HTTPS. Plugin PluginHttpClient is for package-owned integrations with a manifest network[] exact-host allowlist. Outbound webhooks are for subscriber-owned URLs you do not ship code for.
Related
| Page | Why |
|---|---|
| Plugin SDK | Hub for App\Sdk, lifecycle, and the plugin guide set |
| Workflows | Engine, triggers, and handler registration |
| Workflows (admin) | Authoring, publishing, and the /workflows UI |
| Webhooks | Outbound catalogue, signatures, retries, inbound receiver |
| API Console | Managing outbound and inbound webhook sources |
| Event Registry | Shared catalogue and plugin / workflow / webhook flags |
| Webhook Event Registry | Outbound discovery metadata and metering weights |
| Workflow Handler Registry | How core and plugins register action types |
| Approvals | The approval engine workflows suspend on |