Paid add-on. Marketing is licensed separately from the open-source packages. See pricing
Workflows
Build drip and automation workflows from ordered steps, enroll contacts from triggers, code, or an inbound webhook, and advance delays with the scheduled command.
A workflow is a numbered sequence of steps that runs for each enrolled contact: wait, branch on a condition, update the contact, create a deal or task, call a webhook, and so on. Contacts are enrolled by triggers (a form submission, an event attendance), by your code, or by an inbound webhook. Steps run immediately until the workflow reaches a delay; the marketing:process-workflows command resumes enrollments whose delay has passed.
Models
| Model | Table | Purpose |
|---|---|---|
Odden\Marketing\Models\MarketingWorkflow |
odden_marketing_workflows |
The workflow: name, description, trigger_type, trigger_config, is_active (default true), enrollments_count, completed_count, created_by_id. |
Odden\Marketing\Models\WorkflowStep |
odden_marketing_workflow_steps |
One step: workflow_id, step_number, type, config (array), next_step_on_true, next_step_on_false. |
Odden\Marketing\Models\WorkflowEnrollment |
odden_marketing_workflow_enrollments |
A contact's run through a workflow: current_step_id, status, next_run_at, enrolled_at, completed_at. |
Odden\Marketing\Models\WorkflowLog |
odden_marketing_workflow_logs |
One row per executed step: action_taken, status (success, skipped, or failed), details. |
Relations: $workflow->steps (ordered by step_number), $workflow->enrollments, $workflow->creator, $enrollment->workflow, $enrollment->contact, $enrollment->currentStep, $enrollment->logs. The package also adds workflowEnrollments to Contact.
Odden\Marketing\Enums\WorkflowEnrollmentStatus has the cases Active, Paused, Completed, and Exited. The package itself only sets active and completed. Only active enrollments run, so you can pause or exit one by updating its status.
Building a workflow
use Odden\Marketing\Enums\WorkflowStepType;
use Odden\Marketing\Enums\WorkflowTriggerType;
use Odden\Marketing\Models\MarketingWorkflow;
$workflow = MarketingWorkflow::create([
'name' => 'Demo request nurture',
'trigger_type' => WorkflowTriggerType::FormSubmitted,
'trigger_config' => ['form_id' => $form->id],
]);
$workflow->steps()->createMany([
['step_number' => 1, 'type' => WorkflowStepType::CreateSalesTask, 'config' => ['title' => 'Call new demo request', 'due_in_hours' => 4]],
['step_number' => 2, 'type' => WorkflowStepType::Delay, 'config' => ['delay_minutes' => 60 * 24 * 2]],
['step_number' => 3, 'type' => WorkflowStepType::Condition, 'config' => ['property' => 'lead_score', 'operator' => '>=', 'value' => 50], 'next_step_on_true' => 4, 'next_step_on_false' => 5],
['step_number' => 4, 'type' => WorkflowStepType::UpdateContact, 'config' => ['lifecycle_stage' => 'sales_qualified_lead', 'next_step' => null]],
['step_number' => 5, 'type' => WorkflowStepType::Webhook, 'config' => ['url' => 'https://hooks.example.com/odden', 'secret' => 'shared-secret']],
]);When someone submits the form, step 1 creates a task and step 2 schedules the rest for two days later. Then step 3 sends contacts with 50 or more points to step 4, which marks them SQL and ends the workflow, and everyone else to step 5.
Step order
Steps are addressed by step_number, not by id. After a step runs, the enrollment moves to:
- the
next_stepvalue in the step'sconfig, if the key is present (nullends the workflow); - otherwise
step_number + 1.
If no step has that number, the enrollment is completed. Conditions use next_step_on_true and next_step_on_false instead, and delays always continue with step_number + 1.
Step types
Odden\Marketing\Enums\WorkflowStepType:
| Case | Value | config keys |
What it does |
|---|---|---|---|
SendEmail |
send_email |
template_id, subject (default the template's subject, then Marketing Update), body, topic_id, from_email, from_name, reply_to |
Renders the template's body_html (or body) with the contact's merge tags (values HTML-escaped) and queues it to the contact. See the email step. |
SendSms |
send_sms |
message (default Marketing Update), requires_consent (default true) |
Sends only through an SmsProvider you bind; the package ships none. Without one the message is recorded as skipped (No SMS provider configured) and nothing is sent. See SMS needs a provider. The log status is success when a provider accepted the message and skipped otherwise (no phone, no consent, no provider). |
Delay |
delay |
delay_minutes (default 60, minimum 1) |
Moves to the next step and sets next_run_at that many minutes ahead. If there's no next step, completes the enrollment. |
Condition |
condition |
property (default lead_score), operator (default >=), value (default 50) |
Compares a contact value and branches. See conditions. |
UpdateContact |
update_contact |
lifecycle_stage |
Sets the contact's lifecycle stage. It's the only supported field. |
AssignOwner |
assign_owner |
owner_id |
Sets the contact's owner, defaulting to the first user. |
CreateDeal |
create_deal |
deal_name, amount (default 10000), pipeline_id, stage_id |
Creates an open deal (requires getodden/crm-sales) owned by the contact's owner and associates it with the contact. Without pipeline_id, uses the first pipeline and its first stage. Logged as skipped when there's no pipeline. |
CreateSalesTask |
create_sales_task |
title (default High-Priority Lead Follow-up), due_in_hours (default 2) |
Logs a task on the contact. |
InternalNotification |
internal_notification |
message |
Logs an Internal Alert: {message} task on the contact. No notification is sent. |
Webhook |
webhook |
url, method (default POST), secret, headers |
Calls a URL. See outbound webhooks. |
Every step writes a WorkflowLog row. Each case has label() and getLabel().
The email step
send_email uses the same delivery as campaigns: it queues an Odden\Marketing\Mail\MarketingMessageMailable on the odden-marketing.mail queue, through the odden-marketing.mail.mailer mailer, so a queue worker must be running (see Sending mail).
- Subject.
config.subject, or the template's subject, orMarketing Update. The subject gets the same merge tags as the body, unescaped. - Sender.
config.from_email,config.from_name, andconfig.reply_to, falling back to thedefaults.*config values. - Body. HTML plus a plain-text alternative generated from it. There's no open pixel or click tracking: workflow emails don't create campaign recipients.
- Unsubscribe.
{{unsubscribe_url}}points at the contact's preference center. TheList-Unsubscribeheader points at its one-click endpoint (/marketing/preferences/{token}/unsubscribe) withList-Unsubscribe-Post: List-Unsubscribe=One-Click. - Compliance footer. Like campaigns, a body that lacks the preference center link or your postal address gets a footer with them. While no postal address is configured, the email step doesn't run: the enrollment waits at it (still due) and continues once the address is set, and a warning is logged.
The step skips the email when the contact's address is empty, unsubscribed or bounced, or on the suppression list, or, if config.topic_id is set, unsubscribed from that topic. A skipped email is logged with status skipped and Skipped email (unsubscribed or suppressed): {subject}, and no activity is logged. Otherwise the step logs a completed Workflow Email: {subject} email activity on the contact and a Sent email: {subject} entry once the message is queued. Either way, the workflow moves on to the next step.
Conditions
The property is read from the contact:
lead_score: the score;lifecycle_stage: the stage value, such ascustomer;- anything else: the custom property of that name.
Operators >=, >, <= and < compare as integers. != compares as strings. Any other operator, such as = or ==, tests string equality.
When the condition passes, the enrollment moves to next_step_on_true, or step_number + 1 if that's empty. When it fails, it moves to next_step_on_false, or completes the enrollment if that's empty.
$workflow->steps()->create([
'step_number' => 1,
'type' => WorkflowStepType::Condition,
'config' => ['property' => 'plan', 'operator' => '=', 'value' => 'pro'],
'next_step_on_true' => 2,
]);Outbound webhook step
A webhook step queues an HTTP call that sends this JSON (methods GET, POST, PUT, PATCH or DELETE; anything else is sent as POST) with a three-second connect and five-second total timeout:
{
"workflow_id": 3,
"workflow_name": "Demo request nurture",
"contact": {
"id": 17,
"email": "ada@example.com",
"first_name": "Ada",
"last_name": null,
"lead_score": 15,
"lifecycle_stage": "lead"
},
"timestamp": "2026-10-02T16:45:00+00:00"
}Headers:
X-Odden-Signature: HMAC-SHA256 of the JSON body, keyed withconfig.secret, or, only when the step's URL isodden-marketing.webhooks.outbound_url, withodden-marketing.webhooks.secret. Otherwise the request is unsigned.X-Odden-Workflow-ID: the workflow id.User-Agent:Odden-RevOps-Webhook/1.0.- Anything in
config.headers, which can override the above (exceptHost, which is ignored).
To verify a request on the receiving side:
$expected = hash_hmac('sha256', $request->getContent(), 'shared-secret');
abort_unless(hash_equals($expected, (string) $request->header('X-Odden-Signature')), 401);The call is not made by the step itself. The step logs Queued webhook {method} to {url} and dispatches an encrypted Odden\Marketing\Jobs\SendWorkflowWebhookJob (it carries the contact's details and your custom headers) on the odden-marketing.mail connection and queue, then the workflow moves straight on to the next step: it never waits for the receiver, and a failing receiver never stops or holds up the enrollment. A queue worker must be running (see Sending mail). The job is dispatched after the surrounding database transaction commits, if there is one.
The job logs the outcome on the enrollment:
- a 2xx answer:
Webhook {method} to {url}, statussuccess, with theresponse_code; - a connection error, a timeout, or a
408,429or 5xx answer: retried after 1 minute and then 5 minutes (three attempts in all). Each failed attempt that will be retried is logged as... failed (attempt n of 3): will retry, and the last one asWebhook {method} to {url}with statusfailedand the response code (500 for a connection error); - any other answer (such as a 404): logged as failed straight away, without retrying.
With the sync queue driver the job runs in the same process and there are no retries.
The URL must resolve to public addresses only: private, loopback, link-local (including the 169.254.169.254 cloud metadata endpoint), carrier-grade NAT and reserved ranges are refused, for IPv4 and IPv6. It is checked when the step runs (a refused URL is logged as Blocked webhook ... with status failed and nothing is queued) and again by the job just before the call, since DNS may have changed in between (logged the same way, and not retried). The request is pinned to the checked address and follows no redirects. A step without a url is logged as Skipped webhook (no URL). To call a receiver on your own network, list its host in ODDEN_MARKETING_WEBHOOK_ALLOWED_HOSTS (comma-separated, odden-marketing.webhooks.allowed_hosts).
SMS needs a provider
The package ships no SMS provider, so by default the SendSms step does not send a text message. DispatchSmsAction checks that the contact has a phone number and sms_consent, then asks the bound Odden\Marketing\Contracts\SmsProvider to send it.
- No provider bound (the default): the message is recorded as a
MarketingSmsMessagewith the statusskippedand the errorNo SMS provider configured. Nothing is sent, nothing is logged as delivered, and the workflow step log saysSkipped SMS (No SMS provider configured). - A provider bound: bind your implementation (
$this->app->bind(SmsProvider::class, MyProvider::class)). Itssend(string $to, string $body): stringreturns the provider's message id and the message is recorded assentwith that id (an exception from it recordsfailedwith the message).deliveredis left for a provider's delivery receipts to set; the package does not process them yet. - No consent: recorded as
skippedwithNo SMS consent, with or without a provider.
Consent itself is not verified by the package: a ticked consent box on a form is kept as sms_consent_requested_at on the contact, and sms_consent is only set by something that confirmed the number. See forms.
Triggers
Odden\Marketing\Enums\WorkflowTriggerType describes what enrolls contacts:
| Case | Value | Enrolls automatically when | trigger_config filter |
|---|---|---|---|
FormSubmitted |
form_submitted |
A form submission resolves a contact, or an external lead is ingested | form_id (ignored for external leads) |
AssetDownloaded |
asset_downloaded |
A contact downloads a gated asset through a signed link | asset_id |
EventAttended |
event_attended |
A registration is marked attended | event_id |
CustomEvent |
custom_event |
A custom behavioral event is tracked for a contact | event_name (case-insensitive) |
ContactCreated |
contact_created |
A contact is created through CreateContactAction (which dispatches Core's ContactCreated event); contacts saved with Contact::create() directly don't fire it |
lifecycle_stage (a LifecycleStage value), optional |
ListJoined |
list_joined |
A contact is added to an audience list, with CrmList::addMember() or when an active list syncs a new member |
list_id |
LeadScoreReached |
lead_score_reached |
A scoring event moves the contact's score from below the threshold to at or above it (it fires once per crossing, not on every later event) | score, the threshold. Required: a workflow without it never fires |
InboundWebhook |
inbound_webhook |
Enrolled explicitly through the enrollment webhook, not by an event | |
Manual |
manual |
Enrolled explicitly, in code or from the admin, not by an event |
A trigger without its filter key matches every form, asset, event, event name, list, or lifecycle stage (except lead_score_reached, which needs its score). Only active workflows are triggered. For inbound_webhook and manual workflows, enroll contacts yourself.
Enrolling contacts in code
use Odden\Marketing\Actions\EnrollContactInWorkflowAction;
$enrollment = app(EnrollContactInWorkflowAction::class)->execute($workflow, $contact);execute(MarketingWorkflow $workflow, Contact $contact): ?WorkflowEnrollment:
- returns
nullif the workflow is inactive or has no steps; - returns the contact's existing enrollment if it's still running (
activeorpaused), or if it has finished (completed,exitedorfailed) and the workflow'sallow_reenrollmentis off (the default); - restarts a finished enrollment at the lowest-numbered step when
allow_reenrollmentis on; - otherwise creates an
activeenrollment at the lowest-numbered step;
and, when it enrolled or restarted the contact, increments enrollments_count and runs steps immediately until a delay or the end.
A contact has at most one enrollment per workflow: a unique index on workflow_id and contact_id makes two triggers arriving at once enroll the contact once (the second gets the first's enrollment). Re-enrolling reuses that row, so its workflow logs hold every run. Turn Allow Re-enrollment on in the workflow form (or set allow_reenrollment) for workflows a contact should go through again, for example on every form submission. wouldEnroll($workflow, $contact): bool tells you whether execute() would start the workflow now. Because steps run synchronously, the request or job that enrolls the contact also performs the steps up to the first delay; webhook steps only queue their HTTP call. When the last step finishes, the enrollment is set to completed and the workflow's completed_count is incremented.
The action also has triggerFormWorkflows(MarketingForm $form, Contact $contact) and triggerCustomEventWorkflows(string $eventName, Contact $contact), which enroll the contact in every matching workflow.
Processing delays
Schedule the command so that enrollments resume after their delays (see scheduling):
php artisan marketing:process-workflowsIt loads every active enrollment with a next_run_at in the past and runs its current step (and the steps after it, up to the next delay). It prints the number of enrollments advanced. Odden\Marketing\Actions\ProcessDueWorkflowsAction::execute(): int does the same from code.
Each step is claimed before it runs: ExecuteWorkflowStepAction first calls $enrollment->claimStep($stepId), a single conditional UPDATE that only matches while the enrollment is active, still on that step, and due (next_run_at set), and clears next_run_at. If two workers pick up the same enrollment at once (overlapping scheduler runs, or the scheduler racing the immediate run after enrolment), only one sends the step's email, SMS, or webhook; the other does nothing. Completing an enrollment is claimed the same way, so completed_count goes up once. Because of this, ExecuteWorkflowStepAction::execute() does nothing for an enrollment whose next_run_at is null; set it to now() to run one by hand.
A step that throws doesn't strand the enrollment. The failure is reported and logged (Step failed (attempt n of 3), with the error in details), and the enrollment stays on the step and runs again after 5, then 10 minutes. After the third failure the enrollment's status becomes failed. One enrollment failing never stops the others in the same run.
A delay is only as precise as your schedule: with the command every five minutes, a 60-minute delay resumes 60 to 65 minutes later.
Enrollment webhook
External systems (Zapier, Segment, Stripe, your own product) can enroll a contact over HTTP.
| Method | URI | Route name | Auth |
|---|---|---|---|
POST |
/api/marketing/workflows/{workflow}/enroll |
odden.marketing.workflows.enroll-webhook |
ODDEN_MARKETING_API_TOKEN, rate limited by odden-api. CSRF exempt. |
{workflow} is the workflow's id or its exact name (URL-encoded). The workflow's trigger type doesn't matter. Send the token as described in API tokens.
curl -X POST https://your-app.test/api/marketing/workflows/7/enroll \
-H "Authorization: Bearer $ODDEN_MARKETING_API_TOKEN" \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"email": "trial@example.com", "first_name": "Tess", "company": "Initech", "trigger_event": "stripe.trial_started"}'| Field | Rules |
|---|---|
email |
Required, valid email, max 255. Lowercased. |
first_name, last_name, company |
Optional strings, max 255. |
phone |
Optional string, max 50. |
trigger_event |
Optional string, max 100. Used in the timeline entry; defaults to inbound_webhook. |
properties |
Optional object of custom properties. Scalar values are saved on the contact (overwriting existing values with the same key); nested arrays and objects are ignored. |
The endpoint loads or creates the contact (new contacts are lead / new), fills empty name and phone fields, saves the scalar properties before enrolling (so the workflow's conditions can read them), associates the company with that exact name (creating it if needed), logs a completed Webhook Enrollment: {workflow} activity, and enrolls the contact. It returns 201:
{
"success": true,
"workflow_id": 7,
"workflow_name": "Trial onboarding",
"contact_id": 52,
"enrollment_id": 88,
"status": "active",
"message": "Contact successfully enrolled in workflow."
}status is the enrollment status after the first steps ran, so it's completed for a workflow without delays. If the workflow has no steps, enrollment_id is null, status is pending_steps, and message is Contact registered; workflow has no defined steps yet. An unknown workflow returns 404 and an inactive one 422, both with success: false and a message.
Journey view
The package ships a odden-marketing::workflow-journey Blade view that renders a workflow's stats and steps. It expects a $workflow variable and is intended for admin panels such as the Filament plugin.