---
title: "Impression & click tracking — Advertisements for Laravel | Roundly"
description: "Record impressions and clicks of live ads inline or via the queue, with transactional counters, CTR, request context and per-country reports."
url: https://roundly-consulting.com/open-source/docs/advertisements-for-laravel/tracking
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Advertisements for Laravel](https://roundly-consulting.com/open-source/docs/advertisements-for-laravel.md)

# Impression & click tracking

Record impressions and clicks — synchronously, or buffered to the queue when tracking.buffered is true. Both paths write an advertisement\_events row and bump a denormalized counter in one transaction — a failed write leaves neither, so a retried job never double-counts — then fire an event:

```php
use RoundlyConsulting\Advertisements\DataTransferObjects\ImpressionData;
use RoundlyConsulting\Advertisements\Facades\Advertisements;

Advertisements::for($ad)->track('sidebar')->impression();
Advertisements::for($ad)->track('sidebar')->click(new ImpressionData(
    ip: $request->ip(),
    userAgent: $request->userAgent(),
    referrer: $request->headers->get('referer'),
));
Advertisements::for($ad)->track()->impression(); // no placement

$ad->impressions(); // int  (impressions_count)
$ad->clicks();      // int  (clicks_count)
$ad->ctr();         // float 0.0–1.0
$ad->events;        // HasMany<AdvertisementEvent>
```

ctr() is clicks divided by impressions (0.0–1.0), and 0.0 while there are no impressions. The placement is optional and accepts a model, id or slug.

## What track() refuses

track() refuses an ad that isn’t live right now — a draft, scheduled, expired or archived ad (AdvertisementNotActive) — and track($placement) also refuses a placement the ad doesn’t run in (AdvertisementNotInPlacement). Both are AdvertisementExceptions, so a forged or stale impression or click URL credits nothing; catch the base class in a click-redirect controller and answer it as you see fit:

```php
use RoundlyConsulting\Advertisements\DataTransferObjects\ImpressionData;
use RoundlyConsulting\Advertisements\Exceptions\AdvertisementException;
use RoundlyConsulting\Advertisements\Facades\Advertisements;

try {
    Advertisements::for($ad)->track('sidebar')->click(new ImpressionData(ip: $request->ip()));
} catch (AdvertisementException) {
    // not credited: the ad is not live, or never ran in this placement
}
```

## Request context

ImpressionData is optional, and so is each of its fields — ip, userAgent, referrer, occurredAt and meta. Supplied context is merged into the event’s meta; null keys are omitted:

```php
use Illuminate\Support\Collection;

// Inline mode returns the persisted AdvertisementEvent (buffered mode: null)
$event = Advertisements::for($ad)->track('sidebar')->click(new ImpressionData(
    ip: $request->ip(),
    occurredAt: now(),                                     // defaults to now
    meta: new Collection(['campaign' => 'autumn-launch']), // your own keys
));

$event->type;          // AdvertisementEventType::Click
$event->occurred_at;   // Carbon
$event->placement;     // ?Placement
$event->meta;          // Collection — ip, user_agent, referrer (when given) + your keys
$event->country_code;  // 'SK' when geo stamping resolved the IP, else null
```

## Buffered recording

High-traffic pages can move the write off the request. With buffering on, impression() and click() return null, and RecordAdvertisementEventJob replays through the same core on the configured connection and queue — sync and queued recording produce the identical end state. The live-ad and placement checks run when you call track(), before the job is queued. The job carries ids, never whole models; an ad deleted before the job runs is skipped.

```bash
ADVERTISEMENTS_TRACKING_BUFFERED=true
ADVERTISEMENTS_TRACKING_CONNECTION=redis   # unset or blank = the default connection
ADVERTISEMENTS_TRACKING_QUEUE=tracking     # unset or blank = the default queue

php artisan queue:work redis --queue=tracking
```

## Per-country reports

When geo.stamp\_events is on and the ImpressionData carries an IP, recording resolves it through geolocation-for-laravel and stamps the viewer’s country into the indexed country\_code column, plus region, city, latitude and longitude into meta:

```php
use RoundlyConsulting\Advertisements\DataTransferObjects\ImpressionData;

Advertisements::for($ad)->track('sidebar')->impression(new ImpressionData(ip: $request->ip()));

$ad->impressionsByCountry(); // ['SK' => 1240, 'CZ' => 310]
$ad->clicksByCountry();      // ['SK' => 58]
```

Resolution failures never abort the record: an IP geolocation can’t place, a viewer it places without a country, and a lookup that throws (a missing MaxMind database, a rate limit, a custom provider’s exception) all save the event with a null country\_code and still bump the counter. A throwing lookup is passed to your exception handler’s report(), so a broken provider shows up in your logs instead of failing silently. Stamping works on the buffered path too.

## Reacting to tracking

Listen for ImpressionRecorded and ClickRecorded — each exposes the persisted AdvertisementEvent as a public $event property (see Events).

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/advertisements-for-laravel.md)
