Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

How to handle dynamic Subdomains in Symfony

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

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!

24
Login or Register to join the conversation
Default user avatar
Default user avatar Nikhil EV | posted 2 years ago

Hi great tutorial. I am trying to register new user from public like test1.example.com, test2.example.com etc. so when a client register do we need to edit 'hosts' file each client register, to add the subdomain programatically. Do we have any alternative solution to accept all wildcard subdomain in host file ? Thanks in advance

Reply

Hey Nikhil EV

Hm... IIRC there is no possibility to use wildcards directly in hosts file so the only solution for it is to use some sort of local DNS server or you can use Symfony CLI dev server it has a proxy server which allows you to have dev domains for free but with some limitations

Cheers!

1 Reply
Tac-Tacelosky Avatar
Tac-Tacelosky Avatar Tac-Tacelosky | posted 4 years ago

Terrific tutorial, works great.

On the same theme of dynamic subdomains, how can I keep the same user authenticated when I switch between subdomains? I have the session / handler_id set to ~ in framework.yaml, not sure how to set the cookie domain.

Reply

Hey Michael,

Good question! Hm, actually, it should work our of the box with Symfony, but according to your question cookie domain should be as ".example.com" - note that dot before example.com - that means you want to use these cookies on example.com and all its subdomains.

Cheers!

Reply
Default user avatar
Default user avatar Mike Base | posted 4 years ago

Hi guys. I am trying to use that method in Symfony 4. But when I set currentSite with Listener and inject SiteManager to Controller Symfony create new Instance of SiteManager class and currentSite is null. Please help. Regards Mike

Reply
Otto K. Avatar
Otto K. Avatar Otto K. | Mike Base | posted 4 years ago | edited

Hey Mike Base!

Interesting! I'm sure we can get this working! Can you post your listener and controller code? If setup correctly, Symfony should be using the same SiteManager instance in both the listener and controller. So, this "shouldn't" be happening - I bet something isn't quite right :).

Cheers!

Reply
Default user avatar
Default user avatar Mike Base | Otto K. | posted 4 years ago | edited

Thanks for the response Otto K., below my code (the code is customized to my app, but the behaviour is the same):


class CurrentAccountListener {

    /**
     * @var TokenStorage
     */
    private $tokenStorage;
    
    /**
     * @var EntityManagerInterface
     */
    private $em;
    
    /**
     * @var AccountManager
     */
    private $accountManager;
    
    private $baseHost;
    
    public function __construct(TokenStorage $tokenStorage, EntityManagerInterface $em, AccountManager $accountManager, $baseHost) {
        $this->tokenStorage = $tokenStorage;
        $this->em = $em;
        $this->accountManager = $accountManager;
        $this->baseHost = $baseHost;
    }
    
    
    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();
        $currentAccount = $this->getCurrentAccount($request);
        
        $accountManager = $this->accountManager;
        $accountManager->setCurrentAccount( $currentAccount );

        dump($accountManager->getCurrentAccount());
    }

    private function getCurrentAccount(Request $request)
    {
        if($this->getCurrentAccountBySubDomain($request) ) {
            return $this->getCurrentAccountBySubDomain($request);
        }
        if($this->getCurrentAccountByLoggedUser()) {
            return $this->getCurrentAccountByLoggedUser();
        }
        return null;
    }
    
    private function getCurrentAccountBySubDomain(Request $request) {
        
        $host = $request->getHost();
        $baseHost = $this->baseHost;
        
        $subdomain = str_replace('.'.$baseHost, '', $host);
        
        $account = $this->em->getRepository('App:Account')
                ->findOneBy([ 'urlName' => $subdomain ]);

        if(!$account) return null;
        
        return $account;
    }
    
    private function getCurrentAccountByLoggedUser() {
        return $this->getLoggedUser() ? $this->getLoggedUser()->getPromoterAccount() : null;
    }
    
    private function getLoggedUser() {

        if(is_null($token = $this->tokenStorage->getToken())) {
            return null;
        }
        $user = $token->getUser();

        return ($user instanceof User) ? $user : null;
    }

}

class DefaultController extends AbstractController
{

/**
 * @Route("/test", name="test")
 */
public function test(AccountManager $accountManager)
{
    dump($accountManager->getCurrentAccount());

    die;
}



result - https://i.imgur.com/dVpHDuY.png


That code is working properly on Symfony3 when I use:

$this->get('account.manager')->getCurrentAccount();`

Reply

Hey Mike Base!

Hmm. This all looks good to me. But, I have an idea. Can you show me your services.yml? I'm wondering if, while upgrading to the new "service magic", you may have accidentally registered the AccountManager service two times? Specifically, if you still have the old account.manager service declared, but you also have the new "service autoregistration" code (the lines with resource and exclude), then your service is being created two times. If that's the case, my guess is that you're passing the old one (with the snake case id) to your listener, while the controller "action injection" is definitely passing you the new service.

Let me know!

Cheers!

Reply
Default user avatar

You were right! I had an old definition of services, after removing works fine:) but I have one more issue. In my listener, I have method which checks if there is user logged in. After update servies.yml Symfony says that I should use TokenStorageInterface instead of TokenStorage. What $tokenId should I add as a argument to getToken()?

Reply

Hey Mike Base

There is a better way to get the logged in user in a service, by using the "Security" service. In this episode you can see how Ryan uses it: https://symfonycasts.com/sc...

Cheers!

Reply

And, specifically, I think you have the wrong TokenStorageInterface - there are (unfortunately) 2. If you tried the other one, it *would* work. But Diego is right - Security is easier!

Reply
Default user avatar
Default user avatar Mike Base | weaverryan | posted 4 years ago | edited

Agree. Security works perfecto:) weaverryan weaverryan huge thanks for your help. Cheers!

Reply

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

Reply

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!

1 Reply
Default user avatar
Default user avatar Jeff Way | posted 5 years ago

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/qu...

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

Reply

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 ;)

Reply
Default user avatar
Default user avatar Shairyar Baig | posted 5 years ago

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.

Reply

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/sc.... It's a matter of taste.

Cheers!

Reply
Default user avatar
Default user avatar Shairyar Baig | weaverryan | posted 5 years ago

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?

Reply

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!

Reply
Default user avatar
Default user avatar Shairyar Baig | weaverryan | posted 5 years ago

Thanks Ryan, i will dig into this further

Reply
Default user avatar
Default user avatar Shairyar Baig | weaverryan | posted 5 years ago | edited

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.
Reply

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..... 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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

userVoice