# Server-side tracking with Laravel

> A step-by-step walkthrough of clickclacks/clickclacks-php in Laravel: a server source and key, the ClickClacks facade in a billing webhook, identify, local development, queued sending, feature tests with the fake, and checking events arrive.

- Canonical URL: https://clickclacks.io/docs/walkthroughs/laravel
- Section: Walkthroughs
- Last updated: 2026-09-26

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.

## The goal {#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 {#source}

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.

> **What you should see**
>
> The source’s page with the status _Waiting for the first event_, and your key listed under **Secret keys** by its first characters and last four.

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 {#install}

```bash title="terminal"
composer require clickclacks/clickclacks-php
```

The service provider and the `ClickClacks` facade are discovered automatically.

## 3. Configure the key {#configure}

Add the key to the environment, never to the repository:

```bash title=".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](https://clickclacks.io/docs/php.md#laravel).

## 4. Track from a webhook {#track}

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 title="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](https://clickclacks.io/docs/api.md#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 {#identify}

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.

```php title="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 {#when}

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:

```bash title=".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](https://clickclacks.io/docs/php.md#queue).

## 7. Local development {#local}

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:

```bash title=".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 {#test}

`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 title="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 {#verify}

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.

> **What you should see**
>
> The source reads _Receiving_, and the **Server API health** card shows which SDKs are calling. In Events, `Invoice paid` shows your `amount_cents` and revenue. After the replay there is still one event for that invoice, because the `insertId` matched.

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](https://clickclacks.io/docs/php.md#errors).

## Not using Laravel? {#plain}

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 title="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 {#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 {#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](https://clickclacks.io/docs/walkthroughs/one-person.md).

## Related {#related}

- [PHP and Laravel SDK](https://clickclacks.io/docs/php.md): Configuration, errors, delivery and testing.
- [Server-side tracking](https://clickclacks.io/docs/server-side.md): Why server-side, and how keys work.
- [Send events](https://clickclacks.io/docs/api.md): Items, limits and every error code.
- [Any language over HTTP](https://clickclacks.io/docs/walkthroughs/http.md): The same API with curl.
