Search by

tugrul / ad-bundle

tugrul

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.

Package info

github.com/tugrul/AdBundle

Type:symfony-bundle

pkg:composer/tugrul/ad-bundle

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-11-05 14:09 UTC

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 implementing ProducerInterface.
  • AMP pages (identified by route name, amp_* by default) get <amp-ad> markup and the amp-ad component script automatically; an amp decider targets AMP vs. regular pages.
  • Placeholder mode renders the dummy placeholder producer instead of real ads on every environment except prod, 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 implementing DeciderInterface.
  • Strategies choose among eligible candidates: first, random, weighted, all — or your own SelectionStrategyInterface.
  • 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 RenderResult with a full decision trace, dispatched through events and a StatisticsCollectorInterface — 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:

  • adsense producer: renders <amp-ad type="adsense"> from the unit's amp settings 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>
    amp resolves like the other unit properties (adsense.defaults.amp → unit → candidate options.amp); amp: false on a unit or candidate renders nothing on AMP pages. Classes carry over (without adsbygoogle), amp.attr is for extra attributes such as data-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 check PageContext::isAmp().
  • amp decider: amp: { require: false } keeps a candidate that isn't AMP-valid (an html snippet 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 amp width/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:

  1. Look up the area (on_missing_area decides what happens if it doesn't exist).
  2. enabled: false → skipped.
  3. PreRenderEvent — any listener can veto ($event->skip('reason')) or enrich the context.
  4. Area-level deciders (defaults.deciders merged with the area's own). The first deny skips the area.
  5. Candidate-level deciders for every enabled candidate. Candidates not denied are eligible.
  6. The strategy picks which eligible candidates render.
  7. Producers render them (on_producer_error decides what a throwing producer does).
  8. The wrapper element is added, if enabled.
  9. PostRenderEvent (listeners may rewrite the HTML) and StatisticsCollectorInterface::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 | error
  • getSkipReason() — e.g. country:country.denied:RU, no_eligible_candidate, listener:bot
  • getRenderedCandidates() / getEligibleCandidates()
  • getTrace() — every decider outcome with its reason
  • getDurationMs(), 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:

  1. Server-side: a collector that increments per-day counters keyed by area / candidate / status / skip_reason (Redis HINCRBY or a Doctrine table with an upsert).
  2. Client-side viewability & clicks: enable the wrapper, then a tiny script using IntersectionObserver on [data-ad-area] elements posting {area, candidate} to a controller.
  3. Reporting: debug:tugrul-ad can grow a --stats option; the RenderResult::toArray() shape is stable for that.
  4. 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