DEV Community

David Llop
David Llop

Posted on • Originally published at davidllop.com on

Laravel Multitenancy: Route Model Binding

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 functiondeclaration 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 otherusers'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 onthat 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 youa 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 youhave 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, butthat's what programming is about 🙂.

Top comments (0)