PHP
Article

Building an Ad Manager in Symfony 2

By Hugo Giraudel

Just this once won’t hurt – I am not going to write about Sass but Symfony. I had to do a little bit of backend at work and ended up with an interesting problem to solve, involving quite a lot of things so I thought it wouldn’t be such a bad idea to write an article about it.

But first, let me explain. The main idea was to build an ad manager. What the hell is an ad manager you say? Let’s say you have some places on your site/application to display ads. We do have things like this on our site, and one of our teams is (partially) dedicated to bringing those places to life with content.

Now for some boring reasons I won’t list here, we couldn’t use an existing tool, so we were doomed to build something from scratch. As usual, we wanted to do a lot without much coding, while keeping an overall simplicity for the end user (who is not a developer). I think we came up with a fairly decent solution for our little project.

Here are the features we set up:

  • YAML configuration + FTP access;
  • Either images, videos or HTML content;
  • Ability to customize cache duration;
  • Either sliders (yes, the pattern sucks) or random item in collection.

The idea is quite simple. In Twig templates, we use render_esi (more info here) hitting on a controller action passing it a unique key (basically the name of the ad spot), for instance:

{{ render_esi(url('ads_manager', { 'id': 'home_sidebar_spot' })) }}

Then the action fetches the YAML configuration file, grabs the data associated with the given id, and renders a template. At this point, the template does some very simple logic depending on which type of content the given items are (images, videos, HTML…).

Ready? Let’s go.

Global configuration

There are two things we need to have in a global configuration (likely the parameters.yml file): path to the configuration file (ads.yml), and an array of allowed media types.

ads:
    uri: http://location.com/path/to/ads.yml
    allowed_types: ['image', 'video', 'html']

The configuration file

The configuration file is maintained by the team in charge of making the ads. YAML is a human-friendly language that’s perfect for when it comes to simple configuration.

This file is built like this:

home_sidebar_spot:
    cache_public: true
    cache_shared_max_age: 86400
    cache_max_age: 28800
    random: true
    data: 
        - type: "image"
          link: "http://cdn.domain.tld/path/to/file.png"
          target: "http://google.fr/"
          weight: 1

Where:

  • cache_public defines whether the cache should public or private;
  • cache_shared_max_age defines the max age for the cache on the server network;
  • cache_max_age defines the max age for the cache on the client browser;
  • random defines whether the spot should be treated as a slider (multiple items coming one after another) or a static item, randomly chosen;
  • data is an array of items, either all displayed if random is false or reduced to a single item if random is true.

Each item from data is an object composed of:

  • type defines the media type, either image, video or html;
  • link defines the media content so an absolute URL to an image file, a video service or an HTML file;
  • target defines the target of the link behind the image if type is image, otherwise is ignored;
  • weight defines a boost when data contains several items and random is set to true.

And there is such a block for every ad spot on our site, so basically a dozen of those.

The Controller

The controller is very simple: it has a single action. The scenario is:

  1. fetch configuration file;
  2. parse it;
  3. find data from given id;
  4. set cache configuration;
  5. reduce to a single item if it’s random;
  6. render view.
<?php
// Namespace and uses
class AdsManagerController extends Controller
{

    /**
     * @Route("/ads_manager/{id}", name="ads_manager")
     */
    public function indexAction ($id)
    {
        // Fetch data
        $data = $this->getData($id);

        // Prepare response
        $response = new Response();

        // Configure cache
        if ($data['cache_public'] === true) {
            $response->setPublic();
        } else {
            $response->setPrivate();
        }

        // Set max age
        $response->setMaxAge($data['cache_max_age']);
        $response->setSharedMaxAge($data['cache_shared_max_age']);

        // Handle the weight random
        if ($data['random'] === true) {
            $data['data'] = [$this->randomItem($data['data'])];
        }

        // If content is HTML, fetch content from file in a `content` key
        foreach ($data['data'] as $item) {
            if (strtolower($item['type']) === 'html') {
                $item['content'] = file_get_contents($item['link']) || $item['link'];
            }
        }

        // Set content
        $response->setContent($this->renderView('FrontBundle:AdsManager:index.html.twig', [
            'allowed_type' => $this->container->getParameter('ads')['allowed_types'],
            'content' => $data,
            'id' => $id
        ]));

        return $response;
    }
}

private function getData($id)
{
    // Get path to Ads configuration
    $url = $this->container->getParameter('ads')['uri'];
    // Instanciate a new Parser
    $parser = new Parser();

    // Read configuration and store it in `$data` or throw if we cannot parse it
    try {
        $data = $parser->parse(file_get_contents($url));
    } catch (ParseException $e) {
        throw new ParseException('Unable to parse the YAML string:' . $e->getMessage());
    }

    // If `$id` exists in data, fetch content or throw if it's not found
    try {
        return $data = $data[$id];
    } catch (\Exception $e) {
        throw new \Exception('Cannot find `' + $id + '` id in configuration:' . $e->getMessage());
    }
}

private function randomItem($array) {
    $weights = array_column($array, 'weight');
    $total   = array_sum($weights);
    $random  = rand(1, $total);
    $sum     = 0;

    foreach ($weights as $index => $weight) {
        $sum += $weight;

        if ($random <= $sum) {
            return $array[$index];
        }
    }
}

?>

I know opinions are split between avoiding private methods in controllers and exploding long actions into smaller chunks of code. I went for the latter, but feel free to correct me if you feel like it’s a mistake. I’m no PHP developer. ;)

The View

At this point, our controller is done. We only have to deal with the view. We have a little bit of logic in the templates but it actually makes sense since it’s strictly about how to display the content, so that shouldn’t be too much.

The main idea is: either the data key from content contains several items, in which case we output a slider (Bootstrap carousel in our case), or it has a single item so we output only one. In either case, we don’t output an item directly; we include a partial that deals with type checking in case something is wrong, and redirects to the appropriate partial. But let’s start at the beginning.

{# If there are several items to display #}
{% if content.data|length > 1 %}
  {# Output a carousel #}
  <div class="carousel  slide" data-ride="carousel" data-interval="3000">
    {# Carousel indicators #}
    {% for i in 0..(content.data|length)-1 %}
    {% if loop.first %}
    <ol class="carousel-indicators">
    {% endif %}
      <li data-target=".carousel" data-slide-to="{{ i }}" {% if loop.first %}class="active"{% endif %}></li>
    {% if loop.last %}
    </ol>
    {% endif %}
    {% endfor %}

    {# Carousel items #}
    {% for item in content.data %}
    {% if loop.first %}
    <div class="carousel-inner">
    {% endif %}
      <div class="item{% if loop.first %}  active{% endif %}">
        {% include '@Front/AdsManager/_type.html.twig' with {
          type: item.type, 
          item: item
        } %}
      </div>
    {% if loop.last %}
    </div>
    {% endif %}
    {% endfor %}
  </div>

{# If there is a single item, include it #}
{% else %}

  {% include '@Front/AdsManager/_type.html.twig' with {
    type: (content.data|first).type, 
    item: (content.data|first)
  } %}

{% endif %}

Let’s see what the _type partial looks like:

{# If type is allowed, include the relevant partial #}
{% if type|lower in allowed_type %}
  {% include '@Front/AdsManager/_' ~ type ~ '.html.twig' with { item: item } %}
{# Else print an error #}
{% else %}
  <p>Unknown type <code>{{ type }}</code> for id <code>{{ id }}</code>.</p>
{% endif %}

Last, but not least, our partials for specific types:

{# _image.html.twig #}
<div class="epub">
  <a href="{{ item.target|default('#') }}" class="epub__link">
    <img src="{{ item.link|default('http://cdn.domain.tld/path/to/default.png') }}" 
         alt="{{ item.description|default('Default description') }}" 
         class="epub__image" />
  </a>
</div>
{# _video.html.twig #}
{% if item.link %}
<div class="video-wrapper">
  <iframe src="{{ item.link }}" frameborder="0" allowfullscreen></iframe>
</div>
{% endif %}
{# _html.html.twig #}
{{ item.content|default('') }}

Final thoughts

That’s it! Wasn’t that hard in the end, was it? Yet, it is both a simple and powerful way to manage ads when you cannot rely on third-party services. There is always room for improvement, so feel free to suggest updates and tweaks.

Cheers!

  • Carlos Equiz

    Awesome article! Thank you so much!

  • Charles Bryant

    Really interesting topic, I think everyone just assumes ads appear by magic. There are loads of ways to expand this example like, max impressions per time frame, recording click throughs.

    As far as private methods go I hate them almost as much as the final key word. If in doubt use protected. Having to refactor and retest a class is a pain.

    • http://hugogiraudel.com/ Hugo Giraudel

      I’ve heard very different opinions regarding private methods. Being a front-end developer, I really can’t tell which approach is the best. I just went with what felt natural to me. ;)

      Nice to have an extra opinion though.

  • Robert

    Why not put the business logic into a service? Would make the controller smaller and then “private methods” point irrelevant ;)

Recommended
Sponsors
Because We Like You
Free Ebooks!

Grab SitePoint's top 10 web dev and design ebooks, completely free!

Get the latest in PHP, once a week, for free.