How to handle dynamic Subdomains in Symfony

How to handle dynamic Subdomains in Symfony

From Rafael:

Hi, Symfony 2.2 has released hostname pattern for urls, I would like to know how can I create a url pattern that match domains loaded from a database? where should I put the code to load the domains and how should I pass this to a routing config file?

And from zaherg:

How can I handle auto generated subdomains routing with symfony 2?

Answer

Symfony 2.2 comes with hostname handling out of the box, which lets you create two routes that have the same path, but respond to two different sub-domains:

homepage:
    path: /
    defaults:
        _controller: QADayBundle:Default:index

homepage_admin:
    path: /
    defaults:
        _controller: QADayBundle:Admin:index
    host: admin.%base_host%

The base_host comes from a value in parameters.yml, which makes this all even more flexible.

But what if you’re creating a site that has dynamic sub-domains, where each subdomain is a row in a “site” database table? In this case, the new host routing feature won’t help us: it’s really meant for handling a finite number of concrete subdomains.

So how could this be handled? Let’s find out together!

1) The VirtualHost

Before you go anywhere, make sure you have an Apache VirtualHost or Nginx site that sends all the subdomains of your host to your application. Since we’re using lolnimals.l locally, we’ll want *.lolnimals.l to be handled by the VHost.

<VirtualHost *:80>
  ServerName qaday.l
  ServerAlias *.qaday.l

  DocumentRoot "/Users/leannapelham/Sites/qa/web"
  <Directory "/Users/leannapelham/Sites/qa/web">
    AllowOverride All
    Allow from All
  </Directory>
</VirtualHost>

Next, add a few entries to your /etc/hosts file for subdomains that we can play with:

# /etc/hosts
127.0.0.1       lolnimals.l kittens.lolnimals.l alpacas.lolnimals.l dinos.lolnimals.l

Great! Restart or reload your web server and then at least check that you can hit your application from any of these sub-domains. So far our application isn’t actually doing any logic with these subdomains, but we’ll get there!

2) Create the Site Entity

Next, let’s use Doctrine to generate a new Site entity, which will store all the information about each individual subdomain:

php app/console doctrine:generate:entity

Give the entity a name of QADayBundle:Site, which uses a QADayBundle that I already created. For fields, add one called subdomain and two others called name and description, so we at least have some basic information about this site.

Note

Press tab to take advantage of the command autocompletion. This is the brand new 2.2 autocomplete feature in action.

Finish up the wizard then immediately create the database and schema. Be sure to customize your app/config/parameters.yml file first:

php app/console doctrine:database:create
php app/console doctrine:schema:create

Finally, to make things interesting, I’ll bring in a little data file that will add two site records into the database:

// load_sites.php
require __DIR__.'/vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
$loader = require_once __DIR__.'/app/bootstrap.php.cache';
require_once __DIR__.'/app/AppKernel.php';
$kernel = new AppKernel('dev', true);
$request = Request::createFromGlobals();
$kernel->boot();
$container = $kernel->getContainer();
$container->enterScope('request');
$container->set('request', $request);

// start loading things
use KnpU\QADayBundle\Entity\Site;

/** @var $em \Doctrine\ORM\EntityManager */
$em = $container->get('doctrine')->getManager();
$em->createQuery('DELETE FROM QADayBundle:Site')->execute();

$site1 = new Site();
$site1->setSubdomain('kittens');
$site1->setName('Cute Kittens');
$site1->setDescription('I\'m peerrrrfect!');

$site2 = new Site();
$site2->setSubdomain('alpacas');
$site2->setName('Funny Alpacas');
$site2->setDescription('Alpaca my bags!');

$em->persist($site1);
$em->persist($site2);
$em->flush();

A better way to do this is with some real fixture files, but this will work for now. This script bootstraps Symfony, but then lets us write custom code beneath it. If you’re curious about this script or fixtures, check out our Starting in Symfony2 series where we cover all this goodness and a ton more.

Execute the script from the command line.

php load_sites.php

I’ll use the built-in doctrine:query:sql command to double-check that things work.

php app/console doctrine:query:sql "SELECT * FROM Site"

Great, let’s get to the good stuff!

3) Finding the current Site the “Easy” Way

Because of our VirtualHost, our application already responds to every subdomain of lolnimals.l. The goal in our code is to be able to determine, based on the host name, which Site record in the database is being used.

First, let’s use a homepage route and controller that I’ve already created. This will seem simple, but for now, let’s determine which Site record is being used by querying directly here. I’ll add the $request as an argument to the method to get the request object, then use getHost to grab the host name. Dump the value to see that it’s working:

// src/KnpU/QADayBundle/Controller/DefaultController.php

use Symfony\Component\HttpFoundation\Request;
// ...

public function indexAction(Request $request)
{
    $currentHost = $request->getHttpHost();
    var_dump($currentHost);die;

    return $this->render('QADayBundle:Default:index.html.twig');
}

The value stored in the database is actually only the subdomain part, not the whole host name. In other words, we need to transform alpacas.lolnimals.l into simply alpacas before querying. Fortunately, I’ve already stored my base host as a parameter in parameters.yml:

# /app/config/parameters.yml
parameters:
    # ...
    base_host:         qaday.l

By grabbing this value out of the container and doing some simple string manipulation, we can get the current subdomain key:

// src/KnpU/QADayBundle/Controller/DefaultController.php
// ...

public function indexAction(Request $request)
{
    $currentHost = $request->getHttpHost();
    $baseHost = $this->container->getParameter('base_host');

    $subdomain = str_replace('.'.$baseHost, '', $currentHost);
    var_dump($subdomain);die;

    return $this->render('QADayBundle:Default:index.html.twig');
}

Perfect! Now querying for the current Site is pretty easy. We’ll also assume that we need a valid subdomain - so let’s show a 404 page if we can’t find the Site:

// src/KnpU/QADayBundle/Controller/DefaultController.php
// ...

$site = $this->getDoctrine()
    ->getRepository('QADayBundle:Site')
    ->findOneBy(array('subdomain' => $subdomain))
;
if (!$site) {
    throw $this->createNotFoundException(sprintf(
        'No site for host "%s", subdomain "%s"',
        $baseHost,
        $subdomain
    ));
}

Finally, pass the $site into the template so we can prove we’re matching the right one:

// src/KnpU/QADayBundle/Controller/DefaultController.php
// ...

return $this->render('QADayBundle:Default:index.html.twig', array(
    'site' => $site,
));

Dump some basic information out in the template to celebrate:

{# src/KnpU/QADayBundle/Resources/views/Default/index.html.twig #}
{%  extends '::base.html.twig' %}

{% block body %}
    <h1>Welcome to {{ site.name }}</h1>

    <p>{{ site.description }}</p>
{% endblock %}

Ok, try it out! The alpacas and kittens subdomains work perfectly, and the dinos subdomain causes a 404, since there’s no entry in the database for it.

This is simple and functional, but let’s do better!

4) The Site Manager

We’ve met our requirements of dynamic sub-domains, but it’s not very pretty yet. We’ll probably need to know what the current Site is all over the place in our code - in every controller and in other places like services. And we certainly don’t want to repeat all of this code, that would be crazy!

Let’s fix this, step by step. First, create a new class called SiteManager, which will be responsible for always knowing what the current Site is. The class is very simple - just a property with a get/set method:

// src/KnpU/QADayBundle/Site/SiteManager.php
namespace KnpU\QADayBundle\Site;

use KnpU\QADayBundle\Entity\Site;

class SiteManager
{
    private $currentSite;

    public function getCurrentSite()
    {
        return $this->currentSite;
    }

    public function setCurrentSite(Site $currentSite)
    {
        $this->currentSite = $currentSite;
    }
}

Next, register this as a service. If services are a newer concept for you, we cover them extensively in Episode 3 of our Symfony2 Series. I’ll create a new services.yml file in my bundle. The actual service configuration couldn’t be simpler:

# src/KnpU/QADayBundle/Resources/config/services.yml
services:
    site_manager:
        class: KnpU\QADayBundle\Site\SiteManager

This file is new, so make sure it’s imported. I’ll import it by adding a new imports entry to config.yml:

# app/config/config.yml
imports:
    # ...
    - { resource: "@QADayBundle/Resources/config/services.yml" }

Sweet! Run container:debug to make sure things are working:

php app/console container:debug | grep site
site_manager   container KnpU\QADayBundle\Site\SiteManager

Perfect! So.... how does this help us? First, let’s set the current site on the SiteManager from within our controller:

// src/KnpU/QADayBundle/Controller/DefaultController.php
// ...

/** @var $siteManager \KnpU\QADayBundle\Site\SiteManager */
$siteManager = $this->container->get('site_manager');
$siteManager->setCurrentSite($site);

return $this->render('QADayBundle:Default:index.html.twig', array(
    'site' => $siteManager->getCurrentSite(),
));

Don’t let this step confuse you, because it’s pretty underwhelming. This sets the current site on the SiteManager, which we use immediately to pass to the template. If this looks kinda dumb to you, it is! Getting the current site from the SiteManager is cool, but the problem is that we still need to set this manually.

In other words, the SiteManager is only one piece of the solution. Now, let’s add an event listener to fix the rest.

5) Determining the Site automatically with an Event Listener

Somehow, we need to be able to move the logic that determines the current Site out of our controller and to some central location. To do this, we’ll leverage an event listener. Again, if this is new to you, we cover it in Episode 3 of our Symfony2 Series.

First, create the listener class, let’s call it CurrentSiteListener and set it to have the SiteManager and Doctrine’s EntityManager injected as dependencies. Let’s also inject the base_host parameter, we’ll need it here as well:

// src/KnpU/QADayBundle/EventListener/CurrentSiteListener.php
namespace KnpU\QADayBundle\EventListener;

use KnpU\QADayBundle\Site\SiteManager;
use Doctrine\ORM\EntityManager;

class CurrentSiteListener
{
    private $siteManager;

    private $em;

    private $baseHost;

    public function __construct(SiteManager $siteManager, EntityManager $em, $baseHost)
    {
        $this->siteManager = $siteManager;
        $this->em = $em;
        $this->baseHost = $baseHost;
    }
}

The goal of this class is to determine and set the current site at the very beginning of every request, before your controller is executed. Create a method called onKernelRequest with a single $event argument, which is an instance of GetResponseEvent:

// src/KnpU/QADayBundle/EventListener/CurrentSiteListener.php

// ...
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class CurrentSiteListener
{
    // ...

    public function onKernelRequest(GetResponseEvent $event)
    {
        die('test!');
    }
}

Tip

The Symfony.com documentation has a full list of the events and event objects in the HttpKernel section.

Before we fill in the rest of this method, register the listener as a service and tag it so that it’s an event listener on the kernel.request event:

services:
    # ...

    current_site_listener:
        class: KnpU\QADayBundle\EventListener\CurrentSiteListener
        arguments:
            - "@site_manager"
            - "@doctrine.orm.entity_manager"
            - "%base_host%"
        tags:
            -
                name: kernel.event_listener
                method: onKernelRequest
                event: kernel.request

And with that, let’s try it! When we refresh the page, we can see the message that proves that our new listener is being called early in Symfony’s bootstrap.

With all that behind us, let’s fill in the final step! In the onKernelRequest method, our goal is to determine and set the current site. Copy the logic out of our controller into this method, then tweak things to hook up:

public function onKernelRequest(GetResponseEvent $event)
{
    $request = $event->getRequest();

    $currentHost = $request->getHttpHost();
    $subdomain = str_replace('.'.$this->baseHost, '', $currentHost);

    $site = $this->em
        ->getRepository('QADayBundle:Site')
        ->findOneBy(array('subdomain' => $subdomain))
    ;
    if (!$site) {
        throw new NotFoundHttpException(sprintf(
            'No site for host "%s", subdomain "%s"',
            $this->baseHost,
            $subdomain
        ));
    }

    $this->siteManager->setCurrentSite($site);
}

The differences here are a bit subtle. For example, the baseHost is now stored in a property and we can get Doctrine’s repository through the $em property. We’ve also replaced the createNotFoundException call by instantiating a new NotFoundHttpException instance. The createNotFoundException method lives in Symfony’s base controller. We don’t have access to it here, but this is actually what it really does behind the scenes.

Since we’ve registered this as an event listener on the kernel.request event, this method will guarantee that the SiteManager has a current site before our controller is ever executed. This means we can get rid of almost all of the code in our controller:

public function indexAction()
{
    /** @var $siteManager \KnpU\QADayBundle\Site\SiteManager */
    $siteManager = $this->container->get('site_manager');

    return $this->render('QADayBundle:Default:index.html.twig', array(
        'site' => $siteManager->getCurrentSite(),
    ));
}

Try it out! Sweet, it still works! We can now use the SiteManager from anywhere in our code to get the current Site object. For example, if we needed to load all the blog posts for only this Site, we could grab the current Site then create a query that returns only those items. Basically, from here, you can be dangerous!

Leave a comment!

  • 2016-07-29 weaverryan

    Hey Shairyar!

    Actually, /etc/hosts is *not* dynamic like this. However, you *can* make DNS records on the web that are dynamic. In other words, this is a problem when developing locally, but not on production. There *are* ways to do this locally, but they're more complex than using /etc/hosts.

    But check out Laravel's Valet: https://laravel.com/docs/5.2/v.... This is a standalone tool to help manage this type of thing. I haven't used it yet, but I believe it does something similar: it sets up something locally so that all *.dev sites point to your local machine. It might or might not work - but something worth checking (or you can try to dig to see how they do it).

    Cheers!

  • 2016-07-29 Shairyar Baig

    Hi Ryan, I have been thinking if it is possible to create some sort of wildcard host in /etc/hosts like

    *.lolnimals.l

    This way we don't have to hard code every host/subdomain? is this possible? Reason I am asking this is because I have this signup form where user provides the company name and then based on this company a url is generated for them to use the web app like http://companyname.example.com now the routing and all is fine but it is this /etc/hosts file I am thinking how to setup so I dont have to manually add every company name domain in it.

  • 2016-07-26 Shairyar Baig

    Thanks Ryan, i will dig into this further

  • 2016-07-26 weaverryan

    It depends on *how* much needs to change. There are kind of 3 levels:

    A) If you just need a different logo and different company name, just make sure your Site entity has fields for logo and "name" and print this in Twig! I usually make some custom Twig function like get_current_site(), or even make site() a global variable so that I have the Site object.

    B) If you have a few different variants of part of your page (e.g. you have 15 sites, but they have 3 different themes), then you could use something like LiipThemeBundle and set the theme based on some property on the Site (e.g. Site.themeName)

    C) If you parts of your page need to be completely different and you even need to be able to change those templates on production, then yes, you'll need to store at least some fragments of Twig in the database (which is ok, but this seems like a crazy requirement to me!)

    I hope that helps!

  • 2016-07-26 Shairyar Baig

    Thanks Ryan for letting me know about the filter tutorial, I will look into that. Out of curiosity I was wondering if we have dynamic subdomains setup what happens if you want to change the look of one of the subdomains a bit for example change the logo or background color, how will that work since the code base behind the scene is all same? Does this mean that we will need to save the template parts in database which users can customise as per theirs needs or there is an easier alternate provided by Symfony?

  • 2016-07-25 weaverryan

    Exactly :). The trick is to keep your code organized and make sure that *all* queries include the WHERE statement for the correct site. I typically do this manually, but you can also have Doctrine automatically add that to the query (http://knpuniversity.com/scree.... It's a matter of taste.

    Cheers!

  • 2016-07-25 Shairyar Baig

    Nice tutorial, This is exactly what i was looking for, this means that the site table needs to have relationship with every table that gets created so the data can be linked with the site that the content are meant for.

  • 2015-01-19 weaverryan

    Ya, no video download for this - it was just because it was the *only* video in all of these posts. But if you want, you can always download the mp4 that's streaming in the browser (open up network tools to get the URL).

    Cheers!

  • 2015-01-17 Diego Aguiar

    Is it possible to download this video ? Is not the option in Download button

  • 2014-11-19 weaverryan

    Hey Jeff!

    Actually, you're right on all accounts. You would only *need* an event listener if you needed to actually *do* something with the current "Site" at the beginning of the request (e.g. maybe some Site's are locked down, so you redirect to the login page). But if you only need the information later from some service, then yea, just inject the service as you said. So, you're not missing anything at all - quite the opposite :).

    The StackOverflow you linked to correctly injects the request_stack - so that's perfect. It does it via "setter" injection (that's the "calls" stuff). You can also just inject it via the constructor like any other normal service - just wanted to highlight that there's nothing special going on there.

    Cheers!

    P.S. Good question - maybe you're not such a novice ;)

  • 2014-11-19 Jeff Way

    Why is the event listener necessary? It may be my inexperience talking but it seems like anything involving events causes code to appear kind of magical unless you are already aware of it, so for me events are something to be avoided unless they are necessary. In this case it isn't really necessary since it's possible to inject the request directly into your service. Here's how: http://stackoverflow.com/quest...

    What do you guys think? I'm still a novice to Symfony so I'd really appreciate some best-practices wisdom :)