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
composer require clickclacks/clickclacks-phpPlain 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:
CLICKCLACKS_SERVER_KEY=cks_live_…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:
php artisan vendor:publish --tag=clickclacks-config| Variable | What it does |
|---|---|
CLICKCLACKS_SERVER_KEY | The server key. With none set, calls are accepted and dropped. |
CLICKCLACKS_HOST | The API host. Defaults to https://app.clickclacks.io. |
CLICKCLACKS_ENABLED | false turns sending off, for example in local development. |
CLICKCLACKS_QUEUE | true hands every batch to a queued job. |
CLICKCLACKS_QUEUE_CONNECTION | The queue connection for that job. |
CLICKCLACKS_QUEUE_NAME | The queue name for that job. |
CLICKCLACKS_LOG_CHANNEL | Where 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:
CLICKCLACKS_QUEUE=true
CLICKCLACKS_QUEUE_CONNECTION=redis # optional
CLICKCLACKS_QUEUE_NAME=analytics # optional// 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.
// 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([...]):
| Option | Default | What it does |
|---|---|---|
key | required | A secret server key, cks_live_…. A public pk_live_ key is refused. |
host | https://app.clickclacks.io | Custom tracker domains don’t serve the server API. |
flushAt | 100 | Items per batch, 1–500. Reaching it sends at once. |
maxQueueSize | 10000 | Items held in memory. When full, new items are dropped. |
maxRetries | 6 | Retries after the first attempt. |
requestTimeout | 10000 | Milliseconds before one request is abandoned and retried. |
shutdownTimeout | 10000 | Milliseconds the end-of-script flush keeps retrying. |
sync | false | Send every call before it returns. |
validate | false | Dry run with ?validate=true: nothing is stored or counted. |
strict | false | With ?strict=true the API refuses the whole batch if any item is invalid. |
onError | log | Receives a ClickClacksError. Without it, errors go to logger or error_log(). |
onWarning | log | Receives items the API accepted with a change, such as a dropped group trait. |
logger | none | A PSR-3 logger. |
throwOnError | false | Throw instead of reporting, for tests and scripts. |
httpClient | cURL | Any 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.
| Code | When |
|---|---|
item_errors | The API refused some items; each one comes with its index and insert_id. Never retried. |
request_rejected | The API refused the whole request, for example invalid_key. Never retried. |
request_failed | Every retry failed and the items were lost. |
queue_full | maxQueueSize was reached and a new item was dropped. |
flush_timeout / shutdown_timeout | A flush hit its deadline before everything was delivered. |
invalid_call | A call was malformed, such as no event name or an unknown key. Nothing was queued. |
item_too_large | One item was over 1 MiB. |
client_closed | A 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_idwhen it’s queued, and a retry resends the same bytes, so retrying never double counts. Pass your owninsertId, such as an invoice id, to make your own retries safe too. - Retries: network errors, timeouts,
408,429and5xxare retried with exponential backoff and jitter; aRetry-Afterfrom the API always wins. Other4xxanswers are never retried, and a413splits 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' => truechecks your events against the API without storing anything; add'strict' => trueto fail on any invalid item in CI.
Testing
Depend on ClickClacksInterface and pass the fake in tests. It sends nothing and records every call:
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:
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 inidentifytraits, and only if you need them. - Use opaque group ids, and never put secrets, tokens or passwords in properties.
$ipis kept only when the Source records IP addresses.$user_agentis 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.