NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Advertisements for Laravel

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:

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:

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:

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.

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:

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

By donating, you agree to our donation terms.

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.