Building an Ad Manager in Symfony 2
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 ifrandom
isfalse
or reduced to a single item ifrandom
is true.
Each item from data
is an object composed of:
type
defines the media type, eitherimage
,video
orhtml
;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 iftype
isimage
, otherwise is ignored;weight
defines a boost whendata
contains several items andrandom
is set totrue
.
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:
- fetch configuration file;
- parse it;
- find data from given id;
- set cache configuration;
- reduce to a single item if it’s
random
; - 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!