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, or any other HTML input type such astelornumber. Defaults totext.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.
Validation is built from the fields: each field becomes required or nullable, and fields of type email also get the email rule. Nothing else is validated.
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. 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
sms_consentis truthy,sms_consentandsms_consent_atare set on the contact. - If
visitor_tokenis present, anonymous web sessions with that token are stitched to the contact. - If
companyis present, the company with that exact name is loaded or created and associated with the contact. Otherwise the contact is matched to a company by email domain (see account intent). - A task titled
Form Submission: {form title}is logged on the contact's timeline. - The
form_submissionlead scoring event is applied (15 points by default).
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,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).
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 task 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 token who types the email of an existing contact is matched to that contact by email (step 1 above) but sees the base fields, not the progressive ones. Validation, however, uses the fields resolved for that matched contact, so if any progressive field is required, a returning contact who submits the base form without a token gets a 422 for the progressive field. Keep progressive fields optional unless every returning contact arrives through a signed link.
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.
body_content is printed unescaped, so only store HTML you trust.
| 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. Unlike hosted forms, landing page submissions aren't validated against the form's fields. A page without a form returns 404 on submit.
The page is rendered by odden-marketing::landing-page.
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.