usamamuneerchaudhary / adment
Production-grade ad & Google AdSense manager for Filament v5, placements, responsive creatives, click tracking, and a public API.
Fund package maintenance!
Requires
- php: ^8.3
- filament/filament: ^5.0
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-livewire: ^4.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
A simple custom ad & Google AdSense manager for Filament v5 on Laravel 12/13 — placements, responsive (and GIF/video) creatives, weighted A/B rotation, scheduling windows, geo/device targeting, impression & CTR analytics, obfuscated click tracking, AdSense Auto Ads & ad units, ads.txt management, and an optional public JSON API.
Requires: PHP 8.3+, Laravel 12 or 13, Filament 5 (Livewire 4).
Features
- Ad placements (locations): register named slots in config or at runtime; drop ads into any Blade view.
- Weighted A/B rotation: when showing a single ad per location, selection is weighted by the ad's Weight (
ordercolumn). Higher weight = more likely to be shown. - Scheduling windows: optional
starts_atandexpired_atso custom ads only serve inside a time window. - Geo & device targeting: restrict custom ads to ISO country codes (via CDN headers) and/or desktop / tablet / mobile.
- Media types: image, GIF, or video creatives for custom ads (desktop / tablet / mobile variants with fallbacks).
- Impression tracking & CTR: client viewport beacon (
IntersectionObserver) records impressions; lifetime counters + daily stats power CTR dashboards. - Click tracking: obfuscated
/ac-{sha1}/{key}route increments a counter (without touchingupdated_at) and 302s to the destination. The raw destination URL never appears in your markup. - Analytics dashboard: Filament page with impressions/clicks/CTR stats, a daily performance chart, and a top-ads-by-CTR table.
- Google AdSense: Auto Ads snippet injection (with a strict, security-hardened snippet validator) or per-unit slot rendering via a global publisher client ID.
ads.txtmanagement from the admin panel.- Filament resource: full CRUD with conditional fields per ad type, filters, impressions, CTR, schedule highlighting.
- Events:
AdsLoading,AdClicked, andAdImpressionRecordedfor analytics/extension packages. - Optional public API:
GET/POST /api/v1/adswith key filtering. - Octane-safe: services are container-scoped, not singletons.
Installation
composer require usamamuneerchaudhary/adment
php artisan vendor:publish --tag=adment-migrations
php artisan migrate
# optional
php artisan vendor:publish --tag=adment-config
php artisan vendor:publish --tag=adment-views
Register the plugin in your panel provider:
use Usamamuneerchaudhary\Adment\AdmentPlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugin(AdmentPlugin::make()); // ->plugin(AdmentPlugin::make()->settingsPage(false)) to hide settings // ->plugin(AdmentPlugin::make()->analyticsPage(false)) to hide analytics }
Registering locations
In config/adment.php:
'locations' => [ 'not_set' => 'Not set', 'homepage-banner' => 'Homepage banner', 'sidebar' => 'Sidebar', ],
Or at runtime (e.g. in a service provider or theme boot):
use Usamamuneerchaudhary\Adment\Facades\Ads; Ads::registerLocation('footer', 'Footer') ->registerLocation('post-content', 'Below post content');
Displaying ads
Blade components (recommended):
{{-- One weighted-random published, in-window, targeted ad from a location --}} <x-adment::display location="sidebar" /> {{-- All matching ads in a location, ordered by weight --}} <x-adment::display location="sidebar" :single="false" /> {{-- A specific ad by key, with passthrough attributes --}} <x-adment::display ad-key="HOMEPAGEBNNR" class="my-4" />
Facade / service:
Ads::display('sidebar'); // string of HTML Ads::displayAds('HOMEPAGEBNNR'); // ?string Ads::locationHasAds('sidebar'); // bool — for conditional layouts Ads::getAd('HOMEPAGEBNNR'); // ?Ad model
Ads are loaded once per request and memoized; Ads::load(force: true) refreshes.
For impression beacons to work on public pages, include a CSRF meta tag in your layout:
<meta name="csrf-token" content="{{ csrf_token() }}">
Weighted rotation & scheduling
- Weight — the Filament Weight field maps to the
ordercolumn. Values are treated as a minimum of1when picking a single ad for a location. - Starts at — leave empty to start immediately; future values keep the ad out of rotation until then.
- Ends at — required for custom ads; past values mark the ad expired.
AdSense units ignore schedule dates and never expire.
Geo & device targeting
On each custom ad you can set:
- Countries — ISO-3166 alpha-2 tags (e.g.
US,GB). Empty = all countries. - Devices — desktop / tablet / mobile checkboxes. Empty = all devices.
Country is resolved from request headers, in order:
- Configured
adment.targeting.country_resolvercallable (if set) CF-IPCountryCloudFront-Viewer-CountryX-Country-Code
When an ad has country targeting and the country cannot be resolved, the ad is excluded by default (unknown_country_behavior = exclude_restricted).
// config/adment.php 'targeting' => [ 'country_resolver' => fn (\Illuminate\Http\Request $request): ?string => /* ... */, 'unknown_country_behavior' => 'exclude_restricted', ],
Media types
Custom ads support:
| Media type | Rendered as |
|---|---|
| Image | Responsive <picture> (desktop / tablet / mobile) |
| GIF | <img> with srcset fallbacks |
| Video | <video autoplay muted loop playsinline> (mp4 / webm) |
Upload accepted types are enforced in Filament based on the selected media type.
Impression tracking & CTR
Custom creatives wrap in a beacon container. When ~50% of the ad is visible, a one-shot POST to /ai-{sha1}/{key} records an impression (lifetime impressions + today's ad_daily_stats row) and dispatches AdImpressionRecorded.
CTR is clicks / impressions × 100. Open Ad analytics in the Marketing nav group for:
- Period totals (impressions, clicks, CTR, active ads)
- Daily impressions & clicks chart
- Top ads by CTR (respects
analytics.min_impressions_for_ctr_ranking)
AdSense
Configure in Ads settings in the panel (mode: Disabled / Auto Ads / Ad units), then add to your public layout:
<head> ... <x-adment::adsense-head /> </head> <body> ... <x-adment::adsense-foot /> </body>
- Auto Ads: paste the exact snippet from AdSense. It's validated hard: a single empty async
<script>frompagead2.googlesyndication.comwith aca-pub-{16 digits}client — inline JS,eval,document.write, and data URLs are rejected. - Ad units: set your
ca-pub-…client ID, then create ads of type Google AdSense unit with a slot ID. Units render as<ins class="adsbygoogle">and never expire. Impressions for AdSense units are not tracked by Adment (AdSense handles its own metrics).
Click tracking
Custom ads with a destination URL render links through /ac-{sha1(key.id)}/{key}. The handler verifies the hash (hash_equals), refuses non-HTTP(S) destinations, increments clicked and today's daily click stat without model events or timestamp bumps, dispatches AdClicked, and redirects. A legacy /ads-click/{key} route is included.
Public API (optional)
Enable in config ('api' => ['enabled' => true]):
GET /api/v1/ads
GET /api/v1/ads?keys[]=HOMEPAGEBNNR&keys[]=SIDEBAR00001
Returns published, in-window ads ordered by weight, with tracked link URLs; the raw destination is never exposed.
Extending
- Custom model: point
adment.models.adat your subclass (add tenancy scopes, relations, etc.). - Analytics: listen to
AdClicked,AdImpressionRecorded(ad, referer, user agent), andAdsLoading. - Rendering: publish and override the views, or swap the whole
ManagesAdsbinding. - Country detection: set
adment.targeting.country_resolverto a callable that returns an ISO country code.
Architecture
Blade component / Facade
│
▼
AdsManager (scoped service, per-request memoized)
│ filterDisplayable(): published + schedule window
│ filterTargeted(): country + device rules
│ WeightedAdSelector (when single=true)
▼
partials/ad-display.blade.php
├── adsense-slot.blade.php (<ins class="adsbygoogle">)
└── custom-ad.blade.php (image / gif / video + tracked click URL)
└── viewport beacon ── POST /ai-… ── AdImpressionController
AdClickController ── hash check ── recordClick() ── AdClicked ── redirect()->away()
AdAnalyticsRecorder ── ads.impressions/clicked + ad_daily_stats
AdsSettings ── key/value table + forever-cache ── ManageAdsSettings (Filament page)
AdAnalyticsDashboard ── stats / chart / top CTR widgets
Design decisions:
- Contract-first: everything binds
ManagesAds, so a Pro/analytics package can decorate or replace the manager without touching consumers. - Scoped, not singleton, for Octane and queue-worker safety.
- No repository layer: Eloquent scopes (
displayable()) are the query API; the manager consumes collections so filtering logic exists exactly once in the model. - Viewport beacons over render-time increments so CTR reflects ads users actually saw.
- CDN headers for geo by default (zero new dependencies); swap in MaxMind or similar via
country_resolver. - Settings are dependency-free (key/value table + cache); swap for
spatie/laravel-settingsif your app standardizes on it.
Testing
composer test # Pest composer analyse # PHPStan (larastan, level 6) composer format # Pint
The suite covers the model (key generation, hash, image fallbacks, scopes, schedule/CTR, click & impression counting), the manager (filtering, weighted selection, targeting, attribute passthrough, AdSense rendering, URL non-leakage), click & impression routes (hash tampering, unsafe URLs, legacy route, daily stats), Blade components (image / GIF / video), targeting & weighted selector units, the public API, the AdSense snippet validator, and the Filament resource + settings page via Livewire.



