Search code examples
phplaravelrestlaravel-5dingo-api

How to organise controllers for REST relationships? [Laravel routes]


Imagine entities Genre and Book.

Each have API resource endpoints /genre and /book. In Laravel routes that might be:

 $app->resource('/genre', GenreController::class);

I want an endpoint for the relationship. GET /genre/1/book, to get books under the Genre #1.

What is best practice here? Place the handlers in GenreController, BookController or maybe a whole new controller?

On a sidenote, I am using the dingo-api package, but I don't suppose that makes any difference.


Solution

  • So @Chris suggested a dedicated controller for the relationship, and @RossWilson has a genius way to re-use a controller for the relationship (at least for actions that load Book).

    Unfortunately Lumen's RouteProvider returns a simple array, and as such does not have the convenience of $request->route($param) and more importantly, $request->route()->forgetParameter($param).

    === New solution ===

    I ended up doing basically exactly the same as @RossWilson suggested, just in a way that was supported by Lumen. Rather than getting the Route parameter in the Controller's __construct, I made a middleware that moved the Route parameter onto both the Request's input and query arrays.

    The Middleware looks something like this:

    public function handle($request, $next)
    {
        if ($genre_id = Arr::get($request->route()[2], 'genre')) {
            // Add 'genre_id' to the input array (not replacing it if it already exists).
            $request->merge(['genre_id' => $request->input('genre_id', $genre_id)]);
            // Add 'genre_id' to the query array.
            $request->query->add('genre_id', $genre_id);
    
            // Forget the route parameter
            // Has to be done manually, because Lumen...
            $route = $request->route();
            $request->setRouteProvider(function() use ($route) {
                Arr::forget($route[2], 'genre');
                return $route;
            });
        }
    
        // Pass the updated $request to $next.
        return $next($request);
    }
    

    In my implementation I only set the query parameters for GET and DELETE requests, and input parameters for POST and PUT.

    Then you can re-use the BookController for the genre.book resource, filtering from $request->query('genre_id') and associating the relationship from $request->input('genre_id').

    === Original solution ===

    Instead I ended up with a with a dedicated relationship controller GenreBookController, that inherits from the non-relationship controller BookController. It's not as elegant as it could be, because of the method declarations that need to match (see how $book_id = null below to work-around this), but it is quite slim and dry.

    GenreBookController extends BookController:

    protected function addGlobalScope($genre_id)
    {
        // Thanks Ross Wilson for the global scope suggestion.
        Book::addGlobalScope('genreScope', function ($query) use ($genre_id) {
            $query->where('genre_id', $genre_id);
        });
    }
    
    public function show(Request $request, $genre_id, $book_id = null)
    {
        $this->addGlobalScope($genre_id);
        return parent::show($request, $book_id);
    }
    

    and for BookController, the show method is business as usual (it doesn't need to know about the extra parameter on show).

    I also came up with a simple way for GenreBookController to pass through the genre parameter to the store method:

    public function store(Request $request, $genre_id)
    {
        // This way lets an input genre_id override the Route parameter.
        $request->merge(['genre_id' => $request->input('genre_id', $genre_id)]);
    
        // This way forces the Route parameter to be used over input parameters.
        $request->merge(['genre_id' => $genre_id]);
    
        return parent::store($request);
    }
    

    and again, for BookController it is business as usual, and it may of course make any validation/authorization for the genre passed through $request->input('genre_id'). That way there is no duplicated validation and authorization logic.

    A note on FormRequests

    If you are using FormRequests to validate the genre_id, the validation takes place before the GenreBookController can set the genre_id input variable from the route parameter.

    As I see it you have two options:

    1. Use a middleware to move the route parameter onto the Request input.
    2. Put it on the authorize method of your FormRequest (I haven't tested this, as I don't like to put such logic here).

    Laravel: If you are not using Lumen, I suggest taking a look at @RossWilson's answer, it is a little cleaner in my opinion.