Skip to content

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

php
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_step value in the step's config, if the key is present (null ends 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, or Marketing Update. The subject gets the same merge tags as the body, unescaped.
  • Sender. config.from_email, config.from_name, and config.reply_to, falling back to the defaults.* 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. The List-Unsubscribe header points at its one-click endpoint (/marketing/preferences/{token}/unsubscribe) with List-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 as customer;
  • 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.

php
$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:

json
{
    "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 with config.secret, or, only when the step's URL is odden-marketing.webhooks.outbound_url, with odden-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 (except Host, which is ignored).

To verify a request on the receiving side:

php
$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}, status success, with the response_code;
  • a connection error, a timeout, or a 408, 429 or 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 as Webhook {method} to {url} with status failed and 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 MarketingSmsMessage with the status skipped and the error No SMS provider configured. Nothing is sent, nothing is logged as delivered, and the workflow step log says Skipped SMS (No SMS provider configured).
  • A provider bound: bind your implementation ($this->app->bind(SmsProvider::class, MyProvider::class)). Its send(string $to, string $body): string returns the provider's message id and the message is recorded as sent with that id (an exception from it records failed with the message). delivered is left for a provider's delivery receipts to set; the package does not process them yet.
  • No consent: recorded as skipped with No 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

php
use Odden\Marketing\Actions\EnrollContactInWorkflowAction;

$enrollment = app(EnrollContactInWorkflowAction::class)->execute($workflow, $contact);

execute(MarketingWorkflow $workflow, Contact $contact): ?WorkflowEnrollment:

  • returns null if the workflow is inactive or has no steps;
  • returns the contact's existing enrollment if it's still running (active or paused), or if it has finished (completed, exited or failed) and the workflow's allow_reenrollment is off (the default);
  • restarts a finished enrollment at the lowest-numbered step when allow_reenrollment is on;
  • otherwise creates an active enrollment 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):

bash
php artisan marketing:process-workflows

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

bash
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:

json
{
    "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.