HubSpot + Laravel Forms: Sending Leads Where Sales Lives
HubSpot + Laravel Forms: Sending Leads Where Sales Lives
A demo form fills out at 4pm on a Friday. The lead is hot — the prospect just watched the pricing video, clicked through to the trial page, then back to the contact form and asked for a call. Your Sales team lives in HubSpot, deal pipeline in one tab and Gmail in the other.
They will not see this lead until Monday morning, when somebody logs into the Filament admin panel and notices the row. By then a competitor has already replied. The lead went cold not because the product lost — it lost because the form lived in the wrong place.
This is the gap a hubspot laravel form integration exists to close. Submission lands in your Laravel app, a queued listener pushes it into HubSpot within seconds, the new contact shows up in the Sales rep's view next to every other prospect they're working. No Zapier hop. No Monday-morning catch-up. And, as the rest of this post explains, one early decision that determines whether your marketing reports tell the truth a month later. We built the lead-gen form template for the form half; this is the wiring half.
Two HubSpot APIs to choose from
HubSpot exposes two ways to do this job, and they are not interchangeable. The first is the Forms API — you POST a submission to a HubSpot-managed form, HubSpot then creates the contact, deduplicates by email, and fires its form-tracking layer so the submission shows up in HubSpot's analytics with full attribution. The second is the CRM Contacts API — you POST a contact object directly to /crm/v3/objects/contacts and HubSpot creates a contact row, full stop. No form, no analytics, no source attribution.
Pick the Forms API for most cases. It costs an extra ten minutes — you have to create a form inside HubSpot first to get the formGuid — and in exchange you keep marketing's reports intact. Conversion paths, original source, page tracking, and the Forms dashboard all keep working as if a user had filled out a HubSpot-hosted form, because as far as HubSpot is concerned, they did.
Pick the Contacts API only when you have a reason to bypass HubSpot's form layer: a CRM-first workflow where the lead exists before any form is filled, an enrichment pipeline writing to existing records, or a B2B sales motion where marketing analytics do not matter.
Setting up HubSpot credentials
Before either API accepts your request, you need a Private App token. In HubSpot, open Settings → Integrations → Private Apps → Create a private app. Name it "Laravel Form Sync". On the Scopes tab, pick what you need: forms for the Forms API path, crm.objects.contacts.write for the Contacts API path. If you might switch later, take both.
Hit Create, then copy the access token from the resulting modal. This is the only time you see it. Drop it into .env as HUBSPOT_TOKEN, reference it from config/services.php, and treat it like a password — scoped, rotatable, never logged. The older API-key auth in stale tutorials is deprecated; Private App tokens replaced it. If a tutorial tells you to put hapikey=... in your query string, the tutorial is from 2022 and the endpoint will reject you.
For the Forms API you also need the portalId (your HubSpot account ID, visible in any HubSpot URL) and the formGuid of the HubSpot form you want to submit against. Create that form in HubSpot first, even if you never display it on a page — its only job is to be a destination.
Wiring it to FilaForms via Forms API
FilaForms fires a FormSubmitted event on every submission. The listener POSTs to HubSpot's authenticated submissions endpoint with the Private App token as a Bearer header:
namespace App\Listeners;
use FilaForms\Core\Events\FormSubmitted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Http;
class SyncSubmissionToHubSpot implements ShouldQueue
{
public function handle(FormSubmitted $event): void
{
$data = $event->submission->data;
Http::withToken(config('services.hubspot.token'))
->post(sprintf(
'https://api.hsforms.com/submissions/v3/integration/secure/submit/%s/%s',
config('services.hubspot.portal_id'),
config('services.hubspot.form_guid'),
), [
'fields' => [
['objectTypeId' => '0-1', 'name' => 'email', 'value' => $data['email'] ?? ''],
['objectTypeId' => '0-1', 'name' => 'firstname', 'value' => $data['first_name'] ?? ''],
['objectTypeId' => '0-1', 'name' => 'lastname', 'value' => $data['last_name'] ?? ''],
['objectTypeId' => '0-1', 'name' => 'company', 'value' => $data['company'] ?? ''],
],
])
->throw();
}
}
The objectTypeId of 0-1 means contact — HubSpot uses internal IDs for object types, and contact is 0-1. The name values are HubSpot's internal property names, not the labels marketing sees in the UI. More on that in a moment.
ShouldQueue is non-negotiable. The form response should not block on a HubSpot round-trip, and the queue gives you free retry on transient failures. The listener sits on the same primitive that powers the queued delivery layer under every other integration in this series.
Wiring via Contacts API
The Contacts API path is shorter and uglier. POST to HubSpot's CRM Contacts endpoint with the same Bearer token, body shape changes:
Http::withToken(config('services.hubspot.token'))
->post('https://api.hubapi.com/crm/v3/objects/contacts', [
'properties' => [
'email' => $data['email'] ?? null,
'firstname' => $data['first_name'] ?? null,
'lastname' => $data['last_name'] ?? null,
'company' => $data['company'] ?? null,
],
])
->throw();
Cleaner code, fewer concepts. The contact lands in HubSpot, deduplicates against existing contacts by email, and shows up in the Contacts list. What does not happen: form-tracking analytics, original source attribution, conversion-path data, the Forms dashboard, any of HubSpot's marketing-side reporting that depends on a submission event having fired.
Use this when you know none of that matters — internal tools, partner-portal forms, enrichment jobs — or when you specifically want to bypass HubSpot's form layer for compliance reasons. Otherwise the Forms API is the right default.
Field mapping
HubSpot's internal property names are not the labels you see in the UI. "First Name" in the Contacts table is firstname in the API. Get them wrong and the request fails or, worse, succeeds while writing your data into the wrong column. Here is the everyday mapping for a lead-gen form:
| FilaForms field label | HubSpot internal property |
|---|---|
email |
|
| First Name | firstname |
| Last Name | lastname |
| Company | company |
| Phone | phone |
| Job Title | jobtitle |
| Website | website |
Custom properties — something like "Project Budget" or "Use Case" — have to exist in HubSpot before you can write to them. Create them in Settings → Properties → Contact properties, copy the internal name from the property details, then add it to your mapping. The API will silently drop unknown fields on the Forms API path and reject the whole record on the Contacts API path.
Edge cases
HubSpot's rate limits for Private Apps are documented but easy to miss. Free and Starter accounts get 100 requests per 10 seconds per app. Professional and Enterprise get 190. A burst of submissions — a webinar landing page, a newsletter blast linking to a contact form — can trip this. The queue handles it with backoff. A 429 response retries on its own.
HubSpot dedupes contacts by email automatically on both APIs. Submit the same email twice and HubSpot updates the existing contact instead of creating a duplicate. This is the right behaviour for lead-gen and means you do not need a "check before insert" step in your listener.
Token rotation matters. When somebody leaves the team, regenerate the Private App token and roll it through .env and the queue worker config — same lifecycle as a deploy key.
What we got wrong
Our first version posted straight to the Contacts API. The endpoint URL was shorter, the response was tidier, and the code passed review in an afternoon. Contacts appeared in HubSpot. Sales saw the leads. Everyone declared victory.
Two weeks later, marketing pulled the weekly attribution report and the column for "Source" on every new contact from our app was empty. No conversion path. No original page. No campaign tag. From HubSpot's reporting layer, these contacts had appeared by magic — the form-tracking event had never fired, because we had not gone through a HubSpot form. Marketing's quarterly content-attribution review was in three days. The fix took an afternoon: create a HubSpot form to be the destination, swap the endpoint to api.hsforms.com, ship the listener again. The lesson generalises past HubSpot: an integration that bypasses a downstream tool's tracking layer breaks attribution silently, and silent breakage is the worst kind. Read the reporting side of the destination tool before you pick the API.
Try it
If your sales pipeline lives in HubSpot, the Forms API path lands leads in front of the right people inside a minute of submission with attribution intact. If sales lives somewhere lighter, the Notion-as-CRM workflow is the same listener pattern aimed at a different destination — useful for teams who haven't outgrown a database-of-rows yet.
If you haven't set up FilaForms yet, you can set up FilaForms here. The pattern in this post rides on FilaForms' outgoing webhooks — one queued listener, one HTTP call, one decision about which API to send it to.