Skip to content
← Back to blog
Guides August 1, 2026· 9 min read

Adding analytics to a Laravel app without a cookie banner

Cookieless analytics in Laravel 10/11/12: the Blade directive, server-side conversion tracking, and the attribution trap in queued jobs.

Contents

It goes the same way every time. You want to measure traffic on a Laravel app, you go looking for how, and you land squarely in consent-banner tutorials: install a consent management package, drop the Google Analytics tag into the Blade layout, write the middleware that only renders the tag once the visitor accepts, persist the choice in a cookie. In every one of those guides the banner is where the work starts. But a banner is a result, and the result of one specific decision: picking a tool that drops a cookie and ships data outside the European Union. Measurement that collects nothing personal has nothing to get accepted, so it has nothing to ask.

This guide walks through the full integration on Laravel 10 / 11 / 12: the package, the Blade directive, conversions sent from PHP, and an attribution trap that collapses your entire revenue onto a single “visitor” — your own server — the moment a queued job or a webhook joins your checkout.

Why the banner isn’t inevitable

What triggers the consent requirement isn’t the word “analytics”. It’s writing to or reading from the visitor’s device: a cookie, an entry in local storage, a reconstructed browser fingerprint. Measurement that writes nothing to the browser and never tries to recognise anyone from one visit to the next falls outside that scope. The second source of friction, transferring personal data to servers outside the Union, disappears the same way once collection and storage stay in Europe.

What’s left is exactly what you need to steer a product: aggregates. Visits, pages viewed, traffic sources, breakdowns by country and device. No individual profile, no persistent identifier, nobody to recognise. That’s the deliberate design choice behind Takt: no cookie, no identification, a managed service hosted in Europe. The full contrast with the Google Analytics approach is on our comparison page.

On the Laravel side, the practical consequence is pleasantly boring: nothing to add to config/session.php, no extra cookie to list in your privacy policy, no conditional middleware to write or test. And the payoff isn’t only legal: with no banner, nobody declines or dismisses the modal, so your numbers cover all of your traffic rather than the fraction of visitors who clicked “Accept”.

Installing analytics in Laravel

Before the command line, a step zero that’s easy to skip and expensive to have skipped: create the site in Takt and note the domain you register there. That exact value is what belongs in TAKT_DOMAIN, because it’s the declared domain — not the host actually being served — that binds events to a site. A staging environment running on staging.example.com but declaring example.com will therefore pour straight into your production stats.

The Laravel bridge then wires the PHP core into the service container:

composer require vskstudio/takt-laravel
php artisan vendor:publish --tag=takt-config

The second command drops config/takt.php, which reads your environment variables. TaktServiceProvider is auto-discovered: there’s no provider to register by hand. It binds two objects in the container from that config — SnippetRenderer, which produces the browser runtime’s tag, and the Takt client, which posts server-to-server events.

# .env
TAKT_DOMAIN=example.com
TAKT_OUTBOUND=true
TAKT_FILES=true

TAKT_OUTBOUND and TAKT_FILES turn on two autocaptures: clicks on outbound links, and downloads. Every autocapture is off by default; TAKT_TAGGED (elements marked with data-takt-event in the HTML) and TAKT_NOT_FOUND (404 pages) round out the set of switches. TAKT_FILE_EXTENSIONS isn’t one of them: it’s a restriction placed on a capture that’s already running, the list of extensions TAKT_FILES agrees to count as a download.

You’ll notice two absences in that file. There’s no API key: it’s only used by the server-to-server path, and we’ll add it when we get to conversions — an app that only measures pageviews doesn’t need one. And there’s no ingestion address: TAKT_ENDPOINT points at the hosted Takt service — never a server of your own — and the only reason to redefine it is if you serve measurement through a first-party proxy on your own domain. If your version of the package asks for that variable anyway, the Laravel reference is authoritative on the value it expects.

The Blade directive

The package registers a @takt directive. It belongs in the <head> of your layout, rendered once for the whole app:

{{-- resources/views/layouts/app.blade.php --}}
<head>
  <meta charset="utf-8">
  @takt
</head>

That’s the whole pageview setup. By default the runtime is embedded directly in the tag rather than fetched from a URL, which saves a network request on every page load. The directive also accepts inline overrides, but prefer driving captures from the configuration: one layout and one central config beat an option scattered across templates.

Livewire and Inertia: nothing to wire

The runtime wraps history.pushState and listens for popstate. Livewire’s wire:navigate and Inertia’s router both go through pushState, and the browser’s back button fires a popstate: a Laravel app built on either one is therefore measured correctly out of the box, with no configuration, no listener to register and no call to fire on every screen change. One precaution applies to both: the directive has to stay in the layout’s <head> and nowhere else — the piece on the Symfony integration explains why a snippet rendered inside the body ends up multiplying your pageviews.

Tracking Laravel conversions server-side

Browser-side measurement is fine for pageviews. It gets a lot less reliable as soon as money is involved. An ad blocker, a tab closed during the return redirect from the payment provider, a checkout that finishes on a third-party domain: each one is a genuine order that never shows up in your stats. That’s why revenue reported by a tool that stops at the browser never quite matches the back office. A tool that can accept events from your server measures the conversion when the order changes state, not when a browser deigns to run a script. It’s also what settles the decoupled-front case: pageviews go on being measured in the browser, as the piece on the SvelteKit integration works through, while conversions leave from the Laravel API.

Sends from PHP authenticate with an API key. You didn’t need one until now; add it after the lines already in your .env:

# .env — line to add to the previous ones
TAKT_API_KEY=your_ingest_key   # only required for S2S

Don’t skip that line. That key is what authenticates server-to-server sends, and the client won’t report a rejection back to you: in its default mode only a 202 counts as success, and everything else is swallowed rather than propagated. A send refused for want of a valid key therefore raises nothing in your calling code — you’ll only see it as conversions that never arrive in the dashboard.

The facade does the rest, from a controller, a service, a model observer — wherever your checkout confirms the order:

use Vskstudio\Takt\Laravel\Facades\Takt;
use Vskstudio\Takt\Revenue;

Takt::event('Signup', ['plan' => 'pro']);

Takt::event('Purchase', ['plan' => 'pro'], new Revenue(amount: '29', currency: 'EUR'));

The Revenue amount is a decimal string, not a float: that’s deliberate, and it keeps binary rounding away from money. Sends are fire-and-forget: a 202 means success, and a transport failure is swallowed rather than propagated, because analytics should never take down a checkout.

One detail is worth holding on to, because the whole next section rests on it: the facade resolves the Takt client from the container, and that client forwards the visitor’s IP and User-Agent from the current Illuminate\Http\Request. That forwarding, and nothing else, is what ties the conversion to the right visitor — and therefore to their traffic source, their country, their campaign.

The trap: conversions attributed to your own server

Re-read the end of the previous section: the facade forwards the IP and User-Agent “from the current request”. Which assumes there is a request, and that it belongs to the visitor. Three entirely ordinary places in a Laravel app fail one or both of those conditions.

A queued job runs inside a worker, a process that never saw the HTTP request behind the order. An Artisan command — the scheduler’s subscription retry, the catch-up script someone runs by hand after an incident — runs outside any web context. And the road there is a quiet one: the Takt::event(...) line somebody moves out of the controller and into a job one day, because the send was slowing the response down. Not a character of that call changed; its meaning did.

This behaviour is documented: without withVisitor(), a server-to-server event is attributed to the application server, not the real visitor. Attribution is derived server-side from the IP and User-Agent; absent the visitor’s, it uses those of the machine doing the sending. Every one of your conversions then collapses onto a single visitor sitting in your hosting provider’s datacenter, with no traffic source and no usable country.

The third place, the payment webhook, is the nastiest, because it doesn’t look like the other two. There is an HTTP request in flight, so the facade does have something to forward, and nothing looks wrong — except that the request belongs to Stripe, not to your customer. $request->ip() returns a Stripe IP, $request->userAgent() the string from its HTTP client. You don’t get missing attribution, which would be visible: you get wrong attribution, perfectly well formed.

And nothing will warn you. The send doesn’t even fail here: it succeeds, the response is a 202, it’s just attributed to the wrong person. No exception is raised, no test breaks. The only symptom is in the dashboard, usually weeks later, when someone notices that your buyers no longer look anything like your visitors.

The fix is to capture what identifies the visitor during their request, then carry it to the send site. At dispatch time, the request is still right there:

// During the visitor's request: capture what identifies them.
ConfirmOrder::dispatch($order, $request->ip(), $request->userAgent());

That assumes $request->ip() actually returns the visitor’s address. Behind nginx, a load balancer or Cloudflare it returns the proxy’s until the application has been given the list of proxies to trust — through ->withMiddleware(...) in bootstrap/app.php on Laravel 11 and 12, through the TrustProxies middleware on earlier versions. The value belongs to your infrastructure and can’t be guessed; the Laravel documentation covers the shapes it takes. Without it, the fix below applies without fixing anything: your conversions keep collapsing onto a single visitor, your edge this time.

The job itself starts from the skeleton php artisan make:job ConfirmOrder produces, in app/Jobs/ConfirmOrder.php; all that’s left is the constructor and the send. It takes the Takt client through the handle() signature rather than through the facade — a matter of style, not of behaviour: both resolve the same container service, and Takt::withVisitor($ip, $ua)->event(...) would do exactly the same thing. What fixes the bug, either way, is withVisitor() and nothing else.

<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Vskstudio\Takt\Revenue;
use Vskstudio\Takt\Takt;

class ConfirmOrder implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private Order $order,
        private ?string $ip,
        private ?string $userAgent,
    ) {}

    public function handle(Takt $takt): void
    {
        $takt->withVisitor($this->ip, $this->userAgent)
            ->event('Purchase', ['plan' => $this->order->plan],
                new Revenue(amount: (string) $this->order->total, currency: 'EUR'));
    }
}

Three things in that block deserve a word. The trait list is the Laravel 10 stub; on Laravel 11 and 12, make:job groups them under the single Illuminate\Foundation\Queue\Queueable, and the explicit form above stays valid on all three versions. SerializesModels isn’t decoration: without it the whole Order goes into the job payload instead of being serialised by key and reloaded at run time. And (string) $this->order->total assumes the column is cast to decimal:2 on the model: Revenue expects a decimal string, whereas casting a PHP float would produce 29.9 where you meant 29.90.

One last thing to watch: withVisitor() returns a new instance. The send has to be chained onto it, exactly as above. Splitting it into two separate statements — $takt->withVisitor(...) on one line, $takt->event(...) on the next — makes the first call entirely pointless, and entirely silent: you land back on the very bug you were fixing, with the code that appears to fix it sitting right above.

For the webhook, the same logic applies one step earlier. By the time Stripe calls back, the visitor’s request finished long ago: there is nothing left to capture at send time. The IP and User-Agent therefore have to be persisted on the order at checkout, in two ordinary columns, then read back by the webhook controller to feed withVisitor(). It’s a bit of plumbing, but it’s the only way to get the webhook’s completeness and the return controller’s attribution at the same time. Note that you’re now storing data your app may not have kept before: treat it as such, and purge it along with the rest of the order.

Checking that measurement works

Deploy, then open the site live with the Network tab of your devtools open. You should see a POST to /api/event on every page load, and exactly one per wire:navigate or Inertia navigation — click through four or five links in a row and count.

Locally you’ll see nothing at all: localhost and private IPs are excluded by default, as are visitors with Do Not Track or Global Privacy Control enabled. That’s intended behaviour, not a broken config. For a manual check from a public environment, the console is enough:

// from the browser console, on the deployed site
window.takt('test_event');

The attribution trap, though, can’t be tested from the browser, because the send doesn’t originate there. You read it in your ordinary country breakdown, the unfiltered one. The reasoning goes in two steps. Without withVisitor(), every server-to-server send carries the same IP and the same User-Agent, your application server’s: they all fold onto one and the same visitor, located in your host’s country. That phantom visitor then shows up in the overall breakdown as a country with no business being in your audience, weighing roughly as much as your order count for the period. A shop whose visitors are French and where Germany suddenly appears at the size of its sales volume hasn’t started selling in Germany: it’s measuring its host’s datacenter. Device and browser breakdowns carry the same signature — an HTTP client, not an audience. Once the fix is in place that spike disappears and conversions spread back across the real visitors.

Finish with a real end-to-end order, and check that the matching Purchase comes through exactly once. If you emit from both the return controller and the webhook, you’re counting every sale twice, and your dashboard revenue will read double what the back office says.

Wrapping up

One package, one domain to declare, one directive in the layout’s <head>: a Laravel 10, 11 or 12 app is measured with no cookie and no banner, Livewire and Inertia included, and the Takt facade sends conversions from PHP, out of reach of ad blockers. The one genuinely sharp edge is attribution: the facade can only forward an IP and User-Agent when a visitor request is in flight, which is true in neither a queued job, nor an Artisan command, nor a webhook where the request belongs to the payment provider — and the failure is silent, because a 202 comes back regardless. Capture the IP and User-Agent during the visitor’s request, carry them to the send site, and chain the send onto the instance withVisitor() returns. The full option reference is in the Laravel documentation.

Take the next step

Measure your audience without a consent banner.

See Takt in action, then install cookieless analytics on your site.

Share