Hi everybody, today i'm very happy because after reading several information about multi tenant apps, finally i think i'm arriving to a better approach.
Following the approach of Phillip Brown (http://culttt.com/2014/03/31/multi-tenancy-laravel-4/) could make my mind to change the code i've posted before.
And of course, i want to share it with you, but as i said, i'm not an expert in architecture and still don't know how to package this to be shareable and testable.
All right, let's go!:
Requirements:
- Need for just one instance of an app that can work with many databases
- The user has access to some information in these databases (maybe all, maybe some of them)
- The user must be able to change the context (database and/or tenant) while he is in session, no need to logout.
- While the app is in context of some db and tenant, all information must be filtered to meet with the context.
Well, i think this is the very basic requirements.
Solution:
How to solve this?, the first step to get to a solution is that some way or another we need some place to store the current context the app is in. We can use database, the filesystem, i'm not sure if cache and session.
Anyone having experience with sessions in PHP will say "aha", sessions looks the more easy and maybe reliable way of storing the context, why?, because the session is "bound" to our database/app user.
Laravel has already a service to access the current session, also has a Facade called Session, which we can use like this:
// To store a value in session
Session::put('key_name', value);
// To get a value from session
Session::get('key_name', opt_def_value_if_not);
All right, we can go ahead an use the Session facade everywhere we need it, maybe in repositories, maybe in all the places where we need to filter data for the current context, but, i did it before and all my code got coupled with the framework, not a good thing if i'm planning to package this solution and share it through composer!.
Well, so, how to solve it?.
The post from culttt.com helped me to think better and be able to separate this.
First we are going to create an interface, i called it TenantContextInterface. This interface will let us get and set the data related to the current context, no matter how it is implemented (database, files, cache, session, ...):
<?php namespace MultiTenant\Contexts;
interface TenantContextInterface {
/**
* Set the connection name for the actual context
* this tenant
* @param $name
* @return mixed
*/
public function setConnectionName($name);
/**
* Get the name of the current connection in context
* @return mixed
*/
public function getConnectionName();
/**
* Set the id value filter data in the current context
* @param $id
* @return mixed
*/
public function setTenantId($id);
/**
* Get the tenant id value for the current context
* @return mixed
*/
public function getTenantId();
}
As you can see, the interface has a getConnectionName method, this method will return the name of the database connection in context if any, the same for getTenantId method, it will let you get the value to help you filter the data in the current context, later will get to the point to set this values!.
A note on this: if there is need to store different values for the context we can declare more methods!, this is flexibility!.
Now, we have an interface, we know we can't instantiate it, so, let code to an interface by implementing it. Here i have created a TenantContextSession class that implements the interface using Laravel Session:
<?php namespace MultiTenant\Contexts;
class TenantContextSession implements TenantContextInterface {
/**
* Sets the connection name for the actual context
* this tenant
* @param $name
* @return mixed
*/
public function setConnectionName($name)
{
\Session::put('tenant_connection', $name);
}
/**
* Get the name of the current connection in context
* @return mixed
*/
public function getConnectionName()
{
return \Session::get('tenant_connection');
}
/**
* Sets the id value filter data in the current context
* @param $id
* @return mixed
*/
public function setTenantId($id)
{
\Session::put('tenant_id', $id);
}
/**
*
* @return mixed
*/
public function getTenantId()
{
return \Session::get('tenant_id');
}
}
The purpose of this class is to get the context through the session. As you can see in the getConnectionName method is implemented with this:
return \Session::get('tenant_connection');
Here we are using the Laravel Session facade and this is the only place we do this. Now we have decoupled a little bit more from the framework!.
Now we are able to set and get our context data, yay!.
At this point i think you must have authentication ready to let our users to log in.
But wait, if i have many databases, which one i am going to use to log in the user??.
Well this question is totally relevant and we can defer on how to implement this:
the best schema i think we can get without mess is:
have many databases with the same schema to store the user/tenant data. These databases must have a schema to map a user with the data, i think of a projects_users table on each of these databases, this will let us know if a user has access to data on the database, and of course, show the project to let the user be in the context of it.
have one separate database where we just store data about our users, like the username, remember_token, email, password or any data is needed.
If this make sense to you, the database where our users reside will be de default configured on the database connections configuration file, like this:
'default' => 'users',
'connections' => array(
'users' => array(
'driver' => 'mysql',
'host' => getenv('DB_HOST'),
'database' => 'users',
'username' => getenv('DB_USERNAME'),
'password' => getenv('DB_PASSWORD'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
),
This way, no matter what, our code will fetch data from this connection, like when we are authenticating the user, in that time will not be a context already.
All right, let's get going.
After a user logs in, we can or not show the data related to set the context, maybe you can redirect him to the homepage or to other place of the app where a context is not needed. I prefer to redirect him to the page where he can set his context. To do this, i have created the next route:
Route::post('tenant', [
'as' => 'tenants_path',
'uses' => 'TenantsController@store'
]);
If the name makes no sense to you and want something more meaningful, we can use "/context/set" or another instead, that's easy. Actually, the user is never going need to see this url because after the method executes it will be redirected to any other route we need or the intended.
class TenantsController extends \BaseController {
/**
* Show the form for creating a new resource.
*
* @return Response
*/
public function store()
{
TenantContext::setConnectionName(Input::get('tenant_connection'));
TenantContext::setTenantId(Input::get('tenant_id'));
return Redirect::intended('/');
}
}
As you can see the only method used here is the store() one, here is where we are going to "create" the current context for our app. But wait!, what is that facade like TenantContext there?!, ahh, well, we will review that part later but, yes!, we made a facade for the context!. The purpose of this controller's method is to set the data we need for the current context, note we use the setConnectionName and setTenantId methods to store values coming from the page where we show the user his options to set his context.
Finally after setting the context, we redirect to the intended route or to the home route, easy!.
Now we have set our application in context, but wait, how are we going to tell our Eloquent models to be in context?, i don't want to mess with all the code of the Illuminate/Database and reinvent the wheel. Eloquent in this right moment makes its work great on a database connection, we only need to tell it to change the model's connection to the one in our context.
How we achieve this?, certainly i'm going to tell you that i have to review the code of Eloquent to find that we can change the model's connection in the constructor.
Knowing this, we can create a TenantEloquent class:
<?php namespace MultiTenant\Core;
class TenantEloquent extends \Eloquent {
/**
* @param array $attributes
*/
function __construct(array $attributes = array())
{
parent::__construct($attributes);
$context = \App::make('MultiTenant\Contexts\TenantContextInterface');
$this->setConnection($context->getConnectionName());
}
}
Here we just call the parent's constructor and then using the IoC we get an instance of our context implementation. I tried the IoC inject the context as a dependency, but for some reason it couldn't do it, it never resolver it to a TenantContextSession instance, even when it was binded, that's why i get the instance like it is. Finally we set the model's connection to the one in the context and when the IoC creates an instance of any model that extends this class, it will be in te context connection scope, so our queries will hit the right database.
Now, how do we set the tenant_id to filter data from the model?, we can define a global scope (http://laravel.com/docs/eloquent#global-scopes) in this base class, i'm not going to use it here because in my database schema there is not a tenant_id on every table so i will have to filter my data in the repository. I just wanted to show that you can do it if you have a schema like that.
Now we can use our Eloquent models in the current context, here is a demo of my Project and Product models:
<?php namespace MultiTenant\Projects;
use MultiTenant\Core\TenantEloquent;
class Project extends TenantEloquent {
protected $fillable = [];
public function products()
{
return $this->hasMany('MultiTenant\Products\Product');
}
}
<?php namespace MultiTenant\Products;
use MultiTenant\Core\TenantEloquent;
class Product extends TenantEloquent {
protected $fillable = [];
/**
* Relationship with the project the product belongs to
* @return mixed
*/
public function project()
{
return $this->belongsTo('MultiTenant\Projects\Project');
}
}
For this schema i made a "products" table related to the "projects" table to be able to show you how easy and transparent is to work in the context with the models.
Wait, i forgot to show you the code that shows the user his list of projects and lets him choose one to set the context, but before we get there, we are going to cover the Service Provider and Facade for the context.
As you know, we can use dependency injection and resolution that the IoC gives us.
Knowing this we can create a ServiceProvider to let the container inject an instance of our context anywhere we need it, specially in our controllers.
<?php namespace MultiTenant\Providers;
use Illuminate\Support\ServiceProvider;
use MultiTenant\Contexts\TenantContextSession;
class TenantContextServiceProvider extends ServiceProvider {
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->app['tenant.context'] = $this->app->share(function($app)
{
return new TenantContextSession;
});
$this->app->bind('MultiTenant\Contexts\TenantContextInterface', function($app) {
return $app['tenant.context'];
});
}
}
Here is our TenantContextServiceProvider that will bind our TenantContextSession implementation of TenantContextInterface in the container, so we can access with these options:
App::make('MultiTenant\Contexts\TenantContextInterface');
$app['tenant.context']
or using it as a dependency on other class like this
public function __construct(TenantContextInteface $context)
of course, don't forget to add 'MultiTenant\Providers\TenantContextServiceProvider' to the list of providers in the app.php configuration file!.
Well it will be good to have a Facade of this so, let's do it:
If you know nothing about laravel facades, well, it's easy, i didn't know how to create them like 30 minutes ago, but the laravel documentation is amazing so i could learn it at the speed of light (http://laravel.com/docs/facades)
<?php namespace MultiTenant\Facades;
use Illuminate\Support\Facades\Facade;
class TenantContext extends Facade {
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor() { return 'tenant.context'; }
}
You just need to have 3 things:
- A binded implementation of the class you need to access with the facade
- A class that extends the Facade laravel class
- Add an alias for the facade in the app.php configuration file
Actually the binding is already done by our service provider, it put the context instance in the 'tenant.context' key of the container.
'aliases' => array(
'Validator' => 'Illuminate\Support\Facades\Validator',
'View' => 'Illuminate\Support\Facades\View',
'TenantContext' => 'MultiTenant\Facades\TenantContext',
),
Now we can access our context with:
$conn = TenantContext::getConnectionName();
// or any method our container has
TenantContext::setConnectionName($connection);
As you can see, i'm trying to avoid tight coupling within the code, but i'm not sure if this is true, so hope some more experienced developers have better ideas for this.
Now we can access our context anywhere, yeeha! (i'm trying not to be a cowboy @jeffreyway).
Well, now it is time to put the code to show a user a list of all the projects he has access trhough all the connections/databases:
Route::get('projects', [
'before' => 'auth',
'as' => 'projects_path',
'uses' => 'ProjectsController@index'
]);
use MultiTenant\Projects\ProjectRepository;
class ProjectsController extends \BaseController {
/**
* @var
*/
protected $projectsRepository;
/**
* @param ProjectRepository $projectsRepository
*/
function __construct(ProjectRepository $projectsRepository)
{
$this->projectsRepository = $projectsRepository;
}
/**
* Display a listing of projects the current user has access
* through all the contexts (connections/databases).
*
* @return Response
*/
public function index()
{
$projects = $this->projectsRepository->getAllProjectsByUser(Auth::user());
return View::make('projects.index')->withProjects($projects);
}
}
Here i have injected the ProjectRepository which we'll review now!:
<?php namespace MultiTenant\Projects;
use Illuminate\Support\Collection;
use MultiTenant\Core\BaseRepository;
use MultiTenant\Users\User;
class ProjectRepository extends BaseRepository{
/**
* Get all the projects a user has access through all the connections/databases
*
* @param User $user
* @return Collection
*/
public function getAllForUser(User $user)
{
// Get all the database connections
$connections = \Config::get('database.connections');
// Create a collection to store the projects a user has access
// to put in context
$projects = new Collection;
// If the user is now in context of a project, we store it
// to restore his context at the end
$currentTenantDb = $this->context->getConnectionName();
foreach ($connections as $connectionName => $connection)
{
// We iterate on each connection of type 'tenant'
if (isset($connection['type']) && $connection['type'] == 'tenant')
{
// Set the context connection
$this->context->setConnectionName($connectionName);
// Find the user in this context
$tenantContextUser = User::find($user->username);
// If we find the user, it means that in this context
// has access to at least one project or maybe none
if ( $tenantContextUser )
{
// add each of the projects to the collection
// that the user has access in this context
foreach ($tenantContextUser->projects as $project)
{
$projects->push( $project);
}
}
}
}
// restore the context the user was in
$this->context->setConnectionName($currentTenantDb);
// return the list of projects
return $projects;
}
}
The repository has a special method called getAllForUser(), this method is in charge of go for each connection in the database.php configuration file and set the context to fetch the projects a user has access, every time it finds projects on a context, push the projects to a collection that finally is returned to be consumed by a view.
Here i'm going to try to be more clear, because some questions like this can raise:
will it set the context for every connection stored in the database.php configuration file?, and the answer is: if we have not a way to choose only some of the connections, yes. And how we can choose only the ones that represent these "tenant" databases?, i don't want any other database be put in context because maybe we have a different schema and the queries will die!, yes they will die dramatically.
A solution for this is to "tag" the connections we are interested to be set in context, how?:
'connections' => array(
'tenant1' => array(
'driver' => 'mysql',
'host' => getenv('DB_HOST'),
'database' => 'tenant1',
'username' => getenv('DB_USERNAME'),
'password' => getenv('DB_PASSWORD'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'type' => 'tenant',
),
'tenant2' => array(
'driver' => 'mysql',
'host' => getenv('DB_HOST'),
'database' => 'tenant2',
'username' => getenv('DB_USERNAME'),
'password' => getenv('DB_PASSWORD'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'type' => 'tenant',
),
'tenant3' => array(
'driver' => 'mysql',
'host' => getenv('DB_HOST'),
'database' => 'tenant3',
'username' => getenv('DB_USERNAME'),
'password' => getenv('DB_PASSWORD'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'type' => 'tenant',
),
),
As you can see, we have added a 'type' => 'tenant' element on each connection that represents a tenant, this way we are able to fetch data only from those. We are doing it in the repository already!, and if you remember, we have not this element in our default connection, our "users" connection.
Now here is a simple view that shows the projects:
@extends ('layouts.default')
@section ('content')
<h1>Project Selection</h1>
@foreach ($projects as $project)
<li>
{{ Form::open(['route' => 'tenants_path']) }}
{{ Form::hidden('tenant_connection', $project->getConnectionName()) }}
{{ Form::hidden('tenant_id', $project->id) }}
{{ Form::submit('Select ' . $project->name, ['class' => 'btn btn-primary']) }}
{{ Form::close() }}
</li>
@endforeach
@stop
As you can see is a simple form for each project a user has access with a button showing the name for the project, and 2 hidden fields that will be used to set the context data:
- tenant_connection
- tenant_id
tenant_connection gets the value from the project's getConnectionName() method (fortunately this is part of Eloquent), and tenant_id gets its value from the project id field.
When the button is clicked it will post the data for the project to out tenants route, this will set the context and then will redirect to any intended route or home.
We are getting to the final point of this implementation, now we are going to write a filter to avoid a user gets access to a route that needs the context to be selected, otherwise it will fail dramatically and we don't want that.
/**
* Checks that a connection is in context
* to fetch data from the database using Eloquent.
* A redirect response will be issued to /projects
* if no connection exists in context to let the user choose a context
*/
Route::filter('tenant_context', function()
{
if ( ! TenantContext::getConnectionName() )
{
return Redirect::guest('projects/');
}
});
Here we leaverage our facade to get the connection name of the current context, if no connection name is set, then we redirect the user to the page where he must set a context.
Finally i want to create that part of the app that needs a context before it is accessed:
Route::get('products', [
'before' => 'tenant_context',
'as' => 'products_path',
'uses' => 'ProductsController@index'
]);
use MultiTenant\Products\ProductRepository;
class ProductsController extends \BaseController {
protected $productRepository;
function __construct(ProductRepository $productRepository)
{
$this->productRepository = $productRepository;
}
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index()
{
$products = $this->productRepository->all();
return View::make('products.index')->withProducts($products);
}
}
namespace MultiTenant\Products;
use MultiTenant\Core\BaseRepository;
use MultiTenant\Projects\Project;
class ProductRepository extends BaseRepository {
/**
* @param Project $project
* @return mixed
*/
public function findForProject(Project $project)
{
return $project->products();
}
/**
* Find the products of the current tenant in context
* @return mixed
*/
public function all()
{
return Project::findOrFail($this->context->getTenantId())->products;
}
}
namespace MultiTenant\Core;
use MultiTenant\Contexts\TenantContextInterface;
abstract class BaseRepository {
/**
* @var
*/
protected $context;
/**
* @param $context
*/
function __construct(TenantContextInterface $context)
{
$this->context = $context;
}
}
@extends ('layouts.default')
@section ('content')
<h1>Products List</h1>
<ul>
@foreach ($products as $product)
<li>{{ $product->name }}</li>
@endforeach
</ul>
@stop
Phew!!, i'm very tired right now, i will finish this later, but for now is almost everything we need.
If someone have ideas about this or want to review all the code, please reply!.
Thanks.