snipform/php-sdk

Official PHP SDK for the Snipform API

Maintainers

Package info

github.com/SnipForm/snipform-php-sdk

Homepage

pkg:composer/snipform/php-sdk

Statistics

Installs: 19

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.0.9 2026-05-28 12:09 UTC

This package is auto-updated.

Last update: 2026-05-28 12:10:41 UTC


README

Official PHP SDK for the SnipForm API. Eloquent-flavoured query builder over the V2 endpoints.

composer require snipform/php-sdk

Quick start

use SnipForm\SnipForm;

$snipform = SnipForm::client('snipform_pat_xxx');

// List sessions matching a query — auto-paginated
foreach ($snipform->signals()
    ->last28Days()
    ->where('country', 'US')
    ->whereStartsWith('entry_path', '/blog')
    ->sessions() as $session
) {
    echo $session->entryPath.' from '.$session->source.PHP_EOL;
}

// Headline metrics for the same query
$metrics = $snipform->signals()
    ->last7Days()
    ->where('utm_content', 'pub_12345')   // an affiliate
    ->metrics();

echo "Sessions: {$metrics->sessions}, bounce: {$metrics->bounceRate}%";

Contents

Data Objects (DTOs)

Every typed return object extends SnipForm\Data\SnipFormDTO — a pure typed value object with public readonly fields. Two helpers:

$dto->toArray();   // public fields as an associative array
json_encode($dto); // implements JsonSerializable — same as toArray()

DTOs hold no original payload; if you need the raw API JSON, flip the resource into raw mode with asRaw().

Returning from a Laravel controller

DTOs and PaginatedCollection both implement JsonSerializable, so a Laravel (or any PSR-7) controller can return them directly:

public function dashboard(SnipForm $snipform)
{
    return $snipform->signals()->last7Days()->metrics();    // serializes to JSON
}

public function sessions(SnipForm $snipform)
{
    return $snipform->signals()->last7Days()->sessions();   // page 1 of Laravel paginator JSON
}

public function property(SnipForm $snipform)
{
    return $snipform->properties()->overview();             // PropertyOverview → typed JSON
}

A PaginatedCollection serializes as Laravel's standard pagination JSON (data, current_page, last_page, total, next_page_url, …). Only page 1 is serialized — iterate first if you want all pages.

Property

The token is scoped to a single SnipForm Property. Pull its identity + headline counts:

$property = $snipform->properties()->overview();

$property->id;             // string
$property->name;           // string
$property->domain;         // string
$property->hasSignals;     // bool — tracking has fired at least once
$property->state;          // string|null — raw state value
$property->stateName;      // string|null — human label
$property->counts;         // array — e.g. ['sessions' => 188862, 'forms' => 4, 'pages' => 2]

Signals query builder

The first argument to every where*() method is a public field id. Pass it as a SessionField enum case (IDE-discoverable, type-checked) or as a bare string (escape hatch, no SDK-side validation).

use SnipForm\Query\SessionField;

$snipform->signals()
    ->where(SessionField::COUNTRY, 'US')
    ->whereBetween(SessionField::TIME_ON_SITE, 60, 300)
    ->whereStartsWith(SessionField::ENTRY_PATH, '/blog')
    ->sessions();

// Strings still work — the SDK doesn't know the field's type without an
// enum case, so op/field mismatches won't be caught client-side:
$snipform->signals()->where('country', 'US')->sessions();

Field/subfield/type are resolved server-side from the id, so the wire stays small.

Type-safe operators: when you pass an enum case, the SDK validates the operator against the field's type and throws IncompatibleFieldOperator before HTTP:

$snipform->signals()->whereBetween(SessionField::COUNTRY, 0, 10);
// → IncompatibleFieldOperator: Operator `between` is not valid for field
//   `country` (type: keyword). Valid ops: equals, contains, starts_with,
//   regex, exists.

SessionField cases are grouped by concern: entry/exit page, referrer, tags, geo, browser/device/OS, bot detection, channel + UTM attribution, acquisition value, short links, forms, events, session metrics. Bare string fallback covers anything new the server adds before the enum catches up.

Method Op Use for
where($id, $value) equals equality (array value = IN)
orWhere(...) equals (where=or) OR clause
whereNot(...) equals (not=true) negate
orWhereNot(...) equals (where=or, not=true) OR negate
whereStartsWith($id, $v) starts_with prefix
whereContains($id, $v) contains substring
whereRegex($id, $pat) regex regex
whereGt / Gte / Lt / Lte gt / gte / lt / lte numeric comparison
whereBetween($id, $a, $b) between numeric range
whereExists($id) exists field is present
whereNotExists($id) exists (not=true) field is absent

Each clause posts as {id, op, value, where?, not?}. where and not are omitted at default values.

Periods

Use the typed shorthands for autocomplete, or pass a Period case to period().

->today()
->yesterday()
->last7Days()
->last28Days()
->monthToDate()
->yearToDate()
->last12Months()

// Custom date ranges — pick whichever form reads best
->between('2026-01-01', '2026-01-31')      // both at once
->customPeriod('2026-01-01', '2026-01-31') // same
->customPeriod()
    ->fromDate('2026-01-01')
    ->toDate('2026-01-31')                  // piecemeal

// Or via the enum:
use SnipForm\Query\Period;
->period(Period::LAST_28)
->period('last_28')                         // string also fine; validated upfront

Invalid period strings throw SnipForm\Exceptions\InvalidPeriodException immediately — no HTTP round-trip.

Sessions, lazy

->sessions() returns a PaginatedCollection you can iterate. Each iteration step pulls the next page transparently.

foreach ($snipform->signals()->where('device', 'mobile')->sessions() as $session) { ... }
$first  = $snipform->signals()->where('device', 'mobile')->sessions()->first();
$total  = $snipform->signals()->where('device', 'mobile')->sessions()->count();
$all    = $snipform->signals()->where('device', 'mobile')->sessions()->all(); // careful

Pages — explicit pagination

->page($n) returns a Data\Page object that carries the page's items plus the full Laravel paginator meta and navigation methods. One HTTP call gives you both — no separate ->count().

$page = $snipform->signals()->sessions(20)->page(2);

$page->items;          // SessionRow[]   (or array[] in asRaw)
$page->currentPage;    // 2
$page->lastPage;       // 5
$page->total;          // 230
$page->perPage;        // 20
$page->from;           // 21
$page->to;             // 40
$page->nextPageUrl;    // string|null
$page->prevPageUrl;    // string|null
$page->hasMore();      // bool
$page->isFirstPage();
$page->isLastPage();

// Navigation — each is one HTTP call, returns the related Page
$next  = $page->next();    // → Page 3, or null when on last page
$prev  = $page->prev();    // → Page 1, or null when on first page
$first = $page->first();
$last  = $page->last();

// Jump by URL — pass any of the paginator URLs (or a link from the
// Laravel-style `links` array) and the SDK parses the `page` query param.
$jump = $page->pageLink($page->nextPageUrl);
$jump = $page->pageLink('https://api.snipform.io/v2/.../sessions?page=7');

// Render numbered page links from Laravel's `links` collection
foreach ($page->raw()['links'] ?? [] as $link) {
    if ($link['url']) {
        $other = $page->pageLink($link['url']);
    }
}

Page is iterable, countable, and array-accessible — so existing foreach/count/$page[0] usage keeps working:

foreach ($snipform->signals()->sessions()->page(2) as $session) { ... }
$rowsOnThisPage = count($page);   // count of items on THIS page (not total)
$first = $page[0];

Returning a Page from a Laravel controller serializes it as the standard Laravel paginator JSON for that page:

public function sessions(SnipForm $snipform, Request $request)
{
    return $snipform->signals()
        ->last28Days()
        ->sessions(20)
        ->page((int) $request->input('page', 1));
}

Metrics

Returns a MetricsResult value object:

$m = $snipform->signals()->last28Days()->metrics();
$m->sessions;          // int
$m->views;             // int
$m->viewsPerSession;   // float
$m->bounceRate;        // float (0-100)
$m->duration;          // int (seconds)
$m->avgScroll;         // float (0-100)
$m->showing;           // human-readable date span
$m->tookMs;            // server query time

To reach trend data (previous-period, percent, difference), use asRaw().

Short links

Three resources: groups (folders), links (the short URLs themselves), and clicks (the redirect events). Scoped to the property your token belongs to.

Link groups

$groups   = $client->linkGroups()->all();                        // LinkGroup[]
$group    = $client->linkGroups()->find($id);                    // LinkGroup
$group    = $client->linkGroups()->create([
    'name' => 'Spring affiliates',
    'description' => 'Affiliate links for Q2',
    'purpose' => 'affiliate',
    'track_clicks' => true,
]);
$group    = $client->linkGroups()->update($id, ['name' => 'Spring 2026']);
$deleted  = $client->linkGroups()->delete($id);                  // bool, cascades the group's links

Links

// Paginated list — auto-walks every page
foreach ($client->links()->all() as $link) {
    echo $link->shortUrl.''.$link->destinationUrl.PHP_EOL;
}

// Filter by group
foreach ($client->links()->all(['group_id' => $groupId]) as $link) { ... }

$link = $client->links()->find($id);                             // Link
$link = $client->links()->create([
    'group_id' => $groupId,
    'destination_url' => 'https://example.com/landing',
    'domain' => 'snpf.io',
    'utm' => [
        'utm_source' => 'ofillio',
        'utm_medium' => 'affiliate',
        'utm_campaign' => 'spring_sale',
        'utm_content' => 'pub_12345',     // individual affiliate
    ],
]);
$link = $client->links()->update($id, [
    'destination_url' => 'https://example.com/new-landing',
    'is_active' => false,
]);
$client->links()->delete($id);

Each link exposes a small accessor for utm values:

$link->utm('utm_content');  // 'pub_12345' or null

Clicks

Read-only — clicks are recorded server-side from short-link redirects. The fluent filter builder chains until you call ->all() or ->find():

// Every click for one link, walking pages
foreach ($client->clicks()->forLink($linkId)->all() as $click) {
    echo $click->city.' on '.$click->device.PHP_EOL;
}

// Last 30 days of human clicks on a whole campaign
foreach ($client->clicks()
    ->forGroup($groupId)
    ->between(strtotime('-30 days'), time())
    ->usersOnly()
    ->all() as $click
) { ... }

// Just bot traffic
$bots = $client->clicks()->botsOnly()->all()->count();

// Single click
$click = $client->clicks()->find($clickId);

Filters:

Method Effect
forLink($id) scope to one short link
forGroup($id) scope to a link group
between($fromTs, $toTs) unix timestamp range
since($fromTs) open-ended range
usersOnly() exclude bot clicks
botsOnly() only bot clicks
perPage($n) page size, 1–100

Session actions

Three writes scoped to a single SignalSession: resolve a visitor's session id from their request, submit a custom event, and patch acquisition metadata.

Resolve

Looks up the SignalSession that belongs to a visitor — by hashing their IP + User-Agent + language with the same daily salt the JS tracker uses. The visitor must already have been tracked once today on this property for the lookup to find a match.

The SDK accepts a Symfony or Laravel Request and pulls those values for you. Pass $request from your controller:

// Laravel
public function handleVisitor(Request $request)
{
    $resolved = $snipform->session()->resolve($request);
    // → ResolveResult { resolved: true, sessionId: 'abc...', sid: 'hash...' }
}
// Symfony
public function handle(Request $request): Response
{
    $resolved = $snipform->session()->resolve($request);
}

Or pass values explicitly if you're not on a Symfony-flavoured framework:

$resolved = $snipform->session()->resolve([
    'ip'         => $myFramework->getClientIp(),
    'user_agent' => $myFramework->getUserAgent(),
    'lang'       => $myFramework->getAcceptLanguage(),
]);

$resolved->resolved is false if the visitor hasn't been tracked yet today on this property. Handle that case before chaining further writes.

Important: the ip must be the visitor's IP from your incoming request, not your server's outbound IP. Your framework's $request->getClientIp() / equivalent does the right thing automatically (resolves through proxies / CDNs). The SDK does not inspect the transport-level IP of its own outbound call.

Event

Submit a custom event for a session. Identifies the target session in one of two ways:

// Explicit session_id in the payload
$event = $snipform->session()->event([
    'session_id' => $resolved->sessionId,
    'name'       => 'purchase',
    'value'      => 99.99,            // optional
    'meta'       => ['order_id' => 'X-1', 'currency' => 'USD'],  // optional
]);

// Or pass the request — SDK reads session_id from the X-SnipForm-Session-Id
// header or `snip_session_id` body field (set by signals.js attachToFetch /
// attachToForm on the customer's page)
$event = $snipform->session()->event($request, [
    'name'  => 'purchase',
    'value' => 99.99,
]);

Returns a typed Event value object.

Acquisition

Patch acquisition metadata onto a session. Partial — only supplied keys are written. Tags merge with existing tags (deduped); cost / value / currency overwrite.

$snipform->session()->acquisition([
    'session_id'    => $resolved->sessionId,
    'cost'          => 250,          // optional, integer
    'value'         => 9900,         // optional, integer
    'currency_code' => 'USD',        // optional, ISO 4217
    'tags'          => ['affiliate'],// optional, merged
]);

// Or via Request, same as event()
$snipform->session()->acquisition($request, [
    'value' => 9900,
    'tags'  => ['paid'],
]);

Returns the resulting acquisition_meta array along with the session id.

Typical end-to-end flow

public function recordConversion(Request $request)
{
    $resolved = $snipform->session()->resolve($request);
    if (! $resolved->resolved) {
        return;  // visitor hasn't been tracked yet
    }

    $snipform->session()->event([
        'session_id' => $resolved->sessionId,
        'name'       => 'purchase',
        'value'      => $order->total,
    ]);

    $snipform->session()->acquisition([
        'session_id'    => $resolved->sessionId,
        'value'         => (int) ($order->total * 100),
        'currency_code' => $order->currency,
        'tags'          => ['paid'],
    ]);
}

Conversions

Two surfaces:

  • Definition CRUD — list / find / create / update / replaceSteps / publish / toggle / delete, plus a schema() lookup that returns the catalog of valid trigger types.
  • Analytics reads->for($id) opens a fluent reader: summary, segments, cycles, sessions-at-step.

Schema

Inspect the catalog of trigger types, conversion types, segment dimensions, and valid match modes before building a config:

$schema = $snipform->conversions()->schema();
// $schema['conversion_types']     — ['lead', 'sale', 'signup', 'activation', 'download', 'custom']
// $schema['trigger_types']        — full details per type (kind, defaults, fieldOptions, matchOptions, …)
// $schema['cycle_intervals']      — ['day', 'week', 'month']
// $schema['segment_dimensions']   — list of segmentable fields
// $schema['page_match_modes']     — ['contains', 'exact', 'starts_with', 'regex']
// $schema['event_value_match_modes'] — ['exists', 'equals', 'gt', 'gte', 'lt', 'lte']

Definition CRUD

$all = $snipform->conversions()->all();                  // Conversion[]
$c   = $snipform->conversions()->find($id);              // Conversion (with .steps populated)

Create a conversion via the fluent builder — each step() opens a sub-builder whose ->on*() method commits the step and returns the parent for chaining:

$conversion = $snipform->conversions()->create()
    ->name('Newsletter signup')
    ->description('Free trial sign-up flow')
    ->type('lead')
    ->conversionValue(5.00)
    ->defaultPeriod('last_28')
    ->defaultCycle('week')

    ->step('Visit pricing')->onPageView('/pricing')
    ->step('Click signup')->onEvent('signup_click')
    ->step('Submit form')->onFormSubmit($snipFormId)

    ->publish()           // optional — leaves it in 'draft' otherwise
    ->save();             // → Conversion

Step trigger terminals:

Method Triggers when
->onPageView($value, $match = 'contains', $field = 'path') Visitor reaches a page
->onEntryPage($value, $match = 'contains', $field = 'entry_path') Session entered on a page
->onEvent($name, $value = null, $valueMatch = 'exists') Custom event fires
->onFormSubmit($snipFormId) A specific SnipForm submits
->onShortLink($id, $scope = 'link') Session arrived via short link/group

Each step builder also exposes ->optional() to mark the step is_required: false.

Patch existing definitions:

$snipform->conversions()->update($id, [
    'name'             => 'Renamed',
    'conversion_value' => 12.5,
]);

// Replace the full steps list atomically
$snipform->conversions()->replaceSteps($id, [
    ['name' => 'Visit', 'trigger_type' => 'page_view',   'trigger_config' => ['type' => 'page',  'field' => 'path', 'match' => 'contains', 'value' => '/pricing']],
    ['name' => 'Buy',   'trigger_type' => 'event',       'trigger_config' => ['type' => 'event', 'name' => 'purchase', 'valueMatch' => 'exists']],
]);

$snipform->conversions()->publish($id);   // draft → active
$snipform->conversions()->toggle($id);    // active <-> paused
$snipform->conversions()->delete($id);    // bool

Analytics

->for($id) returns a ConversionAnalytics you chain a window onto, then call a terminal:

$reader = $snipform->conversions()->for($id)
    ->between(strtotime('-30 days'), time())
    ->filter(['channel_category' => 'paid_search']);   // optional

$summary = $reader->summary();
$summary->sessions;       // int
$summary->conversions;    // int
$summary->rate;           // float (0-100)
$summary->value;          // float|null — total attributed value
$summary->funnel;         // FunnelStep[] — per-step counts + drop_off

Slice by a flat dimension or a custom tag key:

$reader->segments('channel_category');      // ConversionSegment[]
$reader->segmentsByTag('campaign_phase');   // ConversionSegment[]

Cycle through day/week/month buckets with deltas vs the prior bucket:

$cycles = $reader->cycles('week', page: 0, perPage: 6);
// → ['cycles' => ConversionCycle[], 'has_more' => bool, 'page' => int, 'interval' => 'week']

foreach ($cycles['cycles'] as $c) {
    echo "{$c->label}: {$c->conversions}/{$c->sessions} = {$c->rate}% (Δ{$c->delta}%)\n";
}

Drill into sessions that reached a specific funnel step:

$step = $reader->sessionsAt($stepId, page: 1, perPage: 25);
// → ['sessions' => array[], 'page' => int, 'per_page' => int, 'total' => int, 'has_more' => bool]

Window setters: ->between($fromTs, $toTs) or ->since($fromTs) (open-ended to now). Both take unix timestamps.

asRaw() — opt-out of typed objects

Every resource (and every builder chain) supports ->asRaw(). Terminals return the underlying API array instead of hydrating a typed DTO. Useful when you want fields the SDK doesn't surface, or when you're forwarding API responses to a frontend that already expects the SnipForm JSON shape.

$client->properties()->overview();              // PropertyOverview
$client->properties()->asRaw()->overview();     // array

$client->signals()->last28Days()->metrics();              // MetricsResult
$client->signals()->last28Days()->asRaw()->metrics();     // array — analytics, meta, options

$client->signals()->last28Days()->sessions();             // PaginatedCollection<SessionRow>
$client->signals()->last28Days()->asRaw()->sessions();    // PaginatedCollection<array>

$client->linkGroups()->find($id);                         // LinkGroup
$client->linkGroups()->asRaw()->find($id);                // array

$client->conversions()->find($id);                        // Conversion
$client->conversions()->asRaw()->find($id);               // array

$client->conversions()->asRaw()->create()                 // ConversionBuilder (raw flag forwarded)
    ->name(...)->step('x')->onPageView(...)
    ->save();                                             // array

$client->conversions()->asRaw()->for($id)                 // ConversionAnalytics (raw flag forwarded)
    ->since(strtotime('-30 days'))
    ->summary();                                          // array

Each $client->resource() call returns a fresh instance, so flipping asRaw on one chain doesn't affect the next.

Authentication

The SDK takes a property-scoped Personal Access Token. Generate one in Property → Settings → API Tokens. Tokens carry scope (e.g. signals:read, conversions:write) — the SDK forwards them and the API enforces.

Error handling

use SnipForm\Exceptions\AuthenticationException;
use SnipForm\Exceptions\ApiException;
use SnipForm\Exceptions\InvalidPeriodException;
use SnipForm\Exceptions\MissingSessionIdException;
use SnipForm\Exceptions\SnipFormException;

try {
    $sessions = $snipform->signals()->last7Days()->sessions()->all();
} catch (InvalidPeriodException $e) {
    // SDK-side — invalid string passed to period(). Caught before any HTTP call.
} catch (MissingSessionIdException $e) {
    // SDK-side — session_id couldn't be resolved from the Request.
} catch (AuthenticationException $e) {
    // 401 / 403 — token bad or out of scope.
} catch (ApiException $e) {
    // 4xx / 5xx with a structured body — see $e->status, $e->errors, $e->body.
    // Validation errors are expanded into the message inline:
    //   "The given data was invalid. — period: must be one of ..."
} catch (SnipFormException $e) {
    // any other SDK-side failure (transport, JSON decode, etc.)
}

ApiException exposes:

Property Type Notes
->status int HTTP status code
->errors array Laravel-style ['field' => ['msg', ...]] — empty for non-validation errors
->body array Full unwrapped response body

Configuration

SnipForm::client('snipform_pat_xxx', [
    'base_url'    => 'https://api.snipform.io',  // default
    'path_prefix' => '/v2/',                      // default; older deployments may serve under '/api/v2/'
    'timeout'     => 30,                          // seconds, request timeout
    'verify_ssl'  => true,                        // default; set false for local self-signed certs
]);

Tests

The unit suite is hermetic — no network, no env config required:

composer install
vendor/bin/phpunit --testsuite=Unit

There's also a tests/Integration suite that hits a live SnipForm deployment. Copy the env template, fill in a token + base URL, then:

cp tests/.env.testing.example tests/.env.testing
# edit tests/.env.testing — set SNIPFORM_TEST_TOKEN
vendor/bin/phpunit

Integration tests are skipped when SNIPFORM_TEST_TOKEN isn't set, so CI without secrets still runs unit-only.