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

  1. Open Sources, click Add source and choose Server.
  2. Name it Billing backend and click Create server source.
  3. 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

terminal
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:

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

app/Http/Controllers/BillingWebhookController.php
<?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.
  • $revenue needs $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
// 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:

.env
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
# .env on your laptop
CLICKCLACKS_ENABLED=false

With 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:

tests/Feature/BillingWebhookTest.php
<?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

  1. Deploy, then pay a test invoice (or replay a webhook from your provider’s dashboard).
  2. Open the Billing backend source in Sources.
  3. Open Events, pick Invoice paid, and expand the newest row.
  4. 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.

webhook.php
<?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 insertId on webhooks. Without one, every re-delivery that lands is stored again, and revenue doubles.
  • Testing against production from a laptop. Set CLICKCLACKS_ENABLED=false locally, and use a fake in tests.
  • A public pk_live_ key. That is a browser key and the SDK refuses it. Server keys start cks_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.