tugrul / ad-bundle
Symfony bundle to manage banner/ad areas with pluggable producers (Google AdSense, HTML, image, Twig), a render decision engine (country targeting, random, schedule, route, parameters) and hooks for statistics.
Requires
- php: >=8.2
- psr/clock: ^1.0
- psr/log: ^2.0|^3.0
- symfony/config: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/event-dispatcher: ^6.4|^7.0
- symfony/http-foundation: ^6.4|^7.0
- symfony/http-kernel: ^6.4|^7.0
- symfony/options-resolver: ^6.4|^7.0
- twig/twig: ^3.8
Requires (Dev)
- phpunit/phpunit: ^10.5
- symfony/console: ^6.4|^7.0
- symfony/yaml: ^6.4|^7.0
Suggests
- symfony/console: To use the debug:tugrul-ad command
- symfony/twig-bundle: To use the tugrul_ad_unit() Twig function
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 14:39:07 UTC
README
Manage banner / ad areas on a Symfony site from one YAML file.
{{ tugrul_ad_unit('after_form', {'optional': 'parameter'}) }}
- Areas are named placements (
after_form,sidebar, …) that hold one or more candidates. - Producers render a candidate: built-in
adsense,html,image,twig,placeholder— or your own class implementingProducerInterface. - AMP pages (identified by route name,
amp_*by default) get<amp-ad>markup and the amp-ad component script automatically; anampdecider targets AMP vs. regular pages. - Placeholder mode renders the dummy
placeholderproducer instead of real ads on every environment exceptprod, so banner placement can be checked without loading Google. - Deciders decide whether an area / candidate renders right now: built-in
country,random,schedule,route,parameter— or your own class implementingDeciderInterface. - Strategies choose among eligible candidates:
first,random,weighted,all— or your ownSelectionStrategyInterface. - AdSense units and publisher ids are labeled; areas reference units by label and units reference clients by label, so an id is written once.
- Every render produces a
RenderResultwith a full decision trace, dispatched through events and aStatisticsCollectorInterface— the hook for the statistics layer you'll add later.
Requires PHP ≥ 8.2 and Symfony 6.4 / 7.x.
Installation
composer require tugrul/ad-bundle
Without Flex, register the bundle:
// config/bundles.php return [ // … Tugrul\AdBundle\TugrulAdBundle::class => ['all' => true], ];
Add the two page hooks to your base layout. Producers (or any service) that need something in <head> or before </body> contribute there — the AdSense producer outputs its loader script from tugrul_ad_init():
{# templates/base.html.twig #} <head> … {{ tugrul_ad_init() }} </head> <body> … {{ tugrul_ad_post() }} </body>
Configuration
# config/packages/tugrul_ad.yaml tugrul_ad: on_missing_area: throw # throw | ignore (ignore = render nothing, log a warning) on_producer_error: throw # throw | ignore (ignore = log and skip the failing candidate) wrapper: # optional element around every rendered area; off by default enabled: false # shorthand: wrapper: true tag: div class: tugrul-ad attr: { } country: # default country resolver (see "Country targeting") headers: [CF-IPCountry, CloudFront-Viewer-Country, X-Country-Code] request_attribute: _country fallback: null amp: # see "AMP pages"; shorthand: amp: 'amp_*' | [patterns] | false routes: ['amp_*'] # route-name patterns that identify AMP pages placeholder: # see "Placeholder mode"; shorthand: placeholder: true|false enabled: null # true | false | null = on every environment except prod producers: [adsense] # producers replaced by the placeholder; [] = all class: tugrul-ad-placeholder # CSS class of the placeholder box adsense: clients: # label → publisher id main: 'ca-pub-1111111111111111' partner: 'ca-pub-2222222222222222' default_client: main # optional, defaults to the first client defaults: # inherited by every unit; shown with the built-in values class: '' # classes appended to "adsbygoogle" on the <ins>, e.g. 'w-full' style: 'display:block' format: auto # data-ad-format; null to omit full_width_responsive: true layout: null layout_key: null attr: { } # extra attributes on the <ins>, merged with the unit's amp: # <amp-ad> settings for AMP pages (see "AMP pages") enabled: true responsive: true # Google's responsive full-width unit; false = fixed width/height width: 100vw height: 320 layout: null # layout attribute for fixed-size units attr: { } units: # label → ad unit; set only what differs from defaults sidebar_rect: slot: '1000000001' format: rectangle class: my-sidebar # → class="adsbygoogle w-full my-sidebar" amp: { responsive: false, width: 300, height: 250 } description: Sidebar 300x250 leaderboard: slot: '1000000002' format: horizontal in_article: slot: '1000000003' format: fluid layout: in-article # data-ad-layout full_width_responsive: null style: 'display:block; text-align:center;' amp: false # not rendered on AMP pages partner_unit: client: partner # label → label association slot: '2000000001' quick: '3000000001' # shorthand: label → slot, everything else defaulted defaults: deciders: # applied to every area before its own deciders route: { deny: ['admin_*'] } areas: # 1. shortest form: the area renders one AdSense unit sidebar: sidebar_rect # 2. single candidate with area settings article_end: unit: in_article wrapper: { enabled: true, class: ad-article } # per-area wrapper override (or wrapper: true|false) # 3. full form after_form: description: Below the contact form enabled: true strategy: first # first | random | weighted | all | <custom> deciders: # area level — first deny stops everything country: { allow: [TR, DE], deny: [RU], on_unknown: allow } schedule: { from: '2026-11-20', until: '2026-12-31 23:59', timezone: Europe/Istanbul } candidates: # priority order - name: premium producer: html options: { content: '<a href="/premium"><img src="/img/premium.png" alt=""></a>' } deciders: parameter: { truthy: [premium] } # only when the template passes premium: true - name: house unit: leaderboard # shorthand for producer: adsense + options.unit options: { format: auto, class: sticky } # per-placement overrides of the labeled unit deciders: random: { probability: 0.8 } rotation: strategy: weighted candidates: - { name: a, html: '<i>A</i>', weight: 1 } # html: shorthand for the html producer - { name: b, html: '<i>B</i>', weight: 9 } sidebar_stack: strategy: all # render every eligible candidate candidates: - sidebar_rect - producer: image options: { src: /banners/summer.png, href: 'https://example.com/summer', alt: Summer sale, width: 300, height: 250 } - producer: twig options: { template: ads/newsletter.html.twig, vars: { headline: Join us } }
AdSense unit properties resolve as adsense.defaults → unit → candidate options: a unit only has to set what differs from the defaults, a candidate only what differs from the unit. class and attr accumulate along the way (classes are appended to the mandatory adsbygoogle, attributes merged); amp is merged key by key; every other property is replaced. Set a property to null at any level to omit the attribute (format: null drops data-ad-format). To take full control of the class attribute, set it through attr instead: attr: { class: 'my-own' } replaces it entirely.
Check what you configured:
php bin/console debug:tugrul-ad # overview tables php bin/console debug:tugrul-ad after_form --country=TR -p premium=1 # explain the decision php bin/console debug:tugrul-ad after_form --render # and print the HTML php bin/console debug:tugrul-ad sidebar --route=amp_home --render # the AMP rendering
Twig
{{ tugrul_ad_unit('after_form') }}
{{ tugrul_ad_unit('after_form', {'optional': 'parameter', 'premium': app.user and app.user.premium}) }}
{% if tugrul_ad_has_area('sidebar') %} … {% endif %}
{{ tugrul_ad_init() }} {# in <head>: output of every InitInterface, once per request #}
{{ tugrul_ad_post() }} {# before </body>: output of every PostInterface, once per request #}
The second argument is a free-form parameter bag. Deciders (parameter) and producers (twig gets it as params) can read it. Three keys are reserved and handled by the engine:
| key | effect |
|---|---|
class |
extra CSS class(es) on the wrapper (when enabled) |
attr |
extra HTML attributes on the wrapper, e.g. {'attr': {'id': 'ad-1'}} |
country |
force the visitor country for this call (ISO 3166-1 alpha-2) |
Output
By default the producer's markup is output as is. Enable the wrapper (wrapper: true, globally or per area) to get an element carrying data-ad-* attributes around it:
<div class="tugrul-ad" data-ad-area="after_form" data-ad-candidate="house" data-ad-producer="adsense"> <ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-1111111111111111" data-ad-slot="1000000002" data-ad-format="auto" data-full-width-responsive="true"></ins> <script>(adsbygoogle=window.adsbygoogle||[]).push({});</script> </div>
With strategy: all each candidate is additionally wrapped in <div class="tugrul-ad-unit" data-ad-candidate="…" data-ad-producer="…"> whenever more than one renders. The data-ad-* attributes are there so a future client-side script can report viewable impressions and clicks per area / candidate — turn the wrapper on when you add that.
AMP pages
AMP pages are detected by route name — every route matching tugrul_ad.amp.routes (amp_* by default). Detection is one small service (AmpDetectorInterface, alias it for any other rule) shared by everything that needs it:
adsenseproducer: renders<amp-ad type="adsense">from the unit'sampsettings instead of<ins>:<!-- responsive: true (default) — Google's responsive full-width unit --> <amp-ad width="100vw" height="320" type="adsense" data-ad-client="ca-pub-…" data-ad-slot="…" data-auto-format="rspv" data-full-width><div overflow></div></amp-ad> <!-- responsive: false, width: 300, height: 250 --> <amp-ad class="w-full" width="300" height="250" type="adsense" data-ad-client="ca-pub-…" data-ad-slot="…"></amp-ad>
ampresolves like the other unit properties (adsense.defaults.amp→ unit → candidateoptions.amp);amp: falseon a unit or candidate renders nothing on AMP pages. Classes carry over (withoutadsbygoogle),amp.attris for extra attributes such asdata-ad-host. No push script is emitted.tugrul_ad_init(): outputs<script async custom-element="amp-ad" src="https://cdn.ampproject.org/v0/amp-ad-0.1.js"></script>instead of the adsbygoogle.js loader. Your own hooks can checkPageContext::isAmp().ampdecider:amp: { require: false }keeps a candidate that isn't AMP-valid (anhtmlsnippet with a<script>) off AMP pages,amp: { require: true }restricts one to them;amp: ~only records the page type for statistics (RenderResult::toArray()['amp']).- placeholder: on AMP pages the box takes the
ampwidth/height as its footprint.
Placeholder mode
On non-production environments you usually want to see where banners go without loading AdSense. In placeholder mode the engine runs the full decision pipeline (deciders, strategies, events, statistics) but renders the selected candidate with the dummy placeholder producer instead of its real one.
It is on by default whenever kernel.environment is not prod. Force it with placeholder: true|false (e.g. placeholder: '%env(bool:AD_PLACEHOLDERS)%'), and choose what gets replaced with placeholder.producers ([adsense] by default, [] for everything including your own producers).
<div class="tugrul-ad" data-ad-area="sidebar" data-ad-candidate="0" data-ad-producer="adsense" data-ad-placeholder="true"> <div class="tugrul-ad-placeholder" style="display:block" data-ad-unit="sidebar_rect" data-ad-slot="1000000001" data-ad-format="rectangle">sidebar_rect</div> </div>
The box keeps the real unit's style, format and classes (adsense.defaults → unit → candidate class, appended to placeholder.class), so it has the footprint the ad will have, and shows the unit label, or the candidate name for non-AdSense producers. Style .tugrul-ad-placeholder however you like (rename it with placeholder.class); there is no CSS shipped. The AdSense loader is not output in placeholder mode (PageContext::isPlaceholderMode() is available to your own hooks).
placeholder is an ordinary producer, so it can also be a candidate on its own, e.g. to reserve a slot before the unit exists:
areas: hero: - producer: placeholder options: { label: 'Hero 728x90', style: 'display:block;width:728px;height:90px', class: is-hero }
RenderResult::isPlaceholder() and the data-ad-placeholder wrapper attribute let statistics collectors skip placeholder renders; debug:tugrul-ad reports whether the mode is on.
How the engine decides
RenderEngine::render('after_form', $params) runs this pipeline:
- Look up the area (
on_missing_areadecides what happens if it doesn't exist). enabled: false→ skipped.PreRenderEvent— any listener can veto ($event->skip('reason')) or enrich the context.- Area-level deciders (
defaults.decidersmerged with the area's own). The firstdenyskips the area. - Candidate-level deciders for every enabled candidate. Candidates not denied are eligible.
- The strategy picks which eligible candidates render.
- Producers render them (
on_producer_errordecides what a throwing producer does). - The wrapper element is added, if enabled.
PostRenderEvent(listeners may rewrite the HTML) andStatisticsCollectorInterface::collect().
A decider returns one of three outcomes: allow, deny or abstain (no opinion — e.g. the country is unknown and on_unknown: allow). Only deny blocks; the engine records every outcome with its reason in a DecisionTrace.
Built-in producers
| name | options | description |
|---|---|---|
adsense |
unit (label); overrides class, style, format, full_width_responsive, layout, layout_key, attr, amp; push, test |
Renders a labeled AdSense unit (<amp-ad> on AMP pages); outputs the adsbygoogle.js loader — or the amp-ad component script — from tugrul_ad_init(). |
html |
content |
Raw HTML. |
image |
src, href, alt, width, height, target, rel, loading, img_attr, link_attr |
Image banner, optionally linked. |
twig |
template, vars |
Renders a template with area, candidate, params, request, vars. |
placeholder |
label, class (appended after placeholder.class and the unit's classes), style, format, attr, unit |
Dummy box for checking placement (see "Placeholder mode"). |
Built-in deciders
| name | options | description |
|---|---|---|
country |
allow: [], deny: [], on_unknown: allow|deny |
Geo targeting. deny wins over allow; empty allow means everyone. |
amp |
require: true|false|null |
AMP pages only / regular pages only / detect only (see "AMP pages"). |
random |
probability: 0..1 |
Render with a given chance (A/B sampling, lower ad density). |
schedule |
from, until, days: [mon…sun], hours: ['09:00-18:00'], timezone |
Campaign windows; hour ranges may cross midnight. |
route |
allow, deny (route names, * wildcard), paths_allow, paths_deny (path prefixes), on_unknown |
Target Symfony routes / URL paths. |
parameter |
match: {key: value}, truthy: [], falsy: [], present: [] |
Match the parameters passed from Twig. |
Country targeting
CountryDecider asks CountryResolverInterface for the visitor's country. The default RequestCountryResolver checks, in order: the country runtime parameter, the _country request attribute, the configured CDN headers (CF-IPCountry, CloudFront-Viewer-Country, X-Country-Code), then country.fallback.
If you have a GeoIP database, either set the request attribute from a kernel listener, or replace the resolver:
final class GeoIpCountryResolver implements \Tugrul\AdBundle\Country\CountryResolverInterface { public function __construct(private readonly \GeoIp2\Database\Reader $reader) {} public function resolve(\Tugrul\AdBundle\Context\AdContext $context): ?string { $ip = $context->getRequest()?->getClientIp(); try { return $ip ? $this->reader->country($ip)->country->isoCode : null; } catch (\Throwable) { return null; } } }
services: Tugrul\AdBundle\Country\CountryResolverInterface: '@App\Ads\GeoIpCountryResolver'
Custom deciders
Implement the interface; autoconfiguration tags the service and it becomes available under getName():
namespace App\Ads\Decider; use Symfony\Component\OptionsResolver\OptionsResolver; use Tugrul\AdBundle\Decider\{DeciderInterface, Decision, DecisionContext}; final class UserRoleDecider implements DeciderInterface { public function __construct(private readonly \Symfony\Bundle\SecurityBundle\Security $security) {} public static function getName(): string { return 'user_role'; } public function configureOptions(OptionsResolver $resolver): void { $resolver ->define('deny_roles')->default(['ROLE_PREMIUM'])->allowedTypes('string[]') ->define('anonymous')->default('allow')->allowedValues('allow', 'deny'); } public function decide(DecisionContext $context, array $options): Decision { $user = $this->security->getUser(); if (null === $user) { return 'deny' === $options['anonymous'] ? Decision::deny('user_role.anonymous') : Decision::allow(); } foreach ($options['deny_roles'] as $role) { if ($this->security->isGranted($role)) { return Decision::deny('user_role.denied:'.$role); } } return Decision::allow(); } }
tugrul_ad: defaults: deciders: user_role: { deny_roles: [ROLE_PREMIUM, ROLE_ADMIN] }
DecisionContext gives you the AdContext (parameters, request, shared attribute bag), the AreaDefinition and — at candidate level — the CandidateDefinition. Options are validated once per area through your OptionsResolver; invalid YAML fails with a message naming the area/candidate.
Custom producers
namespace App\Ads\Producer; use Symfony\Component\OptionsResolver\OptionsResolver; use Tugrul\AdBundle\Producer\{ProducerInterface, ProductionContext}; final class AmazonNativeProducer implements ProducerInterface { public static function getName(): string { return 'amazon_native'; } public function configureOptions(OptionsResolver $resolver): void { $resolver ->define('instance_id')->required()->allowedTypes('string') ->define('tracking_id')->required()->allowedTypes('string'); } public function produce(ProductionContext $context, array $options): string { $params = $context->getContext()->getParameters(); // the Twig parameter bag return sprintf('<div id="amzn_assoc_ad_div_%s_0"></div><script>…</script>', $options['instance_id']); } }
tugrul_ad: areas: after_form: candidates: - producer: amazon_native options: { instance_id: abc, tracking_id: mytag-21 }
Return the inner HTML only; the engine adds the wrapper when enabled. Return '' to render nothing (the candidate is then reported as empty_output).
Page hooks: InitInterface and PostInterface
A producer often needs more than the unit markup: a loader in <head>, a bootstrap call before </body>. Implement InitInterface and/or PostInterface — on the producer class itself or on separate services, as you prefer; autoconfiguration registers both — and their output goes to {{ tugrul_ad_init() }} / {{ tugrul_ad_post() }}.
final class AmazonNativeProducer implements ProducerInterface, InitInterface, PostInterface { // … getName(), configureOptions(), produce() as above … public function init(PageContext $context): string { return '<script src="//z-na.amazon-adsystem.com/widgets/onejs?MarketPlace=US"></script>'; } public function post(PageContext $context): string { if (!$context->hasRendered('amazon_native')) { return ''; // none of our units on this page } return '<script>amzn_assoc_boot()</script>'; } }
PageContext gives you getResults() (every RenderResult of the page — empty inside <head>, complete before </body>), hasRendered($producer), isPlaceholderMode() and getRequest(). Each hook runs once per request; the per-request state is reset by the kernel, so it is safe on long-running workers.
Custom selection strategies
final class HourOfDayStrategy implements \Tugrul\AdBundle\Engine\Strategy\SelectionStrategyInterface { public static function getName(): string { return 'hour_of_day'; } public function select(array $eligible, \Tugrul\AdBundle\Context\AdContext $context): array { return [$eligible[(int) date('G') % \count($eligible)]]; } }
areas: rotation: { strategy: hour_of_day, candidates: [a, b, c] }
Events
| event | when | you can |
|---|---|---|
Tugrul\AdBundle\Event\PreRenderEvent |
before deciders | skip($reason), read/modify AdContext attributes |
Tugrul\AdBundle\Event\PostRenderEvent |
after render / skip / error | setHtml() (lazy-load wrappers, tracking pixels), inspect RenderResult |
#[AsEventListener] final class BotListener { public function __invoke(PreRenderEvent $event): void { if (str_contains((string) $event->getContext()->getRequest()?->headers->get('User-Agent'), 'bot')) { $event->skip('bot'); } } }
Statistics (future-proofing)
The engine calls StatisticsCollectorInterface::collect(RenderResult $result) after every render attempt. RenderResult exposes:
getStatus()—rendered|skipped|errorgetSkipReason()— e.g.country:country.denied:RU,no_eligible_candidate,listener:botgetRenderedCandidates()/getEligibleCandidates()getTrace()— every decider outcome with its reasongetDurationMs(),getContext()(parameters, request, resolved country),toArray()for logging/queueing
Implement the interface on any number of services; autoconfiguration tags them and ChainStatisticsCollector fans out to all of them. A collector that throws is logged and never breaks page rendering.
final class ImpressionCounter implements \Tugrul\AdBundle\Statistics\StatisticsCollectorInterface { public function __construct(private readonly \Symfony\Component\Messenger\MessageBusInterface $bus) {} public function collect(\Tugrul\AdBundle\Engine\RenderResult $result): void { $this->bus->dispatch(new RecordAdRender($result->toArray())); } }
Suggested roadmap for the lightweight statistics layer:
- Server-side: a collector that increments per-day counters keyed by
area/candidate/status/skip_reason(RedisHINCRBYor a Doctrine table with an upsert). - Client-side viewability & clicks: enable the wrapper, then a tiny script using
IntersectionObserveron[data-ad-area]elements posting{area, candidate}to a controller. - Reporting:
debug:tugrul-adcan grow a--statsoption; theRenderResult::toArray()shape is stable for that. - Frequency capping / pacing can be added as deciders that read those counters — no engine changes needed.
Services you may want to inject
| id / alias | class |
|---|---|
Tugrul\AdBundle\Engine\RenderEngine |
render / explain programmatically (e.g. in a controller returning HTML fragments) |
Tugrul\AdBundle\Area\AreaRegistry |
list and inspect areas; validate() returns configuration errors |
Tugrul\AdBundle\AdSense\AdSenseUnitRegistry |
labeled units and clients |
Tugrul\AdBundle\Producer\ProducerRegistry, …\Decider\DeciderRegistry, …\Engine\Strategy\StrategyRegistry |
what is registered |
Logging goes to the tugrul_ad Monolog channel.
Testing
composer install vendor/bin/phpunit
License
MIT