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 GlobalScope
10{
11 /**
12 * Apply the global scope.
13 *
14 * @param \Illuminate\Database\Eloquent\Builder $query
15 * @param \Illuminate\Database\Eloquent\Model $model
16 * @return void
17 */
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 $model
28 * @return boolean
29 */
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 $model
39 * @return void
40 */
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 $request
14 * @param \Closure $next
15 * @param string $domain
16 * @return mixed
17 * @throws \Exception
18 */
19 public function handle($request, Closure $next, $domain)
20 {
21 // Find the account ID based on the domain
22 $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 LimitTenantAccess
10{
11 /**
12 * Handle an incoming request.
13 *
14 * @param \Illuminate\Http\Request $request
15 * @param \Closure $next
16 * @param string $domain
17 * @return mixed
18 * @throws \Exception
19 */
20 public function handle($request, Closure $next, $domain)
21 {
22 // Find the account ID based on the domain
23 $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!