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

Ads declare targeting through the Targeting value object — allowed and blocked countries and/or a radius around a point. At serve time, resolve the active ads for a placement that match the viewer’s location:

use RoundlyConsulting\Advertisements\Facades\Advertisements;
use RoundlyConsulting\Advertisements\ValueObjects\Targeting;
use RoundlyConsulting\Geolocation\DataTransferObjects\Coordinates;

$ad->targeting = new Targeting(
    countries: ['SK', 'CZ'],                 // allow list (empty = any)
    excludeCountries: ['RU'],                // deny list
    center: new Coordinates(48.1486, 17.1077),
    radiusKm: 50.0,
);
$ad->save();

// Resolve the viewer from the request IP (geolocation), or pass a Location / null:
$ads = Advertisements::targetedIn('sidebar', request())->get();

// Or the model scope directly:
Advertisement::query()->active()->forPlacement('sidebar')->targetedAt($location)->get();

targetedIn() resolves a Request — or the current request when you pass nothing — to a location through geolocation-for-laravel, or takes an already-resolved Location. A lookup that throws is reported and treated as an unknown viewer, so geolocation never takes serving down. With geo.targeting_enabled off, it’s identical to in().

Matching rules

  • excludeCountries — deny list of ISO codes, checked first.
  • countries — allow list; empty means any country.
  • center + radiusKm — the viewer must be within radiusKm of the centre (exact Haversine). Applies only when both are set.
  • An ad matches when the viewer satisfies every rule it sets: the allow/deny lists check the viewer’s country, the radius checks the viewer’s coordinates.
  • An ad with no targeting (null, or an empty new Targeting(), which is stored as no targeting) matches every viewer while geo.untargeted_match is true.

Unknown viewers and missing facts

The viewer can be a Location, bare Coordinates, an ISO country code, a Request (resolved from its IP) or null. What happens when something about the viewer is unknown is decided by geo.match_when_unknown:

The viewer is…untargeted_only (default)all
unknown — null, an IP geolocation can’t place (or whose lookup throws), an empty country code, a Location with neither country nor coordinatesonly untargeted adsevery ad
known only by country ('US', a Location at 0,0)a radius rule failsa radius rule passes
known only by coordinates (Coordinates, a Location without a country)a country list failsa country list passes

So under the default a viewer known only as 'US' is never served an ad restricted to 50 km around Bratislava, and bare New York coordinates never get an SK-only ad.

The targetedAt() scope

The scope behind targetedIn() accepts four viewer shapes:

use RoundlyConsulting\Geolocation\DataTransferObjects\Coordinates;

Advertisement::query()->active()->targetedAt($location)->get();   // Location — country lists + radius
Advertisement::query()->active()->targetedAt('SK')->get();        // country only — a radius rule can't be checked
Advertisement::query()->active()
    ->targetedAt(new Coordinates(48.1486, 17.1077))->get();       // coordinates only — a country list can't be checked
Advertisement::query()->active()->targetedAt(null)->get();        // unknown viewer — per geo.match_when_unknown

Targeting is evaluated precisely in PHP over the candidate set the surrounding query already narrowed, so chain active() and forPlacement() before it.

Reading and clearing targeting

$targeting = $ad->targeting;         // ?Targeting
$targeting?->isEmpty();              // no country list and no radius (centre + radiusKm)
$targeting?->matches('SK');          // Location, Coordinates or a country code
$targeting?->matches('SK', true);    // a rule the viewer lacks the fact for counts as met
$targeting?->toArray();              // ['countries' => [...], 'exclude_countries' => [...], 'center' => [...], 'radius_km' => 50.0]

$ad->targeting = Targeting::fromArray([   // snake_case payload, e.g. from a form
    'countries' => ['sk', 'cz'],          // normalised to upper case
    'center' => ['latitude' => 48.1486, 'longitude' => 17.1077],
    'radius_km' => 25,
]);

$ad->targeting = null;                    // untargeted again
$ad->save();

The cast stores targeting as JSON and copies the centre into the target_latitude and target_longitude columns. Assign null (or an empty Targeting) to clear it; any value other than a Targeting or null throws InvalidTargeting. A centre without a radius, or a radius without a centre, isn’t a rule.

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.