Server-side tracking with Laravel
Acme CRM’s billing runs in a Laravel app that receives payment webhooks. This walkthrough adds the ClickClacks PHP SDK, records every paid invoice with its revenue exactly once, and tests it, without making a single request wait on analytics.
Updated
The goal
By the end, every paid invoice arrives in ClickClacks as Invoice paid with $revenue, on the same person as that customer’s browser events, and a feature test proves it. You need PHP 8.1 or later with ext-curl and ext-json. The Laravel integration needs Laravel 11 or later.
1. Add a server source and key
- Open Sources, click Add source and choose Server.
- Name it Billing backend and click Create server source.
- Create a key (for example Production) and copy it from Copy your server key. It starts
cks_live_and is shown once.
Already sending from Node? Give each backend its own server source, so its health and keys are separate. People are joined across every source in the project.
2. Install the SDK
composer require clickclacks/clickclacks-php The service provider and the ClickClacks facade are discovered automatically.
3. Configure the key
Add the key to the environment, never to the repository:
CLICKCLACKS_SERVER_KEY=cks_live_example That is all the configuration most apps need. Every setting, and php artisan vendor:publish --tag=clickclacks-config to change the defaults, is in the PHP SDK reference.
4. Track from a webhook
Track after the payment is recorded, with your user ID as distinctId. Payment providers deliver webhooks more than once, so derive the insertId from the invoice: a repeat with the same insertId is stored once.
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use ClickClacks\Laravel\Facades\ClickClacks;
use Illuminate\Http\Request;
class BillingWebhookController extends Controller
{
public function __invoke(Request $request)
{
// Verify the provider's signature and record the payment first.
$invoice = Invoice::where('provider_id', $request->input('data.object.id'))->firstOrFail();
$invoice->markPaid();
ClickClacks::track([
'event' => 'Invoice paid',
'distinctId' => (string) $invoice->user_id, // the same ID the browser identifies with
'insertId' => "inv_{$invoice->id}_paid", // a re-delivered webhook still counts once
'timestamp' => $invoice->paid_at, // a Carbon date is fine
'properties' => [
'amount_cents' => $invoice->amount_cents,
'plan' => $invoice->plan,
'$revenue' => $invoice->amount_cents / 100,
'$currency' => 'USD',
],
]);
return response()->noContent();
}
}- Parameter keys are camelCase (
distinctId,insertId). An unknown key is reported, so a typo doesn’t go unnoticed. - An integer user ID is turned into a string for you, but cast it anyway so the ID looks the same everywhere.
$revenueneeds$currency, three upper-case letters. Other properties that start with$are mostly refused; see Properties.- Timestamps are accepted from 7 days back to 10 minutes ahead. A webhook replayed after a longer outage is refused with
timestamp_too_old.
5. Identify on plan changes
The billing service is the source of truth for a customer’s plan, so set it as a trait from here. A person’s traits come from their newest identify, and identify calls are free.
// app/Listeners/RecordPlanChange.php
ClickClacks::identify([
'distinctId' => (string) $user->id,
'properties' => ['plan' => $user->plan, 'seats' => $user->seats],
]);
ClickClacks::track([
'event' => 'Plan upgraded',
'distinctId' => (string) $user->id,
'properties' => ['from' => $event->from, 'to' => $event->to, 'seats' => $user->seats],
]); If the browser also sends Plan upgraded, remove one of them. The same event from two places counts twice.
6. When events are sent
Calls return at once and are held in memory. Laravel sends them after the response has gone to the caller, and after every queued job, console command and Octane request, so a long-running worker never carries events from one unit of work to the next.
To keep the network off your web processes entirely, turn on queued sending. Batches are handed to a queued job, and a batch that fails for a reason worth retrying is sent again, up to three times:
CLICKCLACKS_QUEUE=true
CLICKCLACKS_QUEUE_CONNECTION=redis # optional
CLICKCLACKS_QUEUE_NAME=analytics # optional Every item gets its insert_id before it is queued, so a job that runs twice never counts an event twice. ClickClacks::queue() and ClickClacks::now() choose per call; see Queued sending.
7. Local development
Keep development traffic out of your data by turning sending off on your laptop. Calls are then accepted and dropped, so your code runs unchanged:
# .env on your laptop
CLICKCLACKS_ENABLED=falseWith no CLICKCLACKS_SERVER_KEY set at all, calls are also accepted and dropped.
8. Test it
ClickClacks::fake() swaps the facade for a fake that sends nothing and records every call, so you can assert on what your code tracked:
<?php
use App\Models\Invoice;
use ClickClacks\Laravel\Facades\ClickClacks;
it('tracks a paid invoice once', function () {
ClickClacks::fake();
$invoice = Invoice::factory()->create(['amount_cents' => 4900]);
$this->postJson('/webhooks/billing', webhookPayload($invoice))->assertNoContent();
ClickClacks::assertTracked(
'Invoice paid',
fn (array $call) => $call['properties']['amount_cents'] === 4900,
1, // exactly once
);
}); The fake also has assertNotTracked, assertNothingTracked and assertIdentified. Outside Laravel, depend on ClickClacks\ClickClacksInterface and pass ClickClacks\Testing\FakeClient in tests.
9. Check it arrived
- Deploy, then pay a test invoice (or replay a webhook from your provider’s dashboard).
- Open the Billing backend source in Sources.
- Open Events, pick
Invoice paid, and expand the newest row. - Replay the same webhook again, then refresh.
Nothing there? Check your log channel (CLICKCLACKS_LOG_CHANNEL, or your default one): delivery problems are logged with a stable code such as request_rejected or item_errors. The SDK never throws into your app. Codes are listed in Errors.
Not using Laravel?
The same SDK works in plain PHP. Items are sent in batches: when flushAt items are waiting, when you call flush(), and when the script ends. Under PHP-FPM, call fastcgi_finish_request() first so visitors never wait on it.
<?php
use ClickClacks\Client;
$clickclacks = new Client(['key' => getenv('CLICKCLACKS_SERVER_KEY')]);
$clickclacks->track([
'event' => 'Invoice paid',
'distinctId' => 'user_4821',
'insertId' => 'inv_2291_paid',
'properties' => ['amount_cents' => 4900, '$revenue' => 49, '$currency' => 'USD'],
]);
// Sent automatically when the script ends; flush() sends now.Common mistakes
- An email as
distinctId. Emails change, and they split people when the browser identifies with a user ID. Use the stable user ID everywhere. - No
insertIdon webhooks. Without one, every re-delivery that lands is stored again, and revenue doubles. - Testing against production from a laptop. Set
CLICKCLACKS_ENABLED=falselocally, and use a fake in tests. - A public
pk_live_key. That is a browser key and the SDK refuses it. Server keys startcks_live_.
Next
Acme now tracks from the browser, the API and billing. Make sure all three land on the same person: One person across browser and backend.