Laravel
The Laravel layer ships inside the PHP SDK and activates through package discovery. It gives you three things: the visitor’s session id on the server without any plumbing, a Snipform facade whose writes never block a response, and contacts that identify themselves on login.
use SnipForm\Laravel\Facades\Snipform;
Snipform::event('purchase', 9900, ['order' => $order->id]); // custom event on the current visitorSnipform::revenue(9900, 'USD'); // acquisition value, integer centsSnipform::identify($user); // contact, from your User model-
Install the SDK.
Terminal window composer require snipform/php-sdk -
Add the property token to
.env. Create one under Property → API in the dashboard; see Authentication & Tokens.Terminal window SNIPFORM_TOKEN=YOUR_PROPERTY_TOKEN -
Load the tracker and the hydrate directive in your root Blade layout (Inertia apps included: the root layout is still Blade).
<head>@snipformHydrate<script src="https://cdn.snipform.io/api/analytics/signals.js?site=YOUR_PROPERTY_KEY" defer></script></head> -
Optionally publish the config file.
Terminal window php artisan vendor:publish --tag=snipform-config
The provider binds SnipForm\Client, SnipForm\Laravel\SnipFormManager (the facade root) and their collaborators as singletons, registers the Snipform alias, the snipform.identify middleware alias, the @snipformHydrate directive and the $request->snipformSessionId() macro. Apps that configured the SDK under config/services.php → snipform before this layer existed keep working; config/snipform.php wins where both are set.
Configuration
Section titled “Configuration”All keys are .env-driven. Publishing the file is only needed for the two keys without an env var.
| Key | Env | Default | Notes |
|---|---|---|---|
token | SNIPFORM_TOKEN | Property API token | |
base_url | SNIPFORM_BASE_URL | https://api.snipform.io | |
path_prefix | SNIPFORM_PATH_PREFIX | /v2/ | |
timeout | SNIPFORM_TIMEOUT | 30 | Seconds |
verify_ssl | SNIPFORM_VERIFY_SSL | true | |
hydrate.enabled | SNIPFORM_HYDRATE_ENABLED | true | Turns off the directive, the middleware and the bind route |
hydrate.mode | SNIPFORM_HYDRATE_MODE | cookie | cookie or post |
hydrate.cookie_name | SNIPFORM_HYDRATE_COOKIE | site_initializer | Cookie mode. Deliberately generic |
hydrate.bind_url | SNIPFORM_HYDRATE_BIND_URL | /snipform/bind | Post mode. Must be same-origin |
hydrate.bind_middleware | ['web'] | Post mode. Middleware on the bind route; web brings session and CSRF | |
events.dispatch | SNIPFORM_EVENTS_DISPATCH | after_response | sync, after_response or queue |
queue_name | SNIPFORM_QUEUE | your default queue | Queue for any write in queue mode |
identify.on_login | SNIPFORM_IDENTIFY_ON_LOGIN | true | Listen for Illuminate\Auth\Events\Login |
identify.queue | SNIPFORM_IDENTIFY_QUEUE | after_response | Dispatch mode for identify; true/false still mean after_response/sync |
identify.dedup_ttl | SNIPFORM_IDENTIFY_DEDUP_TTL | 3600 | Seconds; 0 disables the dedup gate |
identify.cache_store | SNIPFORM_IDENTIFY_CACHE_STORE | default store | Named cache store for the dedup gate |
The visitor’s session id
Section titled “The visitor’s session id”The tracker resolves a session id in the browser. It is the only place the id originates; there is no server-side lookup by IP or user agent. @snipformHydrate gets that value into your Laravel session once per visit, after which every request can read it:
public function checkout(Request $request){ $sessionId = $request->snipformSessionId(); // string|null $sessionId = Snipform::sessionId(); // same thing, via the facade}The four-tier chain
Section titled “The four-tier chain”Both the macro and every facade write resolve the id in this order and return null when nothing matches:
- Laravel session, key
snipform_session_id, stashed by the hydrate loader on an earlier request. Survives redirects, form posts and AJAX alike. X-SnipForm-Session-Idheader, attached by the tracker’ssignals.attachToFetch()on same-origin fetch and XHR.snip_session_idform field, injected bysignals.attachToForm()on a classic form post.snip_session_idquery string, appended by hand.
Tier 1 is what the hydrate loader fills. Tiers 2 to 4 are the session handoff transports and work without the loader.
Cookie mode and post mode
Section titled “Cookie mode and post mode”The directive emits a tiny inline script. Which tracker helper it calls depends on hydrate.mode:
Browser side, the directive renders:
window.signals.cookieTo("site_initializer", { skipIfMatch: "" })The tracker writes the session id into a transient first-party cookie (SameSite=Lax, Path=/). On the visitor’s next request HydrateCookieMiddleware, appended to the web group by the provider, copies the value into the Laravel session and expires the cookie on the response. One round-trip, no extra HTTP call, and the cookie is gone from the browser afterwards.
Tracker boots -> cookie site_initializer=<sid> writtenNext navigation -> cookie travels with the requestHydrateCookieMiddleware -> session()->put('snipform_session_id', <sid>) + Set-Cookie expires itFrom here on -> $request->snipformSessionId() returns it, no cookie involvedThe provider also adds the cookie name to EncryptCookies::except(). The cookie is written by JavaScript, so Laravel must not try to decrypt it.
Browser side, the directive renders:
window.signals.bindTo("/snipform/bind", { skipIfMatch: "" })The tracker POSTs { session_id } once to POST /snipform/bind (route name snipform.bind, middleware from hydrate.bind_middleware). BindController validates the id and stashes it in the Laravel session, returning 204. Keep the standard CSRF meta tag in your layout so the request carries a token:
<meta name="csrf-token" content="{{ csrf_token() }}">Use this mode when something between the browser and Laravel strips or rewrites cookies. It costs one extra request per visit.
In both modes the server bakes the currently stashed id into skipIfMatch, so once delivery has succeeded a reload does nothing: no cookie write, no POST. The loader polls every 50ms until window.signals exists, so the order of the two tags in <head> does not matter.
Honest limits
Section titled “Honest limits”- The very first server-rendered request of a visit has no session id yet; the tracker has not run. Every request after that does.
- Visitors with JavaScript disabled never trigger the loader, so their id never reaches your backend. Writes for them return
false; identify still creates the contact, just without a session link. - The cookie is transport, not storage. It holds the id for one round-trip and is never read by anything but your own app. If your privacy notice enumerates cookies, it belongs in the same “strictly necessary” bucket as the CSRF cookie.
Firing events
Section titled “Firing events”With the id in the Laravel session, writes need no request plumbing:
use SnipForm\Laravel\Facades\Snipform;
Snipform::event('purchase', 9900, ['order' => $order->id]); // name, value, metaSnipform::event('newsletter_signup');Snipform::revenue(9900, 'USD'); // acquisition value, integer centsSnipform::acquisition(['value' => 9900, 'tags' => ['shop:order:10421']]); // value + order tag in one callEach returns true when the write was dispatched and false when no session id resolved: a JS-disabled visitor, a bot, or the first request before the tracker booted. That is a normal outcome, not an exception. Your checkout never breaks because analytics could not see the visitor.
When you already hold the id (stored on an order at checkout via Snipform::sessionId()), use the *For() variants from anywhere: queued jobs, webhooks, artisan commands.
Snipform::eventFor($order->snipform_session_id, 'order_shipped');Snipform::acquisitionFor($order->snipform_session_id, ['tags' => ['repeat']]);Pass a Request as the last argument to event(), acquisition() or revenue() when you are outside the request lifecycle but have one in hand.
Dispatch modes
Section titled “Dispatch modes”When the HTTP call happens is config, not code: snipform.events.dispatch.
| Mode | What happens | Use when |
|---|---|---|
after_response (default) | Runs in the application’s terminating phase, after the response is sent | Almost always. Zero request latency, no worker needed |
queue | A real ShouldQueue job (SnipForm\Laravel\Jobs\SignalWriteJob) on your queue connection, queue name from snipform.queue_name | You run workers and want retries and survival across deploys |
sync | Inline; the caller waits for the API | Tests, CLI, or when you need the result back. Use Snipform::session()->event() for the typed Event |
SNIPFORM_EVENTS_DISPATCH=after_response # sync | after_response | queueSNIPFORM_QUEUE=snipform # optional, queue mode onlyUnder Octane or any long-running worker, after_response still runs per request: the terminating callbacks fire when each request completes.
Identify
Section titled “Identify”Auto-identify on login
Section titled “Auto-identify on login”-
Add the trait to your user model.
use Illuminate\Foundation\Auth\User as Authenticatable;use SnipForm\Laravel\Concerns\Identifiable;class User extends Authenticatable{use Identifiable;} -
There is no step two. The provider listens for
Illuminate\Auth\Events\Loginand identifies the user against the session id from the hydrate loader.
The trait derives the payload from common columns:
| Payload key | Read from |
|---|---|
external_id | $user->getKey() |
email | email |
traits.first_name … traits.city | first_name, last_name, phone, company, job_title, website, country, city |
traits.first_name / traits.last_name fallback | name, split on the first whitespace |
Override either side with hooks on the model:
class User extends Authenticatable{ use Identifiable;
protected function snipformExternalId(): string { return 'usr_'.$this->uuid; }
// Merged over the auto-derived traits protected function snipformTraits(): array { return [ 'company' => $this->team?->name, 'meta' => [['key' => 'plan', 'value' => $this->subscription_plan]], ]; }}Models that do not use the trait can implement SnipForm\Laravel\Contracts\Identifiable and return the payload from snipformPayload() themselves. A model with neither is skipped silently.
The facade
Section titled “The facade”Snipform::identify($user); // any Identifiable model or a raw payload array (alias: user())Snipform::auth(); // identify auth()->user(); no-op for guestsSnipform::auth('admin'); // a specific guardSnipform::payload([ // raw pass-through - someone who is not the auth user 'traits' => ['first_name' => 'Jane'],]);All return true when a write was dispatched and false when the dedup gate short-circuited or no payload could be derived. If the payload has no session_id, the current visitor’s id is merged in when one resolves.
Cost control
Section titled “Cost control”Three layers keep identify cheap enough to call on every request:
- Dispatch mode.
identify.queuedefaults toafter_response, so login is never blocked. - Dedup gate. The provider wires your cache via
Cache::addas an atomic gate. The first call per(session, payload)peridentify.dedup_ttlgoes out; repeats are a cache hit. The gate runs inline; only the HTTP call defers. - Server idempotency. Even without the gate, repeat identifies with the same payload merge in place.
Identify middleware
Section titled “Identify middleware”Token-auth APIs and SPAs on a Sanctum cookie never fire the Login event. Apply snipform.identify to the routes instead; the dedup gate makes it one cache hit per request once warm.
Route::middleware(['auth:sanctum', 'snipform.identify'])->group(function () { Route::get('/me', UserController::class);});
Route::middleware('snipform.identify:web')->group(...); // a specific guardReads and the raw client
Section titled “Reads and the raw client”The facade exposes the SDK resources directly:
Snipform::signals()->last7Days()->metrics();Snipform::contacts()->find($id);Snipform::session()->event($request, ['name' => 'x']); // the typed Event, synchronouslySnipform::client(); // the full SnipForm\ClientOr inject the singleton Client and skip the facade:
public function dashboard(\SnipForm\Client $snipform){ return $snipform->signals()->last7Days()->metrics();}Query building, DTOs and errors are the same as everywhere else: Querying Signals, Resources, Errors.
To register the facade alias without importing it, add to config/app.php:
'aliases' => Facade::defaultAliases()->merge([ 'Snipform' => \SnipForm\Laravel\Facades\Snipform::class,])->toArray(),To take over wiring entirely, disable discovery in your app’s composer.json:
"extra": { "laravel": { "dont-discover": ["snipform/php-sdk"] } }SnipFormSessionMiddleware
Section titled “SnipFormSessionMiddleware”It lifts the id off the request and stores it on $request->attributes without touching the SDK:
protected $middlewareGroups = [ 'web' => [ // ... \SnipForm\Laravel\Middleware\SnipFormSessionMiddleware::class, ],];
// any controller$sessionId = $request->attributes->get(\SnipForm\Laravel\Middleware\SnipFormSessionMiddleware::ATTRIBUTE);