So let's say you run a car franchise, and you have several shops that you want to be able to manage through a web application. The application would be same for each shop, except you'd have to manage different sets of data and have specific assets for each shop. Fear not, this is very easy to achieve using symfony (well, it's quite easy with any framework I guess, but we will be using symfony :p)

Sample project

At the beginning, there were generate:project:

$ mkdir carshop
$ cd carshop
$ symfony generate:project carshop
$ symfony generate:app frontend
$ symfony generate:module frontend default

We will also need a simple database schema for the sake of examples. We will be using Doctrine, so let's fill in our config/doctrine/schema.yml with a very simple schema:

[yml]
detect_relations: true

Car:
  columns:
    site_id:      { type: integer }
    name:         { type: string(255) }
    description:  { type: clob }
    image:        { type: string(255) }

Site:
  columns:
    name:         { type: string(255) }
    domain:       { type: string(255), unique: true }
    main:           { type: boolean, default: false }

Also, let's not forget to enable Doctrine in our project in config/ProjectConfiguration.class.php, replace the line:

[php]
$this->enableAllPluginsExcept('sfDoctrinePlugin');

with:

[php]
$this->enableAllPluginsExcept('sfPropelPlugin');

and to configure the database:

$ symfony configure:database --name=doctrine --class=sfDoctrineDatabase "mysql:host=localhost;dbname=carshop" root

Oh, we shall need some fixtures too, let's put them in data/fixtures/01_sites.yml:

[yml]
Site:
  paris:
    name: Paris
    domain: paris.carshop
  auckland:
    name: Auckland
    domain: auckland.carshop

and data/fixtures/02_cars.yml:

[yml]
Car:
  car_1:
    Site: paris
    name: Peugeot 307
    description: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
  car_2:
    Site: paris
    name: Renault Laguna
    description: Maecenas tortor nunc, aliquam et, ultrices id, ornare consectetur, mauris.
  car_3:
    Site: auckland
    name: Subaru Impreza
    description: Ut accumsan diam et orci. Sed sit amet neque ac diam rutrum iaculis.

And now that everything is ready, let's create the tables and load the fixtures:

$ symfony doctrine:build-all-load

Vhosts

The second thing we will need is a set of vhost with differents domains all pointing to our newly generated project. It's quite easy to do, and I will assume you run some kind of linux or unix here (although it's similar under windows if I remember well). Let's start with creating fake domain names, you just have to add the following line to your /etc/hosts:

127.0.0.1 auckland.carshop paris.carshop

And the following to your apache vhost:

[apache]
ServerName paris.carshop
ServerAlias *.carshop

Save and quit, reload apache, and you're all set.

Detecting the current site

First thing we need to know if we want to handle site-specific data, is which site we're currently in. This can easily be done in the project configuration. Since we will need database access, we will hook up on the context.load_factories event. So, on with coding, open your config/ProjectConfiguration.class.php and add the following line to the setup() method:

[php]
$this->dispatcher->connect('context.load_factories', array($this, 'detectSite'));

This will hook the detectSite() method to the event context.load_factories. We now need to add the detectSite() method to the ProjectConfiguration class:

[php]
/**
 * Detects the current site based on the url
 * Fallback to the main site (main = 1 in database) if we can't find a suitable entry
 *
 * @param sfEvent $event
 */

public function detectSite(sfEvent $event)
{
  $request = sfContext::getInstance()->getRequest();
  $domain = $request->getHost();

  $siteTable = Doctrine::getTable('Site');

  if (false !== $site = $siteTable->retrieveByDomain($domain))
  {
    $siteTable->setCurrent($site);
  }
  else
  {
    $siteTable->setCurrent($siteTable->retrieveMain());
  }
}

Nothing very hard here. We get the HTTP host from the request, then try to fetch a corresponding Site from the database, fallbacking to the main site if necessary. You may have noticed that we added three methods to the Site model class: retrieveByDomain(), retrieveMain() and setCurrent(). Your lib/model/doctrine/SiteTable.class.php should look like this now:

[php]
<?php
/**
 * This class has been auto-generated by the Doctrine ORM Framework
 */
class SiteTable extends Doctrine_Table
{

  /**
   * Holds the current site
   * @var Site
   */

  protected $current;

  /**
   * Retrieve a Site by its domain
   *
   * @param string $domain
   * @return Site or false if no site is found
   */

  public function retrieveByDomain($domain)
  {
    return $this->createQuery()->where('domain = ?', $domain)->fetchOne();
  }

  /**
   * Retrieve the main site
   *
   * @return Site or false if no site is found
   */

  public function retrieveMain()
  {
    return $this->createQuery()->where('main = 1')->fetchOne();
  }
  

  /**
   * Sets the current site
   *
   * @param Site $site
   */

  public function setCurrent(Site $site)
  {
    $this->current = $site;
  }

}

While we're there, let's add a getCurrent() method as well:

[php]
/**
 * Gets the current site
 *
 * @return Site
 */

public function getCurrent()
{
  return $this->current;
}

Handling domain-specific logic and data

Ok, so now that we know where we are, let's make the Car model aware of the current site. Open lib/model/doctrine/CarTable.class.php and add the createQuery() method:

[php]
/**
 * Creates a query, adding the site criteria automatically
 *
 * @return Doctrine_Query
 * @see Doctrine_Table::createQuery()
 */

public function createQuery($alias = '')
{
  $query = parent::createQuery($alias);
  $query->where('site_id = ?', Doctrine::getTable('Site')->getCurrent()->getId());
  
  return $query;
}

This is the method used internally by Doctrine_Table to create queries related to the Car model, so now, all find methods fired from the Car model will only fetch cars from the current site. We need to take care of this at save() time too, in lib/model/doctrine/Car.class.php:

[php]
/**
 * Automatically populates the site_id field if necessary
 * 
 * @see sfDoctrineRecord::save()
 */

public function save(Doctrine_Connection $conn = null)
{
  if (empty($this->site_id))
  {
    $this->setSite(Doctrine::getTable('Site')->getCurrent());
  }

  return parent::save();
}

Handling domain specific assets

On with the assets. We are going to use our good friend mod_rewrite to handle this. First, let's create a specific directory for each domain we're going to manage:

$ mkdir -p web/perhost/{paris,auckland}.carshop/{css,images,js,uploads}

Also, for testing purpose, let's create some fake js file:

$ echo 'paris.carshop' > web/perhost/paris.carshop/js/foo.js
$ echo 'auckland.carshop' > web/perhost/auckland.carshop/js/foo.js
$ echo 'fallback' > web/js/fallback.js

Then add some voodoo magic in your web/.htaccess, before the we skip all files with .something rules generated by symfony:

[apache]
RewriteCond /home/ash/projects/carshop/trunk/web/perhost/%{HTTP_HOST}%{REQUEST_FILENAME} -f
RewriteRule (.*) /perhost/%{HTTP_HOST}/$1 [L]

Please note that %{REQUEST_FILENAME} includes the starting /, so that we don't need to add one after %{HTTP_HOST}.

Now check that you have mod_rewrite enabled (for example check that /etc/apache2/mods-enable/rewrite.load exists). If it's not enabled it, do it now:

$ sudo a2enmod rewrite
$ sudo /etc/init.d/apache2 reload

You can test the following urls to see if everything is going well, the results should be pretty obvious:

See you next time !

Now you have a working multi-domain architecture for your symfony project, but there are still a couple of things we could (and will) discuss:

  • uploading assets (from forms for example)
  • having a different layout for each site
  • having specific css files for each site (eg: paris.css and auckland.css)
  • cache problematic for shared assets

All these points will be addressed in the next part of this tutorial, but not before 2009 has came !