Skip to content

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 visitor
Snipform::revenue(9900, 'USD'); // acquisition value, integer cents
Snipform::identify($user); // contact, from your User model
  1. Install the SDK.

    Terminal window
    composer require snipform/php-sdk
  2. Add the property token to .env. Create one under Property → API in the dashboard; see Authentication & Tokens.

    Terminal window
    SNIPFORM_TOKEN=YOUR_PROPERTY_TOKEN
  3. 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>
  4. 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.

All keys are .env-driven. Publishing the file is only needed for the two keys without an env var.

KeyEnvDefaultNotes
tokenSNIPFORM_TOKENProperty API token
base_urlSNIPFORM_BASE_URLhttps://api.snipform.io
path_prefixSNIPFORM_PATH_PREFIX/v2/
timeoutSNIPFORM_TIMEOUT30Seconds
verify_sslSNIPFORM_VERIFY_SSLtrue
hydrate.enabledSNIPFORM_HYDRATE_ENABLEDtrueTurns off the directive, the middleware and the bind route
hydrate.modeSNIPFORM_HYDRATE_MODEcookiecookie or post
hydrate.cookie_nameSNIPFORM_HYDRATE_COOKIEsite_initializerCookie mode. Deliberately generic
hydrate.bind_urlSNIPFORM_HYDRATE_BIND_URL/snipform/bindPost mode. Must be same-origin
hydrate.bind_middleware['web']Post mode. Middleware on the bind route; web brings session and CSRF
events.dispatchSNIPFORM_EVENTS_DISPATCHafter_responsesync, after_response or queue
queue_nameSNIPFORM_QUEUEyour default queueQueue for any write in queue mode
identify.on_loginSNIPFORM_IDENTIFY_ON_LOGINtrueListen for Illuminate\Auth\Events\Login
identify.queueSNIPFORM_IDENTIFY_QUEUEafter_responseDispatch mode for identify; true/false still mean after_response/sync
identify.dedup_ttlSNIPFORM_IDENTIFY_DEDUP_TTL3600Seconds; 0 disables the dedup gate
identify.cache_storeSNIPFORM_IDENTIFY_CACHE_STOREdefault storeNamed cache store for the dedup gate

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
}

Both the macro and every facade write resolve the id in this order and return null when nothing matches:

  1. Laravel session, key snipform_session_id, stashed by the hydrate loader on an earlier request. Survives redirects, form posts and AJAX alike.
  2. X-SnipForm-Session-Id header, attached by the tracker’s signals.attachToFetch() on same-origin fetch and XHR.
  3. snip_session_id form field, injected by signals.attachToForm() on a classic form post.
  4. snip_session_id query 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.

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> written
Next navigation -> cookie travels with the request
HydrateCookieMiddleware -> session()->put('snipform_session_id', <sid>) + Set-Cookie expires it
From here on -> $request->snipformSessionId() returns it, no cookie involved

The provider also adds the cookie name to EncryptCookies::except(). The cookie is written by JavaScript, so Laravel must not try to decrypt it.

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.

  • 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.

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, meta
Snipform::event('newsletter_signup');
Snipform::revenue(9900, 'USD'); // acquisition value, integer cents
Snipform::acquisition(['value' => 9900, 'tags' => ['shop:order:10421']]); // value + order tag in one call

Each 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.

When the HTTP call happens is config, not code: snipform.events.dispatch.

ModeWhat happensUse when
after_response (default)Runs in the application’s terminating phase, after the response is sentAlmost always. Zero request latency, no worker needed
queueA real ShouldQueue job (SnipForm\Laravel\Jobs\SignalWriteJob) on your queue connection, queue name from snipform.queue_nameYou run workers and want retries and survival across deploys
syncInline; the caller waits for the APITests, CLI, or when you need the result back. Use Snipform::session()->event() for the typed Event
Terminal window
SNIPFORM_EVENTS_DISPATCH=after_response # sync | after_response | queue
SNIPFORM_QUEUE=snipform # optional, queue mode only

Under Octane or any long-running worker, after_response still runs per request: the terminating callbacks fire when each request completes.

  1. 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;
    }
  2. There is no step two. The provider listens for Illuminate\Auth\Events\Login and identifies the user against the session id from the hydrate loader.

The trait derives the payload from common columns:

Payload keyRead from
external_id$user->getKey()
emailemail
traits.first_nametraits.cityfirst_name, last_name, phone, company, job_title, website, country, city
traits.first_name / traits.last_name fallbackname, 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.

Snipform::identify($user); // any Identifiable model or a raw payload array (alias: user())
Snipform::auth(); // identify auth()->user(); no-op for guests
Snipform::auth('admin'); // a specific guard
Snipform::payload([ // raw pass-through - someone who is not the auth user
'email' => '[email protected]',
'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.

Three layers keep identify cheap enough to call on every request:

  1. Dispatch mode. identify.queue defaults to after_response, so login is never blocked.
  2. Dedup gate. The provider wires your cache via Cache::add as an atomic gate. The first call per (session, payload) per identify.dedup_ttl goes out; repeats are a cache hit. The gate runs inline; only the HTTP call defers.
  3. Server idempotency. Even without the gate, repeat identifies with the same payload merge in place.

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 guard

The facade exposes the SDK resources directly:

Snipform::signals()->last7Days()->metrics();
Snipform::contacts()->find($id);
Snipform::session()->event($request, ['name' => 'x']); // the typed Event, synchronously
Snipform::client(); // the full SnipForm\Client

Or 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"] } }

It lifts the id off the request and stores it on $request->attributes without touching the SDK:

app/Http/Kernel.php
protected $middlewareGroups = [
'web' => [
// ...
\SnipForm\Laravel\Middleware\SnipFormSessionMiddleware::class,
],
];
// any controller
$sessionId = $request->attributes->get(\SnipForm\Laravel\Middleware\SnipFormSessionMiddleware::ATTRIBUTE);