PHP and Laravel

Send events, identify calls and company traits from PHP with the official SDK. It batches, gzips and retries safely, never throws into your app, and in Laravel it sends after the response has gone.

Updated

clickclacks/clickclacks-php is the PHP twin of the Node SDK, for the same server API. It runs on PHP 8.1 and later with no dependencies beyond ext-curl and ext-json. The source, changelog and issues are on GitHub.

Install

terminal
composer require clickclacks/clickclacks-php

Plain PHP

index.php
use ClickClacks\Client;

$clickclacks = new Client(['key' => getenv('CLICKCLACKS_SERVER_KEY')]);

$clickclacks->track([
    'event' => 'Subscription started',
    'distinctId' => 'user_8412',
    'properties' => ['plan' => 'pro', '$revenue' => 49, '$currency' => 'USD'],
]);

$clickclacks->identify([
    'distinctId' => 'user_8412',
    'anonymousId' => 'per_k3J9sQ1xR2', // optional: the browser key, to join its history
    'properties' => ['plan' => 'pro'],
]);

track, identify and group return at once. Items are queued and sent in batches: when the queue reaches flushAt, when you call $clickclacks->flush(), and automatically when the script ends.

Laravel

The service provider and the ClickClacks facade are discovered automatically. Add the key to .env:

.env
CLICKCLACKS_SERVER_KEY=cks_live_…
app/Http/Controllers/InvoiceController.php
use ClickClacks\Laravel\Facades\ClickClacks;

ClickClacks::track([
    'event' => 'Invoice paid',
    'distinctId' => (string) $user->id,
    'properties' => ['amount_cents' => $invoice->amount_cents],
]);

Calls are held in memory and sent after the response has been sent. They are also sent after every queued job and console command, and after each Octane request, so a long-running worker never carries events or state from one unit of work to the next. Prefer injection? Type-hint ClickClacks\ClickClacksInterface. To change the defaults, publish the config:

terminal
php artisan vendor:publish --tag=clickclacks-config
VariableWhat it does
CLICKCLACKS_SERVER_KEYThe server key. With none set, calls are accepted and dropped.
CLICKCLACKS_HOSTThe API host. Defaults to https://app.clickclacks.io.
CLICKCLACKS_ENABLEDfalse turns sending off, for example in local development.
CLICKCLACKS_QUEUEtrue hands every batch to a queued job.
CLICKCLACKS_QUEUE_CONNECTIONThe queue connection for that job.
CLICKCLACKS_QUEUE_NAMEThe queue name for that job.
CLICKCLACKS_LOG_CHANNELWhere delivery problems are logged. Defaults to your default channel.

Queued sending

With queued mode on, batches are handed to a queued job instead of being sent from the web process, so no request ever waits on the network:

.env
CLICKCLACKS_QUEUE=true
CLICKCLACKS_QUEUE_CONNECTION=redis   # optional
CLICKCLACKS_QUEUE_NAME=analytics     # optional
php
// Queue just this call, even when queued mode is off:
ClickClacks::queue()->track(['event' => 'Export finished', 'distinctId' => 'user_8412']);

// Send from this process, even when queued mode is on:
ClickClacks::now()->track(['event' => 'Export finished', 'distinctId' => 'user_8412']);

Every item gets its insert_id before it is queued, so a job that runs twice never counts an event twice. When a batch fails for a reason worth retrying (a network error, 429 or 5xx), the job sends again only the items that failed, with a growing delay, up to three times.

Example: a CRM

Say your product is a CRM and one of your customers is a company called Jay’s Plumbing, with employees who log in. People are identified by your user id, and the company is a group. group records the company’s traits; an event counts for the company when it carries it in groups.

php
// When Jay's Plumbing signs up, or changes plan: record the company's traits.
// The newest call replaces the whole set, so send every trait you want shown.
$clickclacks->group([
    'groupType' => 'company',
    'groupId' => 'cmp_311',            // your company ID, not a name or a domain
    'properties' => [
        'name' => "Jay's Plumbing",
        'plan' => 'pro',
        'seats' => 12,
        'industry' => 'Trades',
    ],
]);

// When an employee signs up or their profile changes: record the person's traits.
$clickclacks->identify([
    'distinctId' => 'user_8412',       // your user ID
    'properties' => ['role' => 'owner', 'company_id' => 'cmp_311'],
]);
$clickclacks->identify([
    'distinctId' => 'user_8413',
    'properties' => ['role' => 'dispatcher', 'company_id' => 'cmp_311'],
]);

// Events count for the company when they carry it in groups.
$clickclacks->track([
    'event' => 'Job scheduled',
    'distinctId' => 'user_8413',
    'properties' => ['job_type' => 'boiler service', 'value_cents' => 18000],
    'groups' => ['company' => 'cmp_311'],
]);

$clickclacks->track([
    'event' => 'Invoice paid',
    'distinctId' => 'user_8412',
    'insertId' => 'inv_2291',          // your invoice ID: a retried webhook still counts once
    'properties' => ['$revenue' => 180, '$currency' => 'USD'],
    'groups' => ['company' => 'cmp_311'],
]);

A group type is 1–64 characters of a–z, 0–9 and _, and a project has at most five. Use your own opaque ids (cmp_311), not company names, domains or email addresses. Traits that look personal, such as an email address or a phone number, are dropped by default and come back as a group_trait_dropped warning.

Configuration

Pass options to new ClickClacks\Client([...]):

OptionDefaultWhat it does
keyrequiredA secret server key, cks_live_…. A public pk_live_ key is refused.
hosthttps://app.clickclacks.ioCustom tracker domains don’t serve the server API.
flushAt100Items per batch, 1–500. Reaching it sends at once.
maxQueueSize10000Items held in memory. When full, new items are dropped.
maxRetries6Retries after the first attempt.
requestTimeout10000Milliseconds before one request is abandoned and retried.
shutdownTimeout10000Milliseconds the end-of-script flush keeps retrying.
syncfalseSend every call before it returns.
validatefalseDry run with ?validate=true: nothing is stored or counted.
strictfalseWith ?strict=true the API refuses the whole batch if any item is invalid.
onErrorlogReceives a ClickClacksError. Without it, errors go to logger or error_log().
onWarninglogReceives items the API accepted with a change, such as a dropped group trait.
loggernoneA PSR-3 logger.
throwOnErrorfalseThrow instead of reporting, for tests and scripts.
httpClientcURLAny PSR-18 client, such as Guzzle.

Parameter keys are camelCase, like the Node SDK’s: distinctId, anonymousId, sessionId, insertId, groupType. An unknown key is reported, so a typo doesn’t go unnoticed.

Errors

The SDK never throws into your app for a bad call or a delivery problem. Each one reaches onError (or your logger) as a ClickClacksError with a stable code, and flush() also returns what happened. The API key never appears in an error or a log line.

CodeWhen
item_errorsThe API refused some items; each one comes with its index and insert_id. Never retried.
request_rejectedThe API refused the whole request, for example invalid_key. Never retried.
request_failedEvery retry failed and the items were lost.
queue_fullmaxQueueSize was reached and a new item was dropped.
flush_timeout / shutdown_timeoutA flush hit its deadline before everything was delivered.
invalid_callA call was malformed, such as no event name or an unknown key. Nothing was queued.
item_too_largeOne item was over 1 MiB.
client_closedA call arrived after shutdown().

How delivery works

  • Batching: up to 500 items and 1 MiB per request, sent in order. Bodies over 1 KiB are gzipped.
  • Idempotency: every item gets an insert_id when it’s queued, and a retry resends the same bytes, so retrying never double counts. Pass your own insertId, such as an invoice id, to make your own retries safe too.
  • Retries: network errors, timeouts, 408, 429 and 5xx are retried with exponential backoff and jitter; a Retry-After from the API always wins. Other 4xx answers are never retried, and a 413 splits the batch.
  • Deadlines: the end-of-script flush stops retrying after 10 seconds, and flush($timeout) takes its own deadline, so a slow network can’t hold a process forever.
  • Dry runs: 'validate' => true checks your events against the API without storing anything; add 'strict' => true to fail on any invalid item in CI.

Testing

Depend on ClickClacksInterface and pass the fake in tests. It sends nothing and records every call:

tests/BillingTest.php
use ClickClacks\Testing\FakeClient;

$clickclacks = new FakeClient();   // implements ClickClacks\ClickClacksInterface
(new Billing($clickclacks))->pay($invoice);

$clickclacks->assertTracked('Invoice paid');
$clickclacks->assertGrouped('company', 'cmp_311');

In Laravel, swap the facade for the fake:

tests/Feature/InvoiceTest.php
ClickClacks::fake();

$this->post('/invoices/2291/pay')->assertOk();

ClickClacks::assertTracked('Invoice paid', fn (array $call) => $call['properties']['amount_cents'] === 4900);
ClickClacks::assertIdentified('user_8412');

Privacy

  • Never send personal data you don’t need. Use an opaque internal user id as distinctId, not an email address; put an email or name only in identify traits, and only if you need them.
  • Use opaque group ids, and never put secrets, tokens or passwords in properties.
  • $ip is kept only when the Source records IP addresses. $user_agent is used for browser, OS, device and bot filtering, then dropped.

The item model, limits and every error code are in the server-side tracking guide and the API reference. Found a problem with the SDK? Open an issue on GitHub.