Use multiple databases in one app based on requested url
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 theController 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
Question
1 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
Comment
2 DarkAuth etc..
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.
Comment
3 Sometimes you just cannot do simple, even if you want to..
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.
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 :)
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.
Comment
4 Don't know
Comment
5 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)?
Comment
6 So far
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
}
}
}
?>
Comment
7 Just an example
Question
8 Link management
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 ?
Comment
9 Yep, something like that
Comment
10 Management of all url / redirection / links
echo $form->create('Sample', array('action' => 'user_edit'));
How do you tell him to add the compagny param ?
Comment
11 by defining the controller
I do it like this:
echo $form->create('Sample', array('url' => array('controller' => '/'.$company_url.'/controllername', 'action' => 'user_edit')));
Comment
12 Redirect the URL function for painless links
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_url, Dispatcher::baseUrl()) + strlen(Dispatcher::baseUrl());
$first_segment = substr($new_url, 0, $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.
Comment
13 Redirection code
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($url, false));
$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.
Comment
14 Thanks to you.