Paid add-on. Marketing is licensed separately from the open-source packages. See pricing
Forms and landing pages
Capture leads with hosted forms, an embeddable form script, a headless JSON schema and API endpoint, progressive profiling, and hosted landing pages.
A marketing form is an Odden\Marketing\Models\MarketingForm record with a list of fields. You can serve the same form four ways: as a hosted page, through an embed script on another site, from your own front end using its JSON schema, or inside a hosted landing page. Every submission goes through ProcessFormSubmissionAction, which finds or creates the contact, scores the lead, and enrolls the contact in workflows.
All routes on this page are registered by the package. Their paths depend on the odden-marketing.routes prefixes (see public routes); the paths below use the defaults (no prefix for web routes, api/marketing for API routes). Route names never change, so build links with route() or the model helpers.
Creating a form
use Odden\Marketing\Models\MarketingForm;
$form = MarketingForm::create([
'title' => 'Request a Demo',
'description' => 'Tell us about your team.',
'fields_schema' => [
['name' => 'first_name', 'label' => 'First name', 'type' => 'text', 'required' => true],
['name' => 'email', 'label' => 'Work email', 'type' => 'email', 'required' => true],
['name' => 'company', 'label' => 'Company', 'type' => 'text'],
['name' => 'team_size', 'label' => 'Team size', 'type' => 'select', 'options' => ['1-10', '11-50', '51+']],
],
'submit_button_text' => 'Book my demo',
'success_message' => 'Thanks! We will be in touch.',
]);
$form->slug; // "request-a-demo"
$form->getPublicUrl(); // "https://your-app.test/forms/request-a-demo"
$form->getApiEndpoint(); // "https://your-app.test/api/marketing/forms/request-a-demo"| Attribute | Notes |
|---|---|
title |
Required. |
slug |
Unique. Generated from title with Str::slug() when left empty. |
description |
Shown under the title. |
fields_schema |
Required. Array of field definitions (see below). |
submit_button_text |
Defaults to Submit in the database. |
success_message |
Shown after a submission. Each renderer has its own fallback text. |
redirect_url |
When set, the visitor is sent here after submitting instead of seeing the success message. |
is_active |
Defaults to true. Inactive forms return 404 on every route. |
progressive_profiling_enabled, progressive_fields |
See progressive profiling. |
submissions_count |
Incremented on every submission. |
Field definitions
Each entry in fields_schema is an array with these keys:
name(required): the input name and the key the value is stored under.label: display label. The hosted page falls back to the name with underscores replaced by spaces.type:text,email,textarea,select,checkbox, or any other HTML input type such astelornumber. Defaults totext. Acheckboxis sent as1when ticked, and a required one must be ticked.required:trueto make the field required.options: forselectfields. The hosted page and landing pages expect a list of strings. The embed script also accepts['value' => ..., 'label' => ...]objects.placeholder: used by the embed script only.
In the admin panel, a form is built in three steps: its fields with a live preview drawn by the hosted page's own view, what happens after someone submits, and the link and embed code to copy. Common fields (first name, last name, email, phone, company, message, email marketing consent and SMS consent; the consent boxes use Odden\Marketing\Support\ConsentWording, naming the business by app.name) are added from a palette with the names above, so they fill in the contact. Any other field gets its name from its label when the form is saved (lower case, underscores, unique within the form and its progressive questions, never a key a submission reads otherwise such as contact or visitor_token). A field that already has a name keeps it when its label changes, since stored answers and contact properties use it; it shows read-only under "Advanced". fields_schema and progressive_fields are read through Odden\Marketing\Casts\FormFieldList, which always returns a list of field arrays: entries without a name are dropped, a comma-separated options string becomes a list, and a key/value map saved by earlier versions of the admin form ({"budget": "Estimated budget"}) is read as text fields (the key is the name, the value the label; email becomes a required email field) and saved back as a list.
Validation is built from the fields: each field becomes required or nullable (a required checkbox becomes accepted), and fields of type email also get the email rule. Nothing else is validated. The rules come from the fields the visitor was shown: the base fields, or the resolved progressive fields when a valid signed contact link was sent.
What happens on submission
Odden\Marketing\Actions\ProcessFormSubmissionAction handles every submission. Five field names are mapped to the contact record: email, first_name, last_name, phone, and company, and each is read only when the form declares a field with that name, and only as text (at most 255 characters). A key the form does not declare is kept in the submission's form_data and does nothing else, so a form with a single "message" field creates no contact whatever is posted to it. When the submission carries an email and no verified contact was passed in:
- The email is trimmed and lowercased, and the contact with that email is loaded or created (new contacts get
lead_statusnewandlifecycle_stagelead). An existing contact'sfirst_name,last_name, andphoneare filled in only if they're empty. - If the form declares
sms_consentand it is truthy, and the contact was created by this submission,sms_consent_requested_atis set on the contact. That is a request, not consent: the number was not verified, sosms_consentstays off and no text is sent on it (odden-marketing.forms.sms_consent_requires_verification, default on; turn it off only when numbers are confirmed some other way). An existing contact reached by a typed address is never given consent. (Texts also need an SMS provider.) - If the form declares
marketing_consent(the palette's "Email marketing consent" box), the answer is used for a contact this submission created or a signed link identified. Ticked:marketing_consent_at,marketing_consent_form_id,marketing_consent_formandmarketing_consent_wording(the box's label) are set as properties, and a signed-link contact who had unsubscribed is subscribed again (a bounced address stays suppressed). Double opt-in, where it is on, still applies. Unticked: a new contact is kept but marked unsubscribed, and is not sent a confirmation email. An existing contact reached by a typed address is left as it was. A form without the box behaves as before. - If
visitor_tokenis present, anonymous web sessions with that token are stitched to the contact. - If
companyis present and no company of that name exists, one is created and associated with the contact. If one already exists the visitor is not joined to it (that would put a stranger in an account's contact list): the typed name is kept as theunverified_company_nameproperty, and the contact is matched to a company by email domain (see account intent), as it is when no company was typed. - A completed activity titled
Form Submission: {form title}is logged on the contact's timeline. - The
form_submissionlead scoring event is applied (15 points by default), at mostodden-marketing.lead_scoring.caps.form_submissiontimes per contact per window (3 per 24 hours), so posting a known address repeatedly cannot push a contact to sales-qualified.
Then, for any resolved contact (including a verified one):
- Submitted keys that are declared as a field
namein the form'sfields_schemaorprogressive_fieldsare saved to the contact's custom properties. The five mapped fields above,sms_consent,marketing_consent,visitor_token,contact_id,_tokenand theutm_*keys are never saved as properties, and keys the form doesn't declare are kept only in the submission'sform_data. - Only a verified contact (one passed in as
$contact, which the submit endpoints do for a valid signedcontacttoken) can change a property that already has a value. When the contact was matched by email or just created, properties are only filled in where they're empty, so someone who knows a contact's email can't overwrite their data. - A
FormSubmissionrow is stored with the rawform_data,ip_address,user_agent, andutm_source,utm_medium,utm_campaign,utm_term,utm_contenttaken from the submitted data. - The form's
submissions_countis incremented. - The contact is enrolled in active workflows with the
form_submittedtrigger whosetrigger_config.form_idmatches this form or is unset (see workflows). This happens for a contact the submission created or identified with a signed link. A contact that already existed and was reached only by a typed address does not start the form's workflows (odden-marketing.forms.enrol_unverified_existing_contactsturns that back on). On a double opt-in form the workflows wait for confirmation.
A submission without an email and without a verified contact is still stored, with a null contact_id.
Submissions are available as $form->submissions (newest first) and $contact->formSubmissions, a relation the package adds to Contact.
You can call the action yourself, for example to import submissions from another tool:
use Odden\Marketing\Actions\ProcessFormSubmissionAction;
$submission = app(ProcessFormSubmissionAction::class)->execute(
form: $form,
data: [
'email' => 'grace@example.com',
'first_name' => 'Grace',
'team_size' => '51+',
'utm_source' => 'webinar',
],
ipAddress: '203.0.113.10',
);
$submission->contact->email; // "grace@example.com"The full signature is execute(MarketingForm $form, array $data, ?string $ipAddress = null, ?string $userAgent = null, ?Contact $contact = null): FormSubmission. Pass $contact only when you've verified who is submitting. It skips the email lookup and the steps listed above that depend on it, including scoring, and lets the submission overwrite the contact's existing property values. In the example above, team_size is saved as a property only if the form declares a team_size field.
Hosted forms
| Method | URI | Route name | Notes |
|---|---|---|---|
GET |
/forms/{slug} |
odden.marketing.forms.show |
Renders odden-marketing::forms.show. |
POST |
/forms/{slug} |
odden.marketing.forms.submit |
web middleware, so CSRF protected. Rate limited by odden-public. |
The hosted page posts back to itself. After a successful submission it redirects to redirect_url if set, or renders odden-marketing::forms.success with the success_message. If the request expects JSON (an Accept: application/json header), it returns the same JSON as the API endpoint instead. Validation errors redirect back with the usual error bag.
To restyle the pages, override the views by creating show.blade.php and success.blade.php in resources/views/vendor/odden-marketing/forms/ (the package has no view publish tag). The default views load Tailwind from its CDN.
Embedding a form on another site
The embed script renders a form from its JSON schema and submits it to the API endpoint, so it works on any domain.
| Method | URI | Route name |
|---|---|---|
GET |
/marketing/forms/embed.js |
odden.marketing.forms.embed-script |
GET |
/marketing/forms/{slug}/embed.js |
odden.marketing.forms.slug-embed-script |
The generic script renders every element with a data-odden-form attribute. The slug-specific script renders only the element with data-odden-form="{slug}" or id="odden-form-{slug}".
<div data-odden-form="request-a-demo"></div>
<script src="https://your-app.test/marketing/forms/embed.js" async></script>Two optional attributes on the container turn the form into a popup:
data-odden-display:inline(default, replaces the container's content),modal(centered overlay), orslide-in(bottom-right card).data-odden-trigger, formodalandslide-inonly:immediate(default),exit-intent(pointer leaves through the top of the window),scroll-50(half the page scrolled), ordelay-{seconds}, for exampledelay-10.
<div data-odden-form="request-a-demo" data-odden-display="modal" data-odden-trigger="exit-intent"></div>Closing a popup sets a sessionStorage flag, so it isn't shown again in that browser session. The script is served with Cache-Control: public, max-age=3600.
On submit the script posts the field values as JSON together with the visitor's id as visitor_token. It uses the same id as the tracking script (_odden_vid in localStorage and a first-party cookie on your site, created if missing), so the contact the submission creates or matches is stitched to the pages the visitor viewed before, even when your site is on another domain.
Headless forms
Use the schema endpoint when you render the form yourself (Next.js, Remix, Webflow, a mobile app).
| Method | URI | Route name |
|---|---|---|
GET |
/marketing/forms/{slug}/schema.json |
odden.marketing.forms.schema |
{
"id": 1,
"title": "Request a Demo",
"slug": "request-a-demo",
"description": "Tell us about your team.",
"submit_button_text": "Book my demo",
"action_url": "https://your-app.test/api/marketing/forms/request-a-demo",
"fields": [
{"name": "first_name", "label": "First name", "type": "text", "required": true},
{"name": "email", "label": "Work email", "type": "email", "required": true},
{"name": "company", "label": "Company", "type": "text"},
{"name": "team_size", "label": "Team size", "type": "select", "options": ["1-10", "11-50", "51+"]}
],
"progressive_profiling": false
}fields is the form's fields_schema, or the progressive field list when a valid contact token is passed as a query parameter. Post the values to action_url.
Form API endpoint
| Method | URI | Route name | Auth |
|---|---|---|---|
POST |
/api/marketing/forms/{slug} |
odden.marketing.forms.api-submit |
Public. CSRF exempt, rate limited by odden-public. |
This endpoint needs no API token, because browsers call it directly. Send the field values as JSON or form data, with Accept: application/json:
curl -X POST https://your-app.test/api/marketing/forms/request-a-demo \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"first_name": "Ada", "email": "ada@example.com", "company": "Analytical Engines Ltd", "utm_source": "linkedin", "utm_campaign": "q3-launch", "visitor_token": "..."}'{
"success": true,
"message": "Thanks! We will be in touch.",
"redirect_url": null,
"submission_id": 42
}message falls back to Thank you for your submission! when the form has no success_message. Validation failures return Laravel's standard 422 JSON error response, and inactive or unknown forms return 404. The controller returns JSON when the request expects JSON or its path matches api/*; if you change the API prefix, send the Accept header so you don't get a redirect.
The odden-public limit is per IP address and defaults to 30 requests per minute (see rate limits).
Progressive profiling
With progressive profiling, a returning contact isn't asked again for things you already know. Each base field the contact has a value for is replaced by the next unanswered question from progressive_fields.
$form = MarketingForm::create([
'title' => 'Product Updates',
'fields_schema' => [
['name' => 'first_name', 'label' => 'First name', 'type' => 'text', 'required' => true],
['name' => 'email', 'label' => 'Email', 'type' => 'email', 'required' => true],
],
'progressive_profiling_enabled' => true,
'progressive_fields' => [
['name' => 'job_function', 'label' => 'Job function', 'type' => 'text', 'required' => true],
['name' => 'budget', 'label' => 'Annual budget', 'type' => 'select', 'options' => ['< $10k', '$10k+']],
],
]);
$fields = $form->resolveFieldsForContact($contact);
// For a contact with a first name and email: the job_function and budget fields,
// each with 'is_progressive' => true.resolveFieldsForContact(?Contact $contact): array walks the base fields in order:
- A field the contact doesn't have a value for is kept.
- A known field is replaced by the next progressive field, marked
'is_progressive' => true. Once the queue is empty, further known fields are dropped. - If every field was dropped, the first three progressive fields are returned.
The base fields are returned unchanged when profiling is disabled, progressive_fields is empty, or there's no contact. isFieldKnownByContact(Contact $contact, string $fieldName): bool decides what counts as known: email, first_name, last_name, and phone check the contact's columns, company checks for an associated company, and any other name checks the custom property of that name.
Signed personalized links
A form recognizes a contact only through a signed token, never through a bare id or email in the URL. To send a contact a personalized link, for example in an email, pass the contact to getPublicUrl():
$url = $form->getPublicUrl($contact);
// https://your-app.test/forms/product-updates?contact=17.3f9c...The hosted page greets the contact ("Welcome back, Sarah!"), shows the progressive questions with a "Smart Question" badge, and carries the token in a hidden contact field. The schema endpoint accepts the same contact query parameter, and the submit endpoints accept it as a contact input. When the token is valid, the submission is attached to that contact: progressive answers are saved as custom properties (replacing existing values), but none of the email-based steps run, so no lead score is added and no timeline activity is logged.
The token comes from Odden\Marketing\Support\ContactToken:
use Odden\Marketing\Support\ContactToken;
$scope = ContactToken::forForm($form->id); // "form:{id}"
$token = ContactToken::make($contact, $scope); // "{contact id}.{64-char HMAC}"
ContactToken::resolve($token, $scope); // the Contact
ContactToken::resolve($token, ContactToken::forForm($otherForm->id)); // null
ContactToken::resolve((string) $contact->id, $scope); // nullThe signature is an HMAC-SHA256 of the scope and contact id keyed with app.key, so a token for one form doesn't work on another. Tokens don't expire, and rotating APP_KEY invalidates all of them. resolve() returns null for anything malformed, tampered, or pointing to a deleted contact.
A visitor without a valid token sees the base fields, and is validated against the base fields, whatever email they type. Progressive fields (required or not) therefore never cause a 422 for someone who wasn't shown them.
Landing pages
Odden\Marketing\Models\LandingPage is a hosted page with a headline, HTML body, and an optional form.
use Odden\Marketing\Models\LandingPage;
$page = LandingPage::create([
'title' => 'Q3 Launch',
'slug' => 'q3-launch',
'headline' => 'Ship faster with Odden',
'body_content' => '<p>Join the beta.</p>',
'form_id' => $form->id,
'is_published' => true,
'published_at' => now(),
]);
$page->getPublicUrl(); // "https://your-app.test/p/q3-launch"
$page->getEmbedSnippet(); // '<iframe src="https://your-app.test/p/q3-launch" width="100%" height="600" frameborder="0" style="border:none;"></iframe>'
$page->conversion_rate; // submissions_count / views_count * 100, rounded to 2 decimalsOther attributes: subheadline, meta_title, meta_description, og_image_url, created_by_id (creator relation to your user model), and the counters views_count and submissions_count. slug is required and isn't generated for you. Pages default to unpublished.
A page can instead keep its sections, in order, in content_blocks: a list of ['type' => ..., 'data' => [...]] entries. When content_blocks is set, the page shows those sections and ignores body_content; when it is null (pages made before this), the page shows its headline, body and form as before. LandingPage::blocks() returns the sections either way. The types are:
| Type | Data | Shows |
|---|---|---|
hero |
headline, subheadline |
The page's heading and the text under it. |
text |
body (HTML) |
Formatted text, cleaned like body_content. |
image |
source (upload, library or url), path, asset_id, asset_path, asset_url, url, alt, caption |
An image: an upload (path), a copy of a file library picture (asset_path, or the library's own link in asset_url), or a web address (url, the default when source is missing). Only http and https addresses, and stored paths inside the images folder, are used. |
call_to_action |
heading, text, button_label, target (form or url), url |
A button to the page's form or to an http, https, mailto or tel address. |
form |
form_id |
The page's form. Keep form_id on the page the same: submissions go to it. |
custom_html |
html |
HTML you already have, cleaned like body_content. |
In the admin panel, a page is made in four steps (page basics, content, look, publish) with a live preview drawn by odden-marketing::landing-page in a sandboxed frame, and a page has at most one form section. Saving there writes content_blocks and sets headline, subheadline and form_id from the first headline and form sections, and clears body_content (an older page's body opens as a custom HTML section).
Images uploaded in the builder, or picked from the file library (active, ungated .png, .jpg, .webp or .gif assets), are checked and re-encoded by Odden\Marketing\Support\LandingImages: PNG, JPEG, WebP and GIF only (never SVG), up to odden-marketing.landing_images.max_kb (default 5120) and 8 million pixels, decoded and encoded again with GD so no metadata or trailing data survives. They are stored on the odden-marketing.landing_images.disk disk (default public; run php artisan storage:link) under odden-marketing.landing_images.directory (default landing-images) and shown at the disk's URL, so the disk must be publicly readable. A file library picture is copied when the page is saved, not when it is picked. When a save replaces or removes an uploaded or copied image, or the page is deleted for good, the file is deleted; a page that is only deleted keeps its images until it is deleted for good.
A multi-tenant host (one that rebinds Odden\Core\Contracts\TenantContext) must give each tenant its own folder at boot. Until it does, uploads are refused and stored images aren't shown, rather than every tenant sharing one folder:
use Odden\Marketing\Support\LandingImages;
LandingImages::directoryUsing(fn (): string => 'landing-images/'.currentTenantId());The closure runs on every use, and a page only shows images from the folder it returns. A closure that returns an empty string refuses uploads the same way.
body_content is cleaned with an allow-list (Odden\Marketing\Support\LandingPageHtml, built on symfony/html-sanitizer) when the page is rendered: safe text and structural elements (headings, paragraphs, lists, tables, links, images, ...) with their safe attributes and class are kept; scripts, event handlers, inline style, iframes, forms and links other than http, https, mailto and tel are removed. Content saved before this change is cleaned too, because it happens at render time.
| Method | URI | Route name | Notes |
|---|---|---|---|
GET |
/p/{slug} |
odden.marketing.landing-pages.show |
404 unless is_published. |
POST |
/p/{slug}/submit |
odden.marketing.landing-pages.submit |
CSRF protected, rate limited by odden-public. |
Each view increments views_count and records a page view with the utm_source, utm_medium and utm_campaign query parameters, using the visitor's odden_vid cookie if there is one. The page also loads the tracking script, whose pageview sets that cookie to the visitor's id (see visitor tokens), so the submission is stitched to the pages viewed before it.
A submission is passed to ProcessFormSubmissionAction with the page's form (with the odden_vid cookie value as visitor_token), increments submissions_count, and redirects back with the success message in the success session key. Like hosted forms, the submission is validated against the form's fields first (a failure redirects back with the error bag and records nothing, so submissions_count doesn't change). A page without a form returns 404 on submit.
The page is rendered by odden-marketing::landing-page.
Your logo and colour
Set odden-core.branding.logo_url (an https:// or http:// address, or a path on your host) and odden-core.branding.accent_color (a #rrggbb colour) in your config, and the hosted forms, landing pages, unsubscribe and preference pages, the opt-in confirmation and the NPS pages show the logo (with app.name as its text alternative) and use the colour for buttons, links and focus rings in place of the default indigo and sky. Any other value is ignored, and with neither set the pages look as they do out of the box. White text sits on the colour's buttons, so choose a colour dark enough to read white text on (WCAG asks for a contrast of at least 4.5:1). The rules are built in Odden\Marketing\Support\PageBranding.
The landing page list offers each page's embed code (an iframe). A host that does not let landing pages be framed sets odden-marketing.landing_pages.embeddable to false (ODDEN_MARKETING_LANDING_PAGES_EMBEDDABLE); the list then offers "Copy link" instead. Forms keep their embed code either way. This setting only changes what the list offers: to stop pages being framed, the host must send landing page responses with a Content-Security-Policy: frame-ancestors header itself.
Capturing forms you didn't build with Odden
To capture leads from existing forms on your website, use form auto-capture in the tracking script.
Double opt-in
Anyone can type any address into a form. A form can ask the contacts it creates to confirm their address before they hear from you:
set Ask new contacts to confirm their email address on the form (requires_double_opt_in), or leave it unset to follow
odden-marketing.forms.double_opt_in_default (off in the package; Odden Cloud turns it on).
On such a form, a contact the submission creates (or one already waiting from another double-opt-in form):
- is marked
marketing_consent_pending_at, and receives oneDoubleOptInConfirmationMailperodden-marketing.forms.double_opt_in_resend_hours(24), however many forms they fill in. It has the confirmation link, the sender's name and postal address, and no promotion or tracking; - is not sent campaigns or workflow emails while waiting (
ContactEmailHealth::awaitingConsent(); transactional mail such as receipts is not held); - has what the form would have started held in
PendingConsentrows (one per form submission) forodden-marketing.forms.double_opt_in_expiry_days(7).
When the address is confirmed (the button on GET /marketing/confirm/{token}, which POSTs to it, see double opt-in) the contact stops waiting and the workflows of every held submission still inside its window start. marketing:expire-pending-consents (schedule it daily) marks the rest expired: those workflows never start and the contact stays held back until they confirm. A contact that already existed is not asked to confirm and not held: it was never created by the form.