Experience

Laravel Route Collection Binding

Published on 18th September, 2018

Route model binding is a very useful Laravel feature. But what happens if you have an endpoint which needs to support multiple, comma-delimited IDs?

Route collection binding to the rescue.

Background

I recently worked on an API which supports requests for several specific resources at once. For example:

# Retrieve three users
GET /api/users/123,456,789

# Delete two users
DELETE /api/users/987,654

Each resource GET and DELETE endpoint behaves in a similar fashion, and needs to perform the same set of up-front actions:

  1. Extract the IDs from the URI.
  2. Retrieve the associated models.
  3. Validate that all of the requested models were retrieved.
  4. Confirm that the current user is authorised to perform the requested action, on all of the requested resources.

That’s a lot of repetition, but each endpoint is sufficiently different that I couldn’t easily move everything into a “base” controller, and call it good.

What I really needed was a way of ensuring that these checks are performed before execution even reaches the controller. That way, each endpoint could get on with doing whatever it needed to do, without worrying about the donkey work.

What I really needed was route model binding, for collections1.

Route collection binding

Behind the scenes of route model binding, Laravel uses type declarations and reflection to determine which model is required for a given route. We can’t rely upon the same magic for our route collection binding, but we can get close, and enjoy the same benefits of cleaner controllers, and simplified code.

The key to making everything work is the Route::bind method, which lets us register a closure with the Laravel router, and associate it with a key.

Every time Laravel handles a route which contains a placeholder matching our key, it calls our closure. Whatever the closure returns is passed to the route handler function. This is typically a model instance, but—critically for us—it doesn’t have to be.

For example:

// Bind the 'users' key to the given closure
Route::bind('users', function (string $keys) {
   return 'Users FTW!';
});

// Declare the route
Route::get('/users/{users}', function ($users) {
    echo $users;    // 'Users FTW!'
});

From here, it’s a very short step to achieving route collection binding. We just need to convert our URI parameter into an array of IDs, and retrieve the associated models:

Route::bind('users', function (string $keys) {
    $keys = explode(',', $keys);
    
    return User::whereIn('id', $keys)->get();
});

A complete solution

This all works perfectly, and is fine if you only have to deal with a handful of models. For the aforementioned API, however, I had dozens of models, and was loathe to manually declare the bindings for each one.

Instead, I added the following code to the RouteServiceProvider2, to automatically register the required bindings for every file in the app\Models directory.

<?php

namespace App\Providers;

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Symfony\Component\Finder\SplFileInfo;

class RouteServiceProvider extends ServiceProvider
{
    public function boot()
    {
        parent::boot();

        $this->bindRoutePaginators();
    }

    private function bindRoutePaginators()
    {
        $files = collect(File::files(app_path('Models')))->filter(function (SplFileInfo $file) {
            return $file->isFile();
        });

        $models = $files->mapWithKeys(function (SplFileInfo $file) {
            $model = $file->getBaseName('.php');
            $key = snake_case(str_plural($model));

            return [$key => '\\App\\Models\\' . $model];
        });

        $models->each(function ($class, $key) {
            Route::bind($key, function (string $keys) use ($class): Collection {
                $instance = app()->make($class);
                $keys = collect(explode(',', $keys))->map('trim');

                return $instance->whereIn($instance->getKeyName(), $keys)->get();
            });
        });
    }
}

With that in place, my controllers are nice and clean, and only need to concern themselves with the task at hand.

class UsersController extends Controller
{
    public function show(Collection $users)
    {
        // Endpoint-specific code
    }
}

  1. In reality, I needed route paginator binding, as the API paginates the results. The solution is almost exactly the same, though: just call paginate(), instead of get()↩︎

  2. The actual implementation includes some additional code, which validates the number of returned items, and checks that the user is authorised to perform the requested action on each resource. ↩︎