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.
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:
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.