One core, one app, multiple domains

This article is also available in the following languages:
By eimermusic
Dealing with configurations for multiple domains (environments) is always a topic for discussion. I wanted to share my way of dealing with this common problem. This will not be right for all, which is the point. If this does not suit you, there are a few links at the bottom.

What will this do?


Simplify updates using SCM (git pull anyone?).
Ability to run a single vhost setting, single cake core, single app directory for multiple subdomains.
Each subdomain is it's own application not simply an alias.
Can run domain-sensitive shell tasks.


Apache first.

(or whatever you use)
Just create a vhost and keep adding aliases for each new domain.

ServerName client1.example.com
ServerAlias client2.example.com
ServerAlias client3.example.com
(I don't like wildcards here since I want any non-existing subdomains to point to a non-cake "error site")

Now Cake.

The domain switching is handled in bootstrap.php by looking at the SERVER_NAME environment variable. THis is not perfect. But I think it's preferable to something like using "SetEnv" in htaccess or the vhost. This is simply because that if Apache isn't providing the server name then you probably can't see the set parameter either.
Shell access, you say? Well, that is where the first of the two if clauses in bootstrap.php comes in. More on that in a minute.

bootstrap.php


<?php
// Initial defaults go at the top, for example:
Configure::write('Config.language''swe');


// Load domain-specific config
if ( isset($_SERVER['SERVER_NAME']) ) { // web
    
$bootstrap CONFIGS .'domains'.DS.$_SERVER['SERVER_NAME'].'.php';
} elseif ( 
count($_SERVER['argv']) ) { // cli
    
$_SERVER['SERVER_NAME'] = $_SERVER['argv'][count($_SERVER['argv'])-1];
    
$bootstrap CONFIGS .'domains'.DS.$_SERVER['argv'][count($_SERVER['argv'])-1].'.php';
}
if ( 
file_exists($bootstrap) ) {
    require(
$bootstrap); 
} else {
    echo 
'No configuration could be loaded for domain '.$_SERVER['SERVER_NAME'].'. Exiting...';
    exit;
}    


// At the bottom you can override configurations if you found you had to.
// This is also where you define defaults for constants. (keeps the if out of the domain file)
if ( !defined('CLIENT_NAME') ) {
    
define('CLIENT_NAME''No Client');
}
?>


Domain files

In app/config/ I create a folder called domains. In it, I keep each domain-specific configuration file named after each domain.

client1.example.com.php
client2.example.com.php
client3.example.com.php
myapp.site.php  <-- my local dev setup also gets it's own file.
myapp.local.php  <-- someone elses local setup.

client1.example.com.php


<?php
// this file will mostly contain modifications of the defaults from bootstrap.php
Configure::write('Config.language''eng');
define('CLIENT_NAME''Client One');


// But this is important. I set the database setting here!
Configure::write('Database.config', array(
    
'default' => array(
        
'driver' => 'mysqli_ex',
        
'persistent' => false,
        
'host' => 'localhost',
        
'port' => '',
        
'login' => 'client1',
        
'password' => 'client1',
        
'database' => 'client1',
        
'schema' => '',
        
'prefix' => '',
        
'encoding' => 'utf8'
    
),
    
'test' => array(
        
'driver' => 'mysqli_ex',
        
'persistent' => false,
        
'host' => 'localhost',
        
'port' => '',
        
'login' => 'testing',
        
'password' => 'testing',
        
'database' => 'probably not',
        
'schema' => '',
        
'prefix' => '',
        
'encoding' => 'utf8'
    
)
));
?>


Database settings

As you can see I set the database configuration in the domain file. I prefer this to having a long list of database settings in database.php or a second file for each domain in the domains folder. The database settings are loaded like this:


<?php
class DATABASE_CONFIG {
    var 
$default = array(
        
'driver' => 'mysqli_ex',
        
'persistent' => false,
        
'host' => 'localhost',
        
'port' => '',
        
'login' => 'root',
        
'password' => '',
        
'database' => 'default',
        
'schema' => '',
        
'prefix' => '',
        
'encoding' => 'utf8'
    
);

    function 
__construct () {        
        
$config Configure::read('Database.config');
        if ( !
is_array($config) ) {
            
// screaming exit here?
            
return;
        }
        foreach ( 
$config as $name=>$data ) {
            
$this->$name $data;
        }
    }
    
}
?>

The database file dynamically creates the attributes (class-variables) from the keys in the domain-specific file. This is probably illegal php by some strict setting but I still sleep well at night. The reason I do this and not, like some other people, simply set the "default" to whatever I have in the domain-file (see links at the bottom) is that I sometimes need several databases accessible from the application. This way I can dynamically create as many as I like.


And there shall be a shell.

The shell access is always a problem. You have no server-environment available and no Cake-magic to fake it, as far as I know. The fix is simple but also the least robust part of this setup. Bootstrap is set to accept the domain as a shell argument.

part of bootstrap.php again


<?php

if ( isset($_SERVER['SERVER_NAME']) ) { // normal web access
    
$bootstrap CONFIGS .'domains'.DS.$_SERVER['SERVER_NAME'].'.php';
} elseif ( 
count($_SERVER['argv']) ) { // we need a cli agrument (argv will always exist so this is a bit pointless)
    
$_SERVER['SERVER_NAME'] = $_SERVER['argv'][count($_SERVER['argv'])-1];
    
$bootstrap CONFIGS .'domains'.DS.$_SERVER['argv'][count($_SERVER['argv'])-1].'.php';
}

?>
These lines set the server name and the include file from the last shell argument. That is the less robust part and something you may wish to modify if you find it breaks your shells.


My hourly script is run like this:

/path/to/cake/cake/console/cake hourly client1.example.com
And from cron that would be:

/path/to/cake/cake/console/cake -app /path/to/cake/app/ hourly client1.example.com
I have even verified that the SERVER_NAME survives a requestAction(), good old requesrtAction ;)

Anything to look out for?

This technique works. I have used a variation of this on a live application for almost 3 years. Before devising this tweaked and updated version I looked at the suggestions from blogs and posts around the web. For my purposes this is the best I have seen. But it is not without it's potential problems.

Caching is not exhaustively tested. I can say that Cake's default caching of models and "persistent" things are not adversely affected. Other caching, I don't know. You can specify "domains" for cache files which would be a way to get around problems.

Logs will be jumbled together. This is a problem I am looking into but have no good fix for yet.

Uploaded files should be pointed to domain-specific folders. You don't want a file called trade-secrets.doc to be accessed by the wrong domain!

That's it

Thanks for reading. If you are not bored yet below are a few blog posts that I used as inspiration and reference in varying amounts.

http://rafaelbandeira3.wordpress.com/2008/12/05/handling-multiple-enviroments-on-cakephp/ http://www.littlehart.net/atthekeyboard/2008/11/28/handling-multiple-environments-in-your-php-application/ http://edwardawebb.com/programming/php-programming/cakephp/automatically-choose-database-connections-cakephp

Comments

  • Posted 07/03/09 03:31:55 AM
    I just noticed yesterday that SERVER_NAME is not the ideal value to use when determining the requested hostname. It appears that using HTTP_HOST is preferable.

    Apache does always (afaik) show php the requested hostname in SERVER_NAME. Nginx, however does not. I can't say anything about Lighttpd since I have never checked.

    This is the root of the "problem". In Nginx each server-block can contain:

    server_name client1.example.com client2.example.com client3.example.com;
    This is the equivalent of ServerName and a few ServerAlias in Apache.
    The default configs let php see SERVER_NAME as the first name defined in server_name. So no matter which domain you go to you will always load the configuration for the "first" domain. Not what we wanted.

    The alternatives to switching to HTTP_HOST are:
    -Use one "server block" (= virtual host) for each cake subdomain. This can be done well by putting all common directives into an include file.
    -Change the fcgi params to read: fastcgi_param SERVER_NAME $host;
  • Posted 04/20/09 10:15:30 AM
    I did a minimal patch to the CakePHP (via git updates are very painless with simple patches).

    diff --git a/cake/config/paths.php b/cake/config/paths.php 
    index 6a6449f..075313a 100644 
    --- a/cake/config/paths.php 
    +++ b/cake/config/paths.php 
    @@ -46,6 +46,9 @@ 
     if (!defined('APP')) { 
            define('APP', ROOT.DS.APP_DIR.DS); 
     } 
    +if (file_exists(APP . 'config' . DS . 'paths.php')) { 
    +       include_once APP . 'config' . DS . 'paths.php'; 
    +} 
     /** 
      * Path to the application's models directory. 
      */ 

    I then add the necessary file app/config/paths.php and put this into it:

    if ( !isset($_SERVER['SERVER_NAME']) ) {
        if ( count($_SERVER['argv']) ) { // cli
            $_SERVER['SERVER_NAME'] = $_SERVER['argv'][count($_SERVER['argv'])-1];
        } else {
            echo 'No server name is available. Halting...';
            exit;
        }
    }

    $tmp = APP.'tmp'.DS .'domains'.DS.$_SERVER['SERVER_NAME'].DS;
        //print_r($tmp);
    if ( !is_dir($tmp) ) {
        $old = umask(0);    
        @mkdir($tmp,0777,true);
        @mkdir($tmp.'logs'.DS,0777,true);
        @mkdir($tmp.'cache'.DS.'models'.DS,0777,true);
        @mkdir($tmp.'cache'.DS.'persistent'.DS,0777,true);
        @mkdir($tmp.'cache'.DS.'views'.DS,0777,true);
        @mkdir($tmp.'sessions'.DS,0777,true);
        umask($old);
        if ( is_dir($tmp) ) {
            if (!defined('TMP')) {
                define('TMP', $tmp);
            }
        } else {
            echo 'Specific temporary folder does not exist: '.$tmp.' - Halting...'; // ROOT.DS.APP_DIR.DS
            exit;
        }
    } else {
        if (!defined('TMP')) {
            define('TMP', $tmp);
        }
    }

    This code will check that a domain-tmp exists or try to create it if it does not. Finally it defines TMP. I have kept the permissions very loose in this example. I suggest tightening it up before going public.

    I had to hack the core, but that puts me into deployment nirvana. One source-tree on the server while keeping separate logs, cache, uploads and anything else kept in tmp.


  • Posted 02/26/09 11:51:33 PM
    I answered my own previous question. This turned out to be the exact thing I needed. Thanks!
  • Posted 02/25/09 06:04:02 PM
    Martin, thanks for such an elegant solution to this common problem.

    If I may, here is a similar but slightly different approach to the same issue.

    Instead of automatically determining which bootstrap configuration file to load based on the $_SERVER request, you could also throw the following code into your bootstrap.php file.


    /**
     * Bootstrap file for a particular environment/server
     * 
     */
    if (file_exists(CONFIGS . DS . 'bootstrap-override.php')) {
        require_once(CONFIGS . DS . 'bootstrap-override.php');
    }
    else {
        echo 'No configuration could be loaded.. Exiting...';
        exit; 
    }

    Notice how this snippet expects there to be a bootstrap-override.php file in the config directory. This bootstrap-override.php file is what loads the custom settings based on the environment you want to work in.

    The trick to making this work is that the bootstrap-override.php must by ignored by git (or svn) and must be added manually when a new clone (or checkout) is made. An easy way to implement this is to have a prepared bootstrap-override.default file in the repository that you can just copy/rename to bootstrap-override.php.

    Using your example, you could do the following in your bootstrap-override.php

    app/config/bootstrap-override.php

    require_once(CONFIGS . DS . 'domains' . DS . 'client3.example.com.php');

    The main benefit of this is that you can force a particular bootstrap configuration w/o having to rely on the $_SERVER variable (or some other environment setting) being set in any particular way. Additionally, you can reuse the same bootstrap configuration (e.g. a development configuration) among many builds and still allow room for customization in the override file itself.

    For example:
    app/config/bootstrap-override.php

    // default development settings
    require_once(CONFIGS . DS . 'bootstrap' . DS . 'dev.php');

    // override Database config settings
    Configure::write('Database.config', array(
      ...
    ));

    // override debug setting
    Configure::write('debug',1);

    // etc
  • Posted 02/23/09 02:36:04 AM
    Thanks for your kind comments guys.

    @Eddie
    I'd be very interested in that. I have not found any way to redirect the logging short of modifying the core Object class... which in my book is less desirable than modifying paths.php to redirect the whole tmp directory. (but if no shell is used setting TMP in app/webroot/index.php is enough)
  • Posted 02/20/09 04:07:49 AM
    Its not only useful in a development environment, it has also given hint on how SaaS applications can be made out of CakePHP. Good work!!
  • Posted 02/19/09 04:20:19 PM
    Very nice article Martin.

    I think your approach to the multiple domains and DB info consolidated in separate files is very cool.

    It seems that the log issue could be handled by declaring a path in those files and overriding the core logging methods to use that path in place of the default. Something I might play around with.

Comments are closed for articles over a year old