Multi-tenancy, the Laravel Way!
When using a framework, you want to do things the way the 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, 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 want to turn it into an app? If it's 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 to see Janet’s data, right? Of course not. No one wants to see that. This is where multi-tenancy comes in.
In our context, multi-tenancy 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 will mean a single DB with multiple users. This is a common setup for SaaS apps, large and small. It reduces the complexity of managing complicated serverless setups and introduces some security concerns. How exactly do we stop Jane from spying on Janet?
Several approaches would work, and there are many ways to pet a cat lovingly. We will discuss a couple of methods that utilize Laravel magic to make multi-tenancy fun (which is what Laravel is good at: making things fun)! Those approaches are:
Using scoped urls
Using a global query scope
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, which will often correspond to your models. In our specific case, think tenants:
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 specify the binding key:
Route::get('app/{tenant:slug}', function(Tenant $tenant){...});
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), specifically for the second parameter, Laravel will 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 company). You can chain parameters way down, and Laravel will know how to fetch (all the puns intended) 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;
}
If an item comment for the project item 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.
Ok, the super smart readers (you) might have noticed we haven’t addressed any security concerns here. 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 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. But that is a typical setup, and it makes a lot of sense if you think of tenants like teams or companies.
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 how 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 one the user belongs to. If it is, all good! If not, the user gets a 403 “forbidden” error.
Using this approach, scoping child resources under a parent/grandparent tenant and providing a policy to control access to the tenant, we have taken care of security for all child resources. Users who can’t view the current tenant 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 strength of this approach is in its simplicity. We have a single point of failure, but in a good way, because security isn’t dependent on each model or any 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 that we need to type-hint the tenant in all the functions that return something in the request. This is so Laravel knows how to bind the tenant parameter to a tenant model implicitly. It is not so bad in our examples, but this can be cumbersome if you use 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 nothing with the extra parameters from the route. There are ways around this that still feel “Laravely.” For example, before calling the controller method in your routes file, use an anonymous function to type-hint the dependencies, then return the controller method, only passing it to 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 are simpler. It also keeps the type-hinting logic in the routes file, which fits better for me.
Another drawback is that your routes can get complicated. You’ve immediately introduced an extra parameter now that we require tenants in the URL. It's not a big deal until you travel down the URL scope chain. Calling the route helper function and having to supply 4 parameters to it, returning 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 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 route binding and scoping
Pros:
Minimal code setup
Single point of entry to your resources (the tenant)
Easily enforceable criteria for accessing resources using built-in Laravel features
Only direct children of a tenant need to understand their relationship to it (like a User and a Project in our example)
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 of the gate
Before we move on, I understand that some people might see some of these pros as cons. Let's focus on the last one: only direct children must understand their relationship with the tenant. Some can see this loosening of strictness in the relationship between tenants and other models as a con. Addressing this concern leads directly to the second approach to multi-tenancy in this article, using global query scopes.
Query Scopes
Query scopes in Laravel enable you to control how models are retrieved from the Database. When set on the model class, global scopes 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 check 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 to a model class can be done in several 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, our scope is added to the query whenever a project is queried. 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 with 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 user's tenant ID. A user can only see projects that match their tenant ID. Neat! When we create a route, we 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. 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-tenancy effectively, which makes both of them fun! When choosing one, consider your situation. If you already include the tenant in the URL, using scoped urls with a tenant policy 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. You may want to include the tenant in the URL and scope child resources as an extra precaution. You can include the tenant in all URLs, perform a middleware check, “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!
Categories
- Development
Tags:
- Laravel
- Code
- Tutorial