Multi-tenancy, the Laravel Way!

Two ways to do multi-tenancy in Laravel.

James Hamann
Laravel Tips
9 min readMay 23, 2024

--

When you are using a framework, you want to do things the way that framework intended. It just feels nice, right? If you have a problem, chances are your framework has solved that problem in a very elegant way. At least if the framework is good. And if you are using Laravel, well, you are using one of the best. And you are probably super cool.

So super cool dude (person, cat, however you identify), you have an awesome SaaS idea and you want to turn it into an app? If its SaaS, you are gonna need users. And if you need users, you need to segment those users from each other. You don’t want Jane seeing Janet’s data right? Of course not. No one wants to see that. This is where multi-tenancy comes in.

Multi-tenancy, in our context, means an app with a single database and multiple users that share that database. It can mean other things, especially in this age of complex serverless shenanigans. But for our purposes, it is going to mean single DB, multiple users. This is a common setup for SaaS apps large and small. It reduces much of the complexity of managing complicated serverless setups, but it also introduces some security concerns. How exactly do we stop Jane from spying on Janet?

There are several approaches to this that would work, lots of ways to lovingly pet a cat. We are going to talk about a couple methods that utilize Laravel magic to make multi-tenancy actually fun (which is what Laravel is good at, making things fun)! Those approaches are:

  1. Using “tenant” middleware
  2. Using a global query scope

But before we get into that, we need to talk about scoped urls.

Scoped Urls

Ok, scoped urls. What are they? They fall into the realm of routing in Laravel, and while this won’t be an in-depth look into routing, the official documentation is pretty amazing. Let’s talk route parameters real quick:

When creating routes, Laravel allows you to parameterize sections of your url, and very commonly this will correspond to your models. In our specific case, think tenants (teams):

Route::get('/app/{tenant}', function(Tenant $tenant){
return "hello {$tenant.name}"
});

Using implicit binding, Laravel will take your tenant parameter and attempt to locate that tenant model, then display its name. Super cool on its own. However you probably don’t want people looking up a tenant via an id, so Laravel allows you to specifiy the binding key:

Route::get('app/{tenant:slug}', function(Tenant $tenant){...});

Ok, now that we understand parameterizing (and learned it was a real word) we can delve into scoped urls. While urls with single parameters are cool, urls with two are even cooler:

Route::get('app/{tenant:slug}/{project:slug}', function (
Tenant $tenant,
Project $project)
{
return "Welcome to Project {$project->name}"
})

Yes, using implicit binding and respecting the order of the parameters in your route, Laravel will fetch the appropriate Tenant and Project to show to the user. But that isn’t even the coolest part about this! Since we specified a binding key (slug), and specifically for the second parameter, Laravel is going to treat this as a “child” parameter and only return a project that is a direct child of a tenant, if it exists. So if “Cats Inc.” has a project called “Cat Food”, /app/cats-inc/cat-food would get us the project, but /app/dogs-inc/cat-food would not (unless Dogs Inc. also had a project with the same name, but then we would still be viewing the correct project for each team). You can chain parameters way down, and Laravel will know how to fetch (haha) all of them, as long as the relationships are defined on the models, and you type hint them:

Route::get('/app/{tenant:slug}/{project:slug}/{project-item:slug}/{item-comment:slug}...', function(
Tenant $tenant,
Project $project,
ProjectItem $projectItem,
ItemComment $itemComment
))
{
return $itemComment;
}

As long as an item comment for the slideshow deliverable for the cat food project for cats inc exists, then gosh darn the user can see it. It’s that simple.

We can use route grouping to avoid code reuse:

Route::prefix('/app/{tenant:slug}')->group(function(){
Route::get('/{project:slug}', function(Tenant, Project)...);
Route::get('/{member:name}', function (Tenant, Member)...;
})

Now we have created routes that scope projects to a tenant and members to a tenant, and we did it in one function. Isn’t Laravel awesome? I think so.

Tenant Middleware

Ok, the super smart readers (you) might have noticed we haven’t actually addressed any security concerns here. I mean sure, we can only view a project if it exists for a tenant and we need to guess the project name and tenant name to do so, but that isn’t security at all. That’s just a bit of obfuscation. A form of security, but not a great one. Nothing is locked down. Enter the “can” middleware, and model policies.

Model Policies are ways to control access to models. I won’t go into all the details, but you basically define functions with logic determining if a certain user can do a thing with a thing. So for our tenants, if we only want people who belong to that tenant to see the tenant, we can do something like this:

//App\Policies\TenantPolicy

class TenantPolicy
{

public function view(User $user, Tenant $tenant): bool
{
return $user->tenant->id === $tenant->id;
}
}

This presupposes that a user will only belong to one tenant, and that the model relationships are set up on the model class. That is a typical setup and if you think of tenants like teams or companies it makes a whole lot of sense.

Now that we have set up the policy, we can call it using the “can” middleware on our route, and we can apply it to the same group we used the prefix for:

Route::middleware('can:view,tenant')->prefix('/app/{tenant:slug}')->group(function(){
Route::get('/{project:slug}', function(Tenant, Project)...);
Route::get('/{member:name}', function (Tenant, Member)...;
});

Seems like magic right? We are just telling Laravel to use the built-in “can” middleware, which knows to call a policy for a model, and we supply the method call, “view,” and the parameter name, “tenant.” Laravel is smart enough to implicitly bind the tenant parameter to a Tenant class and call the Tenant policy due to naming conventions. The policy will tell us if the tenant being passed in the url is the same that the user belongs too. If it is, all good! If not, the user gets a 403 “forbidden” error.

Using this approach, we have taken care of security for all child resources. If the user can’t view the current tenant, they will get an error. And a resource won’t be found if it isn’t a descendant of the tenant. Golly gee, we did it!

The strengths of this approach are in its simplicity. We essentially have a single point of failure, but in a good way, because security isn’t dependent on each model, or future models, and only needs to be verified once per request. We’ve put up a fence around our resources with a required initial gate, the tenant. Do you belong to the tenant? No? Then you can’t see the comment for the slideshow. Get out of here Jane! And because of its simplicity, none of your other models need to understand their relationship to the tenant, beyond the ones that are “naturally” direct children.

There are some drawbacks to this approach. You might have noticed in the code examples we need to typehint the tenant in all the functions that end up returning something in the request. This is so Laravel knows how to implicitly bind the tenant parameter to a tenant model. Not so bad in our examples, but this can be cumbersome if you are using controllers for models two, three, and even deeper parameters down the url. In our admittedly contrived example of a route for a deeply nested comment, in a controller for that comment, you would have a function with an arguments list like this:

public function show(
Tenant $tenant,
Project $project,
ProjectItem $projectItem,
ItemComment, $itemComment
){...}

That feels bad. The item comment controller shouldn’t have to care about that many parameters and what order they are in. Also, you often do not do anything with the extra parameters from the route. There are ways around this that still feel “Laravely.” For example, in your routes file, before calling the controller method, use an anonymous function to typehint the dependencies for a group, then return the controller method only passing it the necessary model. This method works well when adding new routes and modifying existing ones, as you don’t need to modify existing controllers if they were more simple. It also keeps the typehinting logic in the routes file, and that fits better to me.

Another drawback is that your routes can get real complicated. You’ve immediately introduced an extra parameter now that we require tenants in the url. Not a big deal, until you start travelling down the url scope chain. Calling the route helper function and having to supply 4 parameters to it, going back up a deeply nested model to the tenant, is no fun. This isn’t a problem inherent to multi-tenancy with route scoping, but it does exacerbate the problem. If you are going to use this approach I would recommend a custom route helper service that knows how to inject the proper parameters into the route helper function.

Let’s put these strengths and weaknesses in a handy dandy list, so people who don’t read paragraphs can understand:

Multi-Tenancy with Tenant Middleware

Pros:

  • Minimal code setup
  • Single point of entry to your resources (the tenant)
  • Easily enforceable criteria for accessing resources using built-in Laravel features

Cons:

  • Having to type hint the route parameters in your controllers or route functions is cumbersome and inefficient
  • Routes become complicated quickly with multiple parameters, and the approach adds complication out the gate

Let’s move on to the next way to implement multi-tenancy.

Query Scopes

Query scopes in Laravel enable you to control how models are retrieved from the Database. Global scopes, when set on the model class, are applied to ALL queries for a model. I won’t go into all the details of how to create one, check out the documentation for that, but let’s emulate a simple one to scope to the tenant on a model:

// App/Models/Scopes

class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = ...
$builder->where('tenant_id', '=', $tenantId);
}
}

Applying this on a model class can be done in a number of ways. Let’s look at the “booted” method:

class Project extends Model
{
/**
* The "booted" method of the model.
*/
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}

Now, whenever a project is queried, our scope is added to the query. Neat huh? But you might have noticed we didn’t set the tenant id we want in the scope. How would we access the proper id in the scope? What should the id be? In our previous solution, the middleware for the route method provided us the user. We don’t have that here, but we can also easily access it via the global “auth” helper:

$tenantId = auth()->user()->tenant_id;

Now, whenever a project query is run, the query is scoped to the tenant id of the user. A user can only see projects that match their tenant id. Neat! Using this method, we actually don’t need to include the tenant in the url anymore:

Route::get('/{project}', function(Project $project){...});

Simpler routes, which means this approach addresses a concern raised by the previous approach. And if we scope our urls like in the previous solution, this will still prevent child resources from being accessed by a parent resource that doesn’t own them. Neat huh! We bypassed the need for a model policy and authorization middleware.

You may have noticed that we had to apply the scope to the model class directly. This will be true for every parent model we want to use, and this can get cumbersome. Luckily, we can extract the scoping attachment into a trait or a separate class:

namespace App\Models;

class TenantModel extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}

Any class we want to be scoped to a tenant can extend the TenantModel class.

As before, an internet-friendly list of pros and cons:

Global Query Scope

Pros

  • All queries for a model will automatically scope to the tenant of the user
  • No need to include tenant in the url
  • No need for extra middleware

Cons

  • Have to add scope to models directly
  • Less clear when a user can and cannot view tenant resources

Conclusion

I’ve used both of these approaches and they both utilize Laravel magic to implement multi-tenanacy effectively, which makes both of them fun! When choosing one, consider your situation. If you are already including the tenant in the url, using scoped urls with tenant middleware can solve your security needs for a multi-tenant app. If you don’t want to include the tenant in the url, consider the global query scoping.

And of course, it is ok to mix and match both. Maybe you want to include the tenant in the url and still scope child resources as an extra precaution. You can include the tenant in all urls, perform a middleware check, then “forget” the parameter and use global query scoping for the rest of the resources. Do what works best for you, and thank god we have Laravel to make building amazing web apps fun!

--

--

James Hamann
Laravel Tips

I'm a father, a software developer, and a poet. It's a strange combo. I feel a lot of things. I love words and people.