Use multiple databases in one app based on requested url

By Anssi Rajakallio (doze)
I needed to build an application where there are multiple customers (companies) using the application and each have their own database, but also some common tables in another database. I selected CakePHP for the framework and started to work with it. This is what I came up with.

Requirements

So let me first explain the database structure.

Tables in common database:

- Users
- Companies
- Common settings
- Etc. common tables to be used across the application...

Tables in company specific database:

- Users extra profile data
- Company employees
- Company services
- Etc. company specific data for the application...

So all users register to the application and the basic information is stored in a table in the common database. One user login to the application should work across the application for all companies. When site visitor goes to some company's page, the data needs to be retrieved from a database that is configured to that specific company. All the application views are the same, but the displayed data changes depending on which company's page is viewed. For security reasons, the company specific data needs to be in their own database and I didn't want to setup the database connections manually each time new company would be added to the application.

I went to the CakePHP google groups with these requirements in my mind and asked for an advice on what should I be looking at from the documentation. The answer was: Routing in CakePHP and setting database name for a datasource.

Routing setup

I decided to have these kind of urls in the application:

Download code http://www.domain.com/<company>/<controller>/<action>/<parameters>
That way the correct company specific database would be selected by the value of that first part of the url. To make that happen I needed to configure the following routes in /app/config/routes.php:

Download code <?php
/**
 * Catch urls like http://www.domain.com/<company>/<controller>/<action>
 */
    
Router::connect(
        
'/:company/:controller/:action',
        array (
            
'controller' => 'home'// default controller
            
'action' => 'index' // default action
        
),
        array (
            
'company' => '[a-zA-Z0-9]+' // regular expression match for the company parameter
        
)
    );
/**
 * Catch urls like http://www.domain.com/<company>/<controller>/<action>/<parameters>
 */
    
Router::connect(
        
'/:company/:controller/:action/*',
        array (
            
'controller' => 'home'// default controller
            
'action' => 'index' // default action
        
),
        array (
            
'company' => '[a-zA-Z0-9]+' // regular expression match for the company parameter
        
)
    );
/**
 * Catch urls like http://www.domain.com/<company>/<controller>/
 */
    
Router::connect(
        
'/:company/:controller',
        array (
            
'controller' => 'home'// default controller
            
'action' => 'index' // default action
        
),
        array (
            
'company' => '[a-zA-Z0-9]+' // regular expression match for the company parameter
        
)
    );
/**
 * Catch urls like http://www.domain.com/<company>/
 */
    
Router::connect(
        
'/:company',
        array(
            
'controller' => 'home'// default controller
            
'action' => 'index' // default action
        
),
        array (
            
'company' => '[a-zA-Z0-9]+' // regular expression match for the company parameter
        
)
    );
?>
Note: The routes need to be exactly in this order for it to work. If you would change the two top ones other way around for example, accessing to http://www.domain.com/company/ would redirect to a null controller. I never did quite understand why. Another thing I didn't understand is that why I needed to define default controller and action in the routes even thought they are setup in the route itself, but trust me, it won't work otherwise.

Now with this setup, I could access the following url:

http://www.domain.com/company/employees/list

and it would be routed to the list() method of EmployeesController. Sweet!

Setting up some static controllers

I also needed some "static" urls for example to use with user registration and login. So to achieve this I added following route above the existing routes:

Download code <?php
/**
 * Static url to users controller
 */
    
Router::connect(
        
'/users/:action/*',
        array (
            
'controller' => 'users',
            
'action' => 'index'
        
)
    );
?>

Now I could access to the following url:

http://www.domain.com/users/login

and that would be routed to the login() method of UsersController. Ok, that's basically it for the routes, on to the next step!

Selecting correct database dynamically

First I needed to create a base class for all my controllers that would do the saving of the parameter from requested url. So I created the following AppController for my application to /app/app_controller.php:

Controller Class:

Download code <?php class AppController extends Controller {

    function 
constructClasses() {
        
// Save company name when we already know it but before models are created
        
Configure::write(
            
'companyDB',
            !empty(
$this->params['company']) ? $this->params['company'] : false
        
);
        
parent::constructClasses();
    }

}
?>

Then I needed a base class for all of my models that would do the database selection. So I created the following AppModel to /app/app_model.php:

Model Class:

Download code <?php class AppModel extends Model {
    var 
$specific false;

    function 
__construct($id false$table null$ds null) {
        if (
$this->specific) {
            
// Get saved company/database name
            
$dbName Configure::read('companyDB');
            
// Get common company-specific config (default settings in database.php)
            
$config ConnectionManager::getDataSource('defaultCompany')->config;

            
// Set correct database name
            
$config['database'] = $dbName;
            
// Add new config to registry
            
ConnectionManager::create($dbName$config);
            
// Point model to new config
            
$this->useDbConfig $dbName;
        }
        
parent::__construct($id$table$ds);
    }

}
?>

Then when creating models for tables that are in the company specific database, I can use the $specific variable to define that the model is in the company specific db, like so:

Model Class:

Download code <?php class Employee extends AppModel {
        var 
$specific true;

}
?>

Then all I needed is to have the two different datasource configurations in /app/config/database.php:

Download code <?php
class DATABASE_CONFIG {

    var 
$default = array(
        
'driver' => 'mysql',
        
'persistent' => false,
        
'host' => 'localhost',
        
'login' => 'user',
        
'password' => 'pass',
        
'database' => 'common_database',
        
'encoding' => 'utf8'
    
);

    var 
$defaultCompany = array(
        
'driver' => 'mysql',
        
'persistent' => false,
        
'host' => 'localhost',
        
'login' => 'user',
        
'password' => 'pass',
        
'encoding' => 'utf8'
    
);
}
?>

That's that. Now when this url is accessed:

http://www.domain.com/company/employees/list

it goes to the list() method of the EmployeeController and gets the data for the Employee model from a database named "company". Yay!

Now if you would like to have own database user/pass for example for each company specific database, you could take this further by saving the company database info in the common database and use that :company parameter to just retrieve the correct database access info from the common database.

Thanks a lot to everyone who helped me at CakePHP google groups and in #cakephp! You guys rock!

 

Comments 1041

CakePHP Team Comments Author Comments
 

Question

1 How do you manage permissions on databases?

How do you manage permissions on databases?

I mean, if the users login in a common database, how do you know if that particulary user have permissions to modify certain database?

How do you prevent companyA's users edit companyB's records?

Cheers
Posted Jun 5, 2009 by Paolo Stancato
 

Comment

2 DarkAuth etc..

I mean, if the users login in a common database, how do you know if that particulary user have permissions to modify certain database?

How do you prevent companyA's users edit companyB's records?
I use DarkAuth in my application and each user belongs to certain usergroup(s) and they have certain rights. I check the rights from the logged in user on executing actions using DarkAuth.

But I have also made the database selection in my app so that the database login information is queried from the common database when a company url is accessed. I just didn't put all that to the example as it would become unnecessary complicated and it also gives one extra query per request, but that can be set to be persisted of course.
Posted Jun 6, 2009 by Anssi Rajakallio
 

Comment

3 Sometimes you just cannot do simple, even if you want to..

I doubt you'll run into performance reasons This is an online time booking application and there will be alot of bookings done each day. There also needed to be a timeslot table to store free timeslots and query them. Timeslots are separate for each employee and one timeslot spans 5 minutes. Business hours can be set by each company and the timeslot table alone can grow very large for one company (if there's multiple offices with multiple employees). Also there are much of other data that relates to the reservations and the tables can grow quite large. This was one of the performance related reasons that I chose to implement own DB for each company.

But it is certainly easier to manage an application that uses just one database when you don't have to jump between databases to do complex queries. And also considering future updates, it's easier to do for single db than 1000 separate.

and even if one practice insisted they wanted their database seperated, it would be trivial to depoloy the webapp seperatly for each company, maintain 2 databases and the code doesn't have to change. The simplest solution is usually the best. True.. but all depends on the case at hand. Sometimes you have to do things differently even if you don't like (or necessary agree with) it :)

Can I ask how you did manage to keep the separation intact, technically speaking. Did you provide each row with a unique companyId/userId? And how did you manage this in your application (transparantly)? Yes, in company specific tables for example in Offices table, there is a company_id field for the rows. That relates to the id field in Companies table in the common database. And in Customer table in company specific database, there is a user_id field for the rows that relate to the id field of the Users table row in the common database. E.g. just normal FK relations. In my application I don't have to do anything specific other than what the tutorial above describes. The relations work between databases ok.
Posted Jul 7, 2009 by Anssi Rajakallio
 

Comment

4 Don't know

I dont get it why do you get a null controller when you put it in a different order? Its nothing major but something that is bugging me Yeah.. I don't know why, but that's the way it goes. The routes have to be exactly like that to work. I have tested it few times after I wrote this article. Could be some bugs in the routing I guess..
Posted Jul 27, 2009 by Anssi Rajakallio
 

Comment

5 Thanks!

This is pretty much verbatim how I had planned to setup an app for multiple clients who both share and have their own data...only I couldn't figure out the null controller issue...weird that it works this way. Thanks!

Also, what happens if a database doesn't exist for the company entered in the URL? Any suggestion on where this form of validation would go (in a single DB setup)?
Posted Jul 29, 2009 by Ryan Horn
 

Comment

6 So far

Made a little progress on this...Allows you to specify which model table to lookup when validating and the table column to check. It assumes the model is directly associated with or is itself the model which is being used to validate.

So far what I have in AppController:

Controller Class:

<?php 

class AppController extends Controller {
    function 
constructClasses() {
        if (!empty(
$this->params['routeKey'])) {
            
Configure::write('routeKey'$this->params['routeKey']);
        }
        
parent::constructClasses();
    }
}
?>

And in a model loading data for the route key I have

Model Class:

<?php 

class [MODEL_NAME] extends AppModel {
    public 
specific = array(
        
'model' => '[MODEL_TO_CHECK]'
        'key' 
=> '[KEY_TO_CHECK]'
    
);
?>

And in AppModel I have

Model Class:

<?php 

class AppModel extends Model {
    public function 
__construct() {
        
$vars func_get_args();
        
call_user_func_array(array($this'parent::__construct'), $vars);
        if (isset(
$this->specific) && is_array($this->specific) && isset($this->specific['model']) && isset($this->specific['key']) {
            
$model = ($this->specific['model'] == get_class($this)) ? $this $this->{$this->specific['model']};
            
$result $model->find('count', array('conditions' => array(get_class($model) . '.' $this->specific['key'] => Configure::read('routeKey'))));
            
// Handle result here
        
}
    }
}
?>
Posted Jul 29, 2009 by Ryan Horn
 

Comment

7 Just an example

Surely the regular expression your using for the company is not the final regular expression used in the application? Good point. For me, the url what company uses to access it's data is always alphanumeric, so I don't need dashes. But others might want to change the routing regex according to their needs.
Posted Sep 3, 2009 by Anssi Rajakallio
 

Question

8 Link management

Hi,

Your article made my life more simple ! It was veru usefull for my works. I'm still wondering something : How do you manage links ? Each time do you make an "$html->link ('Link', $this->params['compagny']...." or do you have configure something in the Routes ? Like routing.admin, but without the prefix ?
Posted Sep 10, 2009 by Gael D
 

Comment

9 Yep, something like that

How do you manage links ? Each time do you make an "$html->link ('Link', $this->params['compagny']...." Yes, I do it somewhat like that. I verify that the company param points to existing company, store it to session and use that. When the param changes to the one stored in session, I verify it again.
Posted Sep 14, 2009 by Anssi Rajakallio
 

Comment

10 Management of all url / redirection / links

I'm still wondering how do you manage, i don't know, the redirect forms by exemple. When you do :
echo $form->create('Sample', array('action' => 'user_edit'));

How do you tell him to add the compagny param ?
Posted Sep 15, 2009 by Gael D
 

Comment

11 by defining the controller

I'm still wondering how do you manage, i don't know, the redirect forms by exemple. When you do :
echo $form->create('Sample', array('action' => 'user_edit'));

How do you tell him to add the compagny param ?

I do it like this:

echo $form->create('Sample', array('url' => array('controller' => '/'.$company_url.'/controllername', 'action' => 'user_edit')));
Posted Oct 8, 2009 by Anssi Rajakallio
 

Comment

12 Redirect the URL function for painless links

I was able to get painless links with some help from the IRC channel yesterday, so I thought I'd share. The trick is to override the url function in Helper using a custom /app/app_helper.php (create if necessary):

Controller Class:

<?php 
class AppHelper extends Helper
{
    function 
url($url null$full false$true_base false)
    {
        
$processed false;
        
$new_url $url;
        
        
// append the company name if necessary
        
if(isset($this->params['company']) && !$true_base && (is_array($new_url) || strpos($new_url'!!NODB!!') === false))
        {
            if(
is_array($url))
            {
                
$new_url parent::url($url$full);
                
$location_to_insert stristr($new_urlDispatcher::baseUrl()) + strlen(Dispatcher::baseUrl());
                
$first_segment substr($new_url0$location_to_insert);
                
$last_segment substr($new_url$location_to_insert);
                
                
$new_url $first_segment.'/'.$this->params['company'].$last_segment;
                
                
$processed true;
            }
            else
            {
                if(
stripos($url'http') !== 0)
                {
                    
$url '/'.$this->params['company'].$url;
                    
$new_url parent::url($url$full);
                    
                    
$processed true;
                }
            }
        }
        
        if(!
is_array($new_url) && strpos($new_url'!!NODB!!') !== false)
        {
            
$new_url str_replace('!!NODB!!'''$new_url);
        }
        
        if(!
$processed)
        {
            
$new_url parent::url($new_url$full);
        }
        
        return 
$new_url;
    }
}
?>

Using this, you can pass the extra true_base parameter as true if you're using the url function directly, or put !!NODB!! somewhere in the url if you're using it indirectly (for example, through the HTML helper's link function), to get a link without the database name. Otherwise, it will transparently add the database name currently being used to all URLs generated in all helpers (assuming they use the standard way of doing so).

Some redirection (i.e. from form_helper forms, though the form targets are correct) doesn't work quite right yet, I'll post that if I can get it working.
Posted Oct 28, 2009 by Jamie Arseneault
 

Comment

13 Redirection code

As promised, here's the code that transparently redirects correctly (in app/app_controller.php):

Controller Class:

<?php 
class AppController extends Controller
{
    
// ...
    
    
function redirect($url$status null$exit true$true_base false)
    {
        
$new_url $url;
        
$skip_processing false;
        
        if(!
is_array($new_url) && strpos($new_url'!!NODB!!') !== false)
        {
            
$new_url str_replace('!!NODB!!'''$new_url);
            
$skip_processing true;
        }

        
// append the company name if necessary
        
if(isset($this->params['company']) && !$true_base && (is_array($new_url) || strpos($new_url'!!NODB!!') === false) && !$skip_processing)
        {
            if(
is_array($url))
            {
                
$new_url str_replace(Dispatcher::baseUrl(), ''Router::url($urlfalse));
                
$new_url '/'.$this->params['company'].$new_url;
            }
            else
            {
                if(
stripos($url'http') !== 0)
                {
                    
$new_url '/'.$this->params['company'].$url;
                }
            }
        }
        
        return 
parent::redirect($new_url$status$exit);
    }
    
    
// ...
}
?>

Post here if anyone finds bugs with these methods.
Posted Oct 29, 2009 by Jamie Arseneault
 

Comment

14 Thanks to you.

This is a great help for the app that i'm developing.
Posted Feb 4, 2010 by Bryan de Asis