Marketing
What the getodden/crm-marketing package adds, its models, scheduled commands, configuration, and routes.
getodden/crm-marketing adds email marketing and lead capture to Odden. It stores everything in your database, links every record to Core's contacts, companies, and lists, and builds its emails with getodden/mail.
composer require getodden/crm-marketing
php artisan migrateThe package registers Odden\Marketing\MarketingServiceProvider through package discovery. See Installation for the full setup, and Core concepts for contacts, companies, and lists.
The two halves
The module has an email side and a lead side.
- Campaigns: audiences, scheduling, local-time and send-time optimization, A/B tests, proofs, open and click tracking
- Email templates: templates built from mail builder slots, revisions, translations, merge tags, smart content, dynamic images, and AMP
- Subscriptions and compliance: unsubscribe links, the preference center, subscription topics, and double opt-in
- Deliverability: the suppression list, ESP bounce and complaint webhooks, content linting, domain checks, and the sunset policy
- Transactional email: the HTTP API that sends a template to one recipient or a batch
Leads
- Forms and landing pages
- Web tracking
- Lead scoring
- Workflows
- Events and gated assets
- Inbound webhooks and events API
- Attribution
Models
All models live in Odden\Marketing\Models. The email side uses these:
| Model | Table (config key) | What it holds |
|---|---|---|
Campaign |
odden_marketing_campaigns (tables.campaigns) |
A broadcast email: subject, sender, template, audience, schedule, A/B settings, counters |
CampaignRecipient |
odden_marketing_campaign_recipients (tables.recipients) |
One row per contact a campaign was sent or staged for, with its tracking and unsubscribe tokens |
MarketingTemplate |
odden_marketing_templates (tables.templates) |
Reusable email content: mail builder slots, compiled HTML and text, variant B |
MarketingTemplateRevision |
odden_marketing_template_revisions |
A snapshot of a template, taken on every save |
MarketingTemplateTranslation |
odden_marketing_template_translations |
Per-locale subject, preview text, and body for a template |
MarketingSavedBlock |
odden_marketing_saved_blocks |
A reusable slot saved for the template editor |
MarketingSubscription |
odden_marketing_subscriptions (tables.subscriptions) |
Global subscription status per email address |
MarketingSubscriptionTopic |
odden_marketing_subscription_topics (tables.subscription_topics) |
A communication topic shown in the preference center |
MarketingContactTopic |
odden_marketing_contact_topics (tables.contact_topics) |
An email address's opt-in or opt-out for one topic |
EmailSuppression |
odden_marketing_suppressions (tables.suppressions) |
The global do-not-send list |
EspEvent |
odden_marketing_esp_events (tables.esp_events) |
Every event received from an email provider webhook |
The lead side adds forms, landing pages, visitor sessions, scoring rules, workflows, events, assets, NPS surveys, and SMS messages. Their pages document them.
Tables without a config key use fixed names.
Enums
| Enum | Cases |
|---|---|
CampaignStatus |
Draft, Scheduled, Sending, Sent, Cancelled |
CampaignType |
Regular, Automated |
RecipientStatus |
Pending, Sent, Opened, Clicked, Bounced, Unsubscribed, Suppressed |
SubscriptionStatus |
Subscribed, Unsubscribed, Bounced |
Each enum has getLabel(), and all but CampaignType have getColor() (a Filament color name).
Relations added to Contact
When the package boots, it adds these relations to Odden\Core\Models\Contact:
| Relation | Returns |
|---|---|
campaignRecipients |
HasMany of CampaignRecipient |
marketingSubscription |
HasOne of MarketingSubscription |
formSubmissions |
HasMany of FormSubmission |
leadScoreLogs, leadDecayLogs |
HasMany, newest first |
workflowEnrollments |
HasMany of WorkflowEnrollment |
customBehavioralEvents |
HasMany of CustomBehavioralEvent |
When Core merges two contacts, Marketing moves every row it keys to the duplicate contact over to the surviving one, and on a company merge it moves custom behavioural events. Where both contacts have a row that can only exist once, such as two recipients of the same campaign, it keeps one. Opt-outs carry over: see Merging contacts.
Core's contacts table also carries the marketing columns this package migrates in: marketing_topics, marketing_verification_token, marketing_email_verified_at, last_marketing_email_sent_at, is_unengaged, unengaged_since, and sunset_stage.
Sending mail
Every email the package sends is queued, never sent during the request or command that triggers it:
- campaign messages, from
DispatchCampaignAction,marketing:dispatch-scheduled, andmarketing:evaluate-ab-tests(see Campaigns) - workflow
send_emailsteps - campaign proofs (
SendCampaignProofAction) - the transactional API
Run a queue worker, or nothing is delivered. Every marketing mailable (MarketingMessageMailable, CampaignProofMailable, and TransactionalTemplateMailable) is pushed to the queue only after the surrounding database transaction commits, like Sales and Service mail, so mail queued inside a transaction that rolls back is never sent. The mail goes on your default queue connection and queue unless you set ODDEN_MARKETING_MAIL_CONNECTION and ODDEN_MARKETING_MAIL_QUEUE, and through your default mailer unless you set ODDEN_MARKETING_MAILER:
ODDEN_MARKETING_MAIL_QUEUE=marketing-mail
ODDEN_MARKETING_MAILER=sesphp artisan queue:work --queue=marketing-mail,defaultWith the sync queue connection, messages are sent as they're queued, inside the request or command. That works for development but not for a real list.
Scheduled commands
The package registers five Artisan commands but doesn't schedule them. Add them to routes/console.php as shown in Installation.
| Command | What it does | Documented in |
|---|---|---|
marketing:dispatch-scheduled |
Dispatches Scheduled campaigns whose scheduled_at has passed, and releases pending local-time and send-time-optimized recipients whose time has come |
Campaigns |
marketing:evaluate-ab-tests |
Picks the winner of A/B campaigns whose test window has ended and releases the rest of the audience | Campaigns |
marketing:sunset-subscribers {--days=90} {--min-sends=3} {--suppress} |
Flags, or with --suppress unsubscribes, contacts with no opens or clicks |
Deliverability |
marketing:process-workflows |
Advances workflow enrollments | Workflows |
marketing:decay-lead-scores {--days=30} {--points=5} |
Lowers the scores of inactive contacts | Lead scoring |
Configuration
Publish the config file to change any of these:
php artisan vendor:publish --tag=odden-marketing-config| Key | Default | Environment variable | What it does |
|---|---|---|---|
tables.* |
odden_marketing_* names |
Table names for the models listed above and the lead-side models | |
defaults.sender_name |
Odden Marketing |
MARKETING_FROM_NAME |
Sender name for campaign messages and proofs when the campaign has none, and for workflow emails |
defaults.sender_email |
newsletter@odden.test |
MARKETING_FROM_EMAIL |
Sender address for campaign messages and proofs when the campaign has none, and for workflow emails |
defaults.reply_to |
support@odden.test |
MARKETING_REPLY_TO |
Reply-to address for campaign proofs when the campaign has none, and for workflow emails |
mail.mailer |
null |
ODDEN_MARKETING_MAILER |
Mailer (from config/mail.php) for all marketing mail. Empty uses the default mailer |
mail.connection |
null |
ODDEN_MARKETING_MAIL_CONNECTION |
Queue connection for marketing mail. Empty uses the default connection |
mail.queue |
null |
ODDEN_MARKETING_MAIL_QUEUE |
Queue name for marketing mail. Empty uses the connection's default queue |
fatigue_protection.enabled |
false |
MARKETING_FATIGUE_PROTECTION_ENABLED |
Skip contacts who were emailed too recently during campaign dispatch |
fatigue_protection.max_emails_per_7_days |
2 |
MARKETING_MAX_EMAILS_7_DAYS |
Campaign emails a contact may receive in a rolling 7 days |
fatigue_protection.min_hours_between_sends |
24 |
MARKETING_MIN_HOURS_BETWEEN_SENDS |
Minimum hours between two campaign emails to one contact |
sales_handoff.auto_handoff_on_sql |
true |
MARKETING_AUTO_HANDOFF_ON_SQL |
See Lead scoring |
sales_handoff.sql_score_threshold |
100 |
MARKETING_SQL_THRESHOLD |
See Lead scoring |
sales_handoff.default_deal_amount |
10000.00 |
MARKETING_HANDOFF_DEAL_AMOUNT |
See Lead scoring |
routes.enabled |
true |
ODDEN_MARKETING_ROUTES_ENABLED |
Register the package's routes |
routes.web.domain |
null |
ODDEN_MARKETING_DOMAIN |
Domain for the web route group |
routes.web.prefix |
'' |
ODDEN_MARKETING_PREFIX |
Path prefix for the web route group |
routes.web.middleware |
['web'] |
Middleware for the web route group |
|
routes.api.domain |
null |
ODDEN_MARKETING_DOMAIN |
Domain for the api route group |
routes.api.prefix |
api/marketing |
ODDEN_MARKETING_API_PREFIX |
Path prefix for the api route group |
routes.api.middleware |
['web'] |
Middleware for the api route group |
|
api.token |
null |
ODDEN_MARKETING_API_TOKEN |
Shared secret for the server-to-server endpoints |
esp.mailgun.signing_key |
null |
ODDEN_MARKETING_MAILGUN_SIGNING_KEY |
Mailgun HTTP webhook signing key. When set, Mailgun's ESP webhook is authenticated by signature instead of the API token |
esp.mailgun.tolerance |
900 |
ODDEN_MARKETING_MAILGUN_SIGNATURE_TOLERANCE |
Seconds a Mailgun webhook signature stays valid |
amp.allowed_origins |
https://mail.google.com, https://outlook.live.com, https://mail.yahoo.com, https://mail.aol.com |
ODDEN_MARKETING_AMP_ALLOWED_ORIGINS (comma-separated) |
Email client origins allowed to call the AMP endpoints |
webhooks.outbound_url |
null |
ODDEN_MARKETING_WEBHOOK_URL |
Fallback URL for outbound webhook notifications when a request doesn't pass webhook_url |
webhooks.secret |
null |
ODDEN_MARKETING_WEBHOOK_SECRET |
Fallback signing secret for outbound webhooks when a request doesn't pass webhook_secret. With no secret at all, the webhook isn't sent |
See Transactional email for how the webhooks.* keys are used.
Routes
Routes are registered in two groups whose attributes come from odden-marketing.routes.web and odden-marketing.routes.api. Empty values are dropped, so the default web group has no prefix and no domain. Configuration explains the shared route options.
If you set routes.enabled to false, register your own routes with the same names: models and emails build their links with route('odden.marketing.…').
web group
These are the routes for the email side. The group also holds the hosted form, landing page, web tracking, NPS, and asset download routes, documented on their own pages.
| Method | URI | Name | Notes |
|---|---|---|---|
GET |
/marketing/track/open/{token} |
odden.marketing.track.open |
Open-tracking pixel |
GET |
/marketing/track/click/{token} |
odden.marketing.track.click |
Click redirect, destination in ?url=, signed with ?sig=. Unsigned links return 404 |
GET |
/marketing/unsubscribe/{token} |
odden.marketing.unsubscribe.show |
Unsubscribe confirmation page |
POST |
/marketing/unsubscribe/{token} |
odden.marketing.unsubscribe.process |
throttle:odden-public, CSRF protected |
GET |
/marketing/preferences/{token} |
odden.marketing.preferences.show |
Preference center |
POST |
/marketing/preferences/{token} |
odden.marketing.preferences.update |
throttle:odden-public, CSRF protected |
GET |
/marketing/confirm/{token} |
odden.marketing.confirm |
Double opt-in confirmation |
POST |
/marketing/webhooks/esp/{provider} |
odden.marketing.webhooks.esp |
API token, throttle:odden-api, CSRF exempt |
GET |
/marketing/images/countdown-timer.svg |
odden.marketing.images.countdown-timer |
Dynamic SVG image |
GET |
/marketing/images/badge.svg |
odden.marketing.images.badge |
Dynamic SVG image |
api group
Every route in this group is exempt from CSRF verification. The default prefix is api/marketing.
| Method | URI | Name | Protection |
|---|---|---|---|
POST |
/templates/{template}/send |
odden.marketing.templates.send |
API token, throttle:odden-api |
POST |
/templates/{template}/send-batch |
odden.marketing.templates.send-batch |
API token, throttle:odden-api |
POST |
/webhooks/deliverability |
odden.marketing.webhooks.deliverability |
API token, throttle:odden-api |
POST |
/amp/feedback |
odden.marketing.amp.feedback |
throttle:odden-public, amp.allowed_origins |
POST |
/amp/rsvp |
odden.marketing.amp.rsvp |
throttle:odden-public, amp.allowed_origins, signed RSVP token |
POST |
/forms/{slug} |
odden.marketing.forms.api-submit |
throttle:odden-public |
POST |
/events/{slug}/register |
odden.marketing.events.register |
throttle:odden-public |
POST |
/leads/webhook/{source?} |
odden.marketing.leads.webhook |
API token, throttle:odden-api |
POST |
/events/{slug}/attendance-webhook |
odden.marketing.events.attendance-webhook |
API token, throttle:odden-api |
POST |
/events/track |
odden.marketing.events.track |
API token, throttle:odden-api |
POST |
/workflows/{workflow}/enroll |
odden.marketing.workflows.enroll-webhook |
API token, throttle:odden-api |
The API token
Every route marked "API token" requires ODDEN_MARKETING_API_TOKEN:
ODDEN_MARKETING_API_TOKEN=a-long-random-stringGenerate one with php -r 'echo bin2hex(random_bytes(32));'. Send it as Authorization: Bearer <token>, an X-Odden-Token header, or a ?token= query parameter.
The endpoints fail closed: while the token is empty they return 403, and a missing or wrong token returns 401. Configuration covers the token and the odden-public and odden-api rate limiters in detail.
Admin screens
If you use the Filament plugin, it provides resources for campaigns and templates, a deliverability audit, proof sending, and a sender domain health page, all built on the classes these pages describe.