Multi-tenancy in Laravel Spark
Leo Sjöberg • October 9, 2015
For those that aren't aware, Laravel Spark is a boilerplate for business-oriented SaaS applications, complete with two-factor authentication, subscription payments and teams. Something that is of course a very probable application for Spark is multi-tenant applications; an application where users have their own space in the app, separated from any other user's content. The way this is accessed can either be as a subdomain of your service e.g customer.service.tld
, or simply with the account id as service.tld/123
(this is in fact how we do it at work. This blog post will deal with handling both ways, but the differences are in this case really minor, it's merely how the retrieval of the account id is handled.
Organisations, Teams and Users
Depending on the size of your application, the first thing to decide is on what level you want your tenants. Quite likely, you will want to allow multiple people to access the same data. This is most easily done with Spark's built-in Teams feature. Simply enable Teams and that's that part done. However, if your SaaS is a bit more complex, you might want to have a structure similar to GitHub, where you have organisations, and each organisation can have multiple teams, with access to different data. In that case, as Spark is structured right now, you'll have to add that extra layer of complexity yourself. This post mainly deals with just one flat team without internal structure for sake of simplicity (however, if there's enough interest, I could definitely expand on more complex structures).
Limiting access
Note: this section will use the term
team
to refer to the default Spark Team
In order to ensure users can only access data belonging to their account, you need to attach a team id to the tables where you want data protected. Usually, it's good practice to attach this to every table, except for the one keeping track of users (because you probably want to allow one user to belong to multiple accounts) and your teams table. Luckily, Spark will automatically create migrations to support such a structure, with many-to-many team-user relations. However, you'll have to remember to add a team_id
on all your migrations for the rest to work.
In order to limit access, we will need to query on team id every time. However, since this is so essential, you'll never want to miss querying by account. Hence, this is a perfect use case for a global query scope. In order to ease the process of creating a global query scope, I'm using the optional Sofa/Laravel-Global-Scope
package. Using that, our scope simply becomes
1<?php 2 3namespace App\Domain; 4 5use Sofa\GlobalScope\GlobalScope; 6use Illuminate\Database\Eloquent\Model; 7use Illuminate\Database\Eloquent\Builder; 8 9class TenantAccessScope extends GlobalScope10{11 /**12 * Apply the global scope.13 *14 * @param \Illuminate\Database\Eloquent\Builder $query15 * @param \Illuminate\Database\Eloquent\Model $model16 * @return void17 */18 public function apply(Builder $query, Model $model)19 {20 $query->where('team_id', '=', session('team_id', 0));21 }22 23 /**24 * Determine whether where clause is the contraint applied by this scope.25 *26 * @param array $where Single element from the Query\Builder::$wheres array.27 * @param \Illuminate\Database\Eloquent\Model $model28 * @return boolean29 */30 public function isScopeConstraint(array $where, Model $model)31 {32 return $where['column'] == $model->getTenantColumn();33 }34 35 /**36 * Add the tenant id when creating a new entity.37 *38 * @param \Illuminate\Database\Eloquent\Model $model39 * @return void40 */41 public function creating(Model $model)42 {43 if (! $model->hasGlobalScope($this)) {44 return;45 }46 47 $column = $model->getTenantColumn();48 $model->$column = $model->getTenantId();49 }50}
You'll see that for this global scope, I use session('team_id', 0)
to find the team id to query on. I am setting the team id in a route middleware, and storing it in the session. Another option here is to define it as a global constant. However, it will be less flexible and more difficult to test since you cannot redefine the team ID. Just remember that there is a minor danger with allowing your team id to be redefinable if you at some point happen to define it elsewhere in your application.
To work together with this scope, you'll need a trait which actually uses the scope when querying:
1<?php 2 3namespace App\Domain; 4 5use App\Team; 6use Illuminate\Database\Eloquent\Model; 7 8trait TenantAccessTrait 9{10 protected $tenantColumn = 'team_id';11 12 public function team()13 {14 return $this->belongsTo(Team::class);15 }16 17 public static function bootTenantAccessTrait()18 {19 $tenantScope = new TenantAccessScope();20 static::addGlobalScope($tenantScope);21 22 static::creating(function (Model $model) use ($tenantScope) {23 $tenantScope->creating($model);24 });25 }26 27 public static function forTenant($id)28 {29 // We can actually pass in a domain here since every tenant has one.30 if (is_string($id)) {31 $id = Team::findByDomain($id);32 }33 34 return (new static())->newQueryWithoutScope(new TenantAccessScope())->where((new static)->getTenantColumn(), '=', $id);35 }36 37 public static function forAllTenants()38 {39 return (new static())->newQueryWithoutScope(new TenantAccessScope());40 }41 42 public function getTenantColumn()43 {44 return $this->tenantColumn;45 }46 47 public function getTenantId()48 {49 return \Auth::user()->currentTeam()->id;50 }51}
This trait does two things: firstly, it registers the global scope through the public static function bootTenantAccessTrait
, and then adds both the relation method team()
as well as some helpers for when you want to query for a specific account, or on all accounts (which can be useful for you as an administrator). Note that you could also make the forTenant
and forAllTenants
builder macros to be more flexible. However, to keep it simple, I've skipped doing that here.
Capturing the team id
As mentioned in the introduction, you'll often have the users access their tenant site from either customer.domain.tld
or domain.tld/123
(or domain.tld/customer
). I've decided to deal with capturing the team id in a route middleware, and below I'll provide two different versions; one for capturing a subdomain and one for an appended id.
Subdomain:
1<?php 2 3namespace App\Http\Middleware; 4 5use Closure; 6use App\Team; 7 8class LimitTenantAccess 9{10 /**11 * Handle an incoming request.12 *13 * @param \Illuminate\Http\Request $request14 * @param \Closure $next15 * @param string $domain16 * @return mixed17 * @throws \Exception18 */19 public function handle($request, Closure $next, $domain)20 {21 // Find the account ID based on the domain22 $team = Team::findByDomain(explode('.', Request::getHost())[0]);23 24 if (! \Auth::user()->teams->find($team)) {25 throw new \Exception('You do not have access to this team.');26 }27 28 define('TEAM_ID', $team->id);29 30 return $next($request);31 }32}
Note that I have, for my own application, abstracted out the explode('.', Request::getHost())[0]
into a subdomain()
helper function. findByDomain
is simply a method that will query the model where('subdomain', '=', $domain)
.
Appended id:
1<?php 2 3namespace App\Http\Middleware; 4 5use Request; 6use Closure; 7use App\Team; 8 9class LimitTenantAccess10{11 /**12 * Handle an incoming request.13 *14 * @param \Illuminate\Http\Request $request15 * @param \Closure $next16 * @param string $domain17 * @return mixed18 * @throws \Exception19 */20 public function handle($request, Closure $next, $domain)21 {22 // Find the account ID based on the domain23 $team = Team::find(Request::segment(1));24 25 if (! \Auth::user()->teams->find($team)) {26 throw new \Exception('You do not have access to this team.');27 }28 29 define('TEAM_ID', $team->id);30 31 return $next($request);32 }33}
Final notes
Multi-tenancy in Spark is not much different from multi-tenancy in any other Laravel application. The main difference is that you have teams built right in, making the setup process somewhat easier!