Permalink
Switch branches/tags
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
111 lines (84 sloc) 3.53 KB
title summary category published
Laravel Multitenancy: Route Model Binding
This time we will learn how to increment our app security when using Laravel's top feature Route Model Binding in a multitenancy project.
laravel-mutlitenancy-tips
true

Why write code like this

public function edit($articleId)
{
    $article = Article::findOrFail($articleId);
 
    return view('articles.edit', compact('article')); 
}

When you can simply do

public function edit(Article $article)
{
    return view('articles.edit', compact('article'));
}

I'm a huge fan of this Laravel feature since I discovered it. You just need to typehint the model in the function declaration and the framework will handle the findOrFail for you. That's it.

But it has a problem though, sometimes you need to check for other fields.

Using our previous example:

public function edit($articleId)
{
    $article = Article::where('tenant_id', auth()->user()->tenant_id)->findOrFail($articleId);
    
    return view('articles.edit', compact('article'));
}

Would look cleaner than

public function edit(Article $article)
{
    abort_unless($article->tenant_id === auth()->user()->tenant_id);
    
    return view('articles.edit', compact('article'));
}

This is a common scenario. You have an app to edit articles, and you have to prevent your users to edit other users's articles. Basic security rule. But you don't like that kind of code you need to read twice to understand what it does at a first glance.

Well, you could use Global Scopes for sure, but they have a problem: a global scope will be applied to all the queries on that model, no matter where in the app they occur. So when you need to query all the articles for some statistics or maintenance, you will need to add withoutGlobalScope to that query every single time.

The solution my team and I found was to override the default behaviour of Route Model Binding. Of course Laravel offers you a simple solution, you just have to override a method in your model to have it up and running.

This is the default behaviour:

/**
 * Retrieve the model for a bound value.
 *
 * @param  mixed  $value
 * @return \Illuminate\Database\Eloquent\Model|null
 */
public function resolveRouteBinding($value)
{
    return $this->where($this->getRouteKeyName(), $value)->first();
}

And this is what we will need to declare in our Article model:

/**
 * Retrieve the model for a bound value.
 *
 * @param  mixed  $value
 * @return \Illuminate\Database\Eloquent\Model|null
 */
public function resolveRouteBinding($value)
{
    return $this->where($this->getRouteKeyName(), $value)
        ->where('tenant_id', auth()->user()->tenant_id)
        ->first();
}

I know what you're about to say

But David, it's easy to understand what the code does if we check this in the controller

Yeah sure, I'll not do this for every kind of filtering like reedeming a PromoCode with an expiration date. But when you have a multitenancy app this kind of delegation is appreciated.

I'm with the philosophy of:

A Controller should receive only Entities which it can work without checking conditionals.

I apply this rule also for Request validation using Form Requests. You'll find times in which this rule cannot be applied due to some sort of complicated and arcane checks, of course, but that's what programming is about 🙂.