Sequences, templates, and playbooks
Run multi-step outbound cadences, render email templates with merge tags, and capture qualification answers with playbooks.
Sequences (also called cadences) take a contact through a series of timed steps: emails, calls, LinkedIn touches, and tasks. Email templates provide the content for email steps. Playbooks are qualification scripts whose answers are saved onto a deal or contact.
Sequences
An Odden\Sales\Models\SalesSequence has a name, an optional description, is_active (defaults to true), an optional user_id (the author), and a steps array. Each step is an array with:
| Key | Notes |
|---|---|
step |
The step number, for your reference. Steps run in array order. |
type |
email, call, linkedin, or anything else (treated as a task). |
delay_days |
Days to wait before this step. |
title |
Used in the activity title. |
template_id |
A SalesEmailTemplate ID. Required for email steps to send anything; see Email steps. |
use Odden\Sales\Models\SalesSequence;
$sequence = SalesSequence::create([
'name' => 'Outbound Q3',
'user_id' => $rep->id,
'steps' => [
['step' => 1, 'type' => 'email', 'delay_days' => 0, 'title' => 'Intro email', 'template_id' => $template->id],
['step' => 2, 'type' => 'call', 'delay_days' => 2, 'title' => 'Intro call'],
['step' => 3, 'type' => 'email', 'delay_days' => 3, 'title' => 'Break-up email'],
],
]);
$sequence->totalSteps(); // 3The first step's delay_days counts from enrollment. Each later step's delay_days counts from the day the previous step was completed. Due dates are whole dates, not times.
Enrolling contacts
use Odden\Sales\Actions\EnrollContactInSequenceAction;
$enrollment = app(EnrollContactInSequenceAction::class)->execute($contact, $sequence, $rep->id);execute(Contact $contact, SalesSequence $sequence, int|string|null $enrolledById = null) creates or updates the SalesSequenceEnrollment for that contact and sequence. Enrolling the same contact again restarts it at step 1. The enrollment gets:
current_step = 1andstatus = 'active',next_step_due_at= today plus the first step'sdelay_days,enrolled_by_id= the given ID or the authenticated user. Activities created by the sequence use this as their creator.
If the contact's lead_status is New, it is changed to InProgress (quietly, without model events).
$contact->salesSequenceEnrollments lists a contact's enrollments, newest first. $sequence->enrollments lists a sequence's.
Enrollment status
status is a plain string:
active: still running.completed: every step has been done.unenrolled: removed early.
Contacts are unenrolled automatically when:
- an associated deal is won or lost (Deals),
- they book a meeting through a meeting link,
- their
lead_statusisUnqualifiedorBadTimingwhen the next step is processed.
To remove a contact yourself, update the enrollment's status to unenrolled.
Merging contacts
When two contacts are merged, Sales moves the secondary contact's enrollments and meeting bookings to the primary contact, inside the merge's transaction.
A contact has one enrollment per sequence, so if both contacts are enrolled in the same sequence only one enrollment is kept and the other is deleted:
- A
completedorunenrolledenrollment wins over anactiveone, so a merge never restarts outreach that already finished or was stopped. - Otherwise the enrollment with the higher
current_stepwins. - Otherwise the older enrollment wins.
Processing due steps
Steps are only executed by Odden\Sales\Actions\ProcessCadencesAction, usually through the Artisan command:
php artisan sales:process-cadencesThe command prints a table of counts. The package does not schedule it; see Scheduling.
The action picks up active enrollments whose next_step_due_at is today or earlier (or empty), skipping enrollments in inactive sequences. For each one:
Email steps queue the step's rendered template to the contact. See Email steps.
Call, LinkedIn, and task steps create a pending call, linkedin, or task activity on the contact, due now, titled {type label}: {title}. The enrollment then waits. On each later run, once the rep has marked that activity as completed, the contact is marked as contacted and the enrollment advances. While the activity is still pending nothing happens.
After the last step the enrollment is set to completed.
execute() returns counts:
use Odden\Sales\Actions\ProcessCadencesAction;
$stats = app(ProcessCadencesAction::class)->execute();
// ['processed' => 1, 'emails_sent' => 1, 'tasks_created' => 0, 'unenrolled' => 0, 'completed' => 0]emails_sent counts emails queued for delivery, and emails_skipped counts email steps that were not sent. The command's table shows both, as "Emails Queued for Delivery" and "Emails Skipped (no address or template)".
Each step runs at most once. Advancing the enrollment is an atomic update that only succeeds while the enrollment is still on that step, and the step's activity and email are created in the same database transaction, so running the command again, or two runs overlapping, never sends the same step twice. Completing a manual step is guarded the same way.
Email steps
When an email step is due, the action:
- Renders the step's template with
renderWithContext(), passing the contact and the enrollment's owner as the user. The owner is the user who enrolled the contact (enrolled_by_id), or the sequence'suser_idif there isn't one, so{{ sender.name }},{{ rep.email }}, and the other user tags refer to them. The subject goes throughTemplateParser::parse()and the body throughparseHtml(), so merge values are HTML-escaped in the body. - Queues an
Odden\Sales\Mail\SequenceStepMailto the contact's email address. The mailable implementsShouldQueue, so a queue worker must be running; it is dispatched after the database transaction commits. The mailer, queue connection, and queue come fromodden-sales.mail. - Logs a completed
emailactivity on the contact with the rendered subject as its title and the rendered HTML as its body. Its metadata holdssequence_id,sequence_enrollment_id,step,template_id, andto. - Marks the contact as contacted, changes a
Newlead status toInProgress, and advances the enrollment.
The email is sent from odden-sales.mail.from if set, otherwise from your app's mail.from. The owner's address is used as the reply-to. Set odden-sales.mail.sequences.send_as_owner to true to send from the owner's address and name instead; your mail provider must be allowed to send as those addresses.
A step is skipped, not sent, when the contact has no email address, the address is not valid, or the step has no template_id (or its template was deleted). The package never sends placeholder text. A skipped step logs a cancelled email activity titled Not sent: {title} whose body gives the reason, with skipped => true and skip_reason (missing_email, invalid_email, or missing_template) in its metadata. The contact is not marked as contacted, and the enrollment still advances so later steps run. ProcessCadencesAction::SKIP_REASONS maps each reason to its message.
Email templates
An Odden\Sales\Models\SalesEmailTemplate has name, subject, body_html, category (defaults to general), user_id, and is_shared (defaults to true).
Rendering with CRM context
renderWithContext(?Contact $contact = null, ?Deal $deal = null, ?Model $user = null, array $extra = []) returns ['subject' => ..., 'body_html' => ...] with merge tags replaced. Sequence email steps call it with the contact and the enrollment's owner as $user.
use Odden\Sales\Models\SalesEmailTemplate;
$template = SalesEmailTemplate::create([
'name' => 'Intro',
'subject' => 'Quick question, {{ contact.first_name }}',
'body_html' => '<p>Hi {{ contact.first_name }}, I work with teams like {{ company.name }}.</p>',
]);
$rendered = $template->renderWithContext(contact: $contact, deal: $deal, user: $rep);
$rendered['subject']; // "Quick question, Dana"
$rendered['body_html'];Tags use {{ path }} with dot notation, with or without spaces. Available paths:
| Prefix | Keys |
|---|---|
contact. |
id, first_name, last_name, name, full_name, email, phone, job_title, title, timezone, lead_status (label) |
company. |
id, name, domain, industry (from the contact's first company) |
deal. |
id, name, amount, currency, formatted_amount, stage, expected_close_date, days_in_stage |
user., sender., rep., owner. |
id, name, email (all four refer to the $user argument) |
Custom properties resolve too: {{ contact.renewal_tier }} reads the contact's renewal_tier property when there is no built-in key of that name. The same works for company. and deal.. Keys in $extra are merged in at the top level, so ['offer' => ['code' => 'Q3']] makes {{ offer.code }} available.
Tags that don't resolve become an empty string. deal.formatted_amount always uses a $ sign, whatever the deal's currency.
In body_html, merge values are HTML-escaped with Laravel's e(), so a contact named <b>Dana</b> or a company called R&D Labs appears as typed (<b>Dana</b>, R&D Labs) and can't inject markup. The subject is plain text, so values go into it unescaped; escape the subject yourself if you put it into HTML. Write the HTML you want in the template itself, not in merge values. Escaping happens once, when the template is rendered, so don't escape values before passing them in $extra or they will be escaped twice.
The parser is Odden\Sales\Services\TemplateParser, with parse(string $template, array $context = []) for plain text (values inserted as-is), parseHtml(string $template, array $context = []) for HTML (values escaped with e()), and buildContext(?Contact, ?Deal, ?Model $user, array $extra) if you want to use it on other strings.
Simple replacement
render(array $variables = []) replaces {{ key }} and {{key}} with the given strings, without CRM context. As with renderWithContext(), values are HTML-escaped in body_html and inserted as-is in the subject:
$template->render(['first_name' => 'Sam']);Playbooks
An Odden\Sales\Models\SalesPlaybook is a list of questions. Answers are written to custom properties on a deal or contact and summarized in a note.
| Attribute | Notes |
|---|---|
name |
|
slug |
Unique. |
category |
Defaults to qualification. |
framework |
Defaults to custom. The presets use bant and meddic. |
description |
|
questions |
List of ['id', 'label', 'type', 'options'?, 'target_property'?, 'help'?]. |
is_active |
Defaults to true. |
user_id |
The author. |
type and options describe how a UI should ask the question; the package does not validate answers against them.
Two presets return ready-to-create attribute arrays: SalesPlaybook::defaultBantPreset() (slug bant-qualification) and SalesPlaybook::defaultMeddicPreset() (slug meddic-enterprise).
use Odden\Sales\Actions\ExecuteSalesPlaybookAction;
use Odden\Sales\Models\SalesPlaybook;
$playbook = SalesPlaybook::create(SalesPlaybook::defaultBantPreset());
app(ExecuteSalesPlaybookAction::class)->execute(
target: $deal,
playbook: $playbook,
answers: [
'budget_status' => 'Allocated & Approved',
'target_timeline' => 'This Quarter (1-3 months)',
],
userId: $rep->id,
);
$deal->getProperty('qualification_budget'); // "Allocated & Approved"execute(Deal|Contact $target, SalesPlaybook $playbook, array $answers, int|string|null $userId = null) is keyed by question id. For each question with a non-empty answer it sets the question's target_property (if any) on the target, then saves the target once. It logs a note activity titled Playbook: {name} with a Markdown summary of the answered questions. Answers for unknown question IDs are ignored.