Master Nested Resource Controllers in Laravel Routing
Friday, November 22, 2024
// 4 min read
Table of Contents
Route resources provide a convenient way to generate multiple route definitions for CRUD actions with a single line of code. In this article, we are going to focus only on Nested Resources. If you want to read about the basics of Resource Route and Controllers, read this article first.
Let's assume you manage a blog, and people can comment on your posts, so your application has its own Post model that can have multiple comments.
You set up the right relationships in your models, and now you want to create the following routes to manage a Post's Comments:
GET
/posts/{post}/comments
(index method)GET
/posts/{post}/comments/create
(create method)POST
/posts/{post}/comments
(store method)GET
/posts/{post}/comments/{comment}
(show method)GET
/posts/{post}/comments/{comment}/edit
(edit method)PUT/PATCH
/posts/{post}/comments/{comment}
(update method)DELETE
/posts/{post}/comments/{comment}
(destroy method)
Nested Resource Controllers
First, we need the controller, you can generate it easily using the following artisan command:
php artisan make:controller PostCommentController --resource --model=Comment --parent=Post
Because we want to pass the post
model as a parameter in our routes, we need to handle that in the controller methods as well. You can tell the command to take into account the parent model with the --parent
option.
After a bit of editing, this is what my controller looks like:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Comment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class PostCommentController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Post $post)
{
return view('comments.index', [
'post' => $post,
'comments' => $post->comments
]);
}
/**
* Show the form for creating a new resource.
*/
public function create(Post $post)
{
Gate::authorize('create', [Comment::class, $post]);
return view('comments.create', compact('post'));
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, Post $post)
{
Gate::authorize('create', [Comment::class, $post]);
$request->validate([
'content' => 'required|string'
]);
$post->comments()->create([
'content' => $request->content,
'user_id' => auth()->id()
]);
return redirect()->route('posts.comments.index', $post);
}
/**
* Display the specified resource.
*/
public function show(Post $post, Comment $comment)
{
return view('comments.show', compact('post', 'comment'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Post $post, Comment $comment)
{
Gate::authorize('update', $comment);
return view('comments.edit', compact('post', 'comment'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Post $post, Comment $comment)
{
Gate::authorize('update', $comment);
$request->validate([
'content' => 'required|string'
]);
$comment->update($request->all());
return redirect()->route('posts.comments.index', $post);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Post $post, Comment $comment)
{
Gate::authorize('delete', $comment);
$comment->delete();
return redirect()->route('posts.comments.index', $post);
}
}
Note the Gate authorize methods, I am using a policy to check if the user can edit or delete a post. I wrote a detailed post about Controllers, you might read the chapter about Policies.
Nested Resource Routes
To generate all these routes, we can use a nested resource route. You may use "dot" notation in your route declarations:
Route::resource('posts.comments', PostCommentController::class);
This generates the route list I referenced earlier.
Scope binding
However, with this approach, any comment can be displayed under any post when I load the show endpoint /posts/{post}/comments/{comment}
even if the comment doesn't belong to that post.
For instance, there is a comment that belongs to the post with ID 2
: /posts/2/comments/4
But I can load and see the same comment for the post with ID 1
, although this post does not have any comments at all: /posts/1/comments/4
This is why it is recommended to use scope binding, which ensures that the child model (Comment
) is linked to the parent model (Post
), preventing access to unauthorized data. In this case, we want to verify that the selected Comment belongs to the Post. If it does not, the application should return a 404 page.
You can easily add this extra verification, with the scoped
method.
Route::resource('posts.comments', PostCommentController::class)->scoped();
You just need to make sure that your models have the necessary relationships.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
public function comments()
{
return $this->hasMany(Comment::class);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Comment extends Model
{
public function post()
{
return $this->belongsTo(Post::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
Customizing the Key
You can customize the key used for retrieving a model.
Route::resource('posts.comments', PostCommentController::class)->scoped([
'post' => 'slug',
]);
This allows for URLs like /posts/{post:slug}/comments/{comment}
, where posts are retrieved by their slug
.
Do not forget to set the route key name in the model:
public function getRouteKeyName()
{
return 'slug';
}
Customizing Parameters
By default, resource route parameters are generated based on the singular version of the model name (e.g., /comments
becomes {comment}
). You can override this by using the parameters
method.
Route::resource('posts.comments', PostCommentController::class)->parameters([
'comments' => 'commentId'
]);
Although, in my opinion, this is rarely necessary.
Shallow Nesting
In our example, one comment already has a unique ID, so it is not necessary to specify the post when we need to show, edit, or delete an individual comment. To generate the routes in that manner, you can use shallow nesting.
Route::resource('posts.comments', PostCommentController::class)
->scoped(['post' => 'slug'])
->shallow();
And your routes will look like the following:
GET
/posts/{post}/comments
(index method)GET
/posts/{post}/comments/create
(create method)POST
/posts/{post}/comments
(store method)GET
/comments/{comment}
(show method)GET
/comments/{comment}/edit
(edit method)PUT/PATCH
/comments/{comment}
(update method)DELETE
/comments/{comment}
(destroy method)
The relevant methods in the controller need to be updated (I even renamed my controller, now is simply called CommentController
):
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Comment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class CommentController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Post $post)
{
return view('comments.index', [
'post' => $post,
'comments' => $post->comments
]);
}
/**
* Show the form for creating a new resource.
*/
public function create(Post $post)
{
Gate::authorize('create', [Comment::class, $post]);
return view('comments.create', compact('post'));
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, Post $post)
{
Gate::authorize('create', [Comment::class, $post]);
$request->validate([
'content' => 'required|string'
]);
$post->comments()->create([
'content' => $request->content,
'user_id' => auth()->id()
]);
return redirect()->route('posts.comments.index', $post);
}
/**
* Display the specified resource.
*/
public function show(Comment $comment)
{
return view('comments.show', compact('comment'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Comment $comment)
{
Gate::authorize('update', $comment);
return view('comments.edit', compact('comment'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Comment $comment)
{
Gate::authorize('update', $comment);
$request->validate([
'content' => 'required|string'
]);
$comment->update($request->all());
return redirect()->route('posts.comments.index', $comment->post);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Comment $comment)
{
Gate::authorize('delete', $comment);
$comment->delete();
return redirect()->route('posts.comments.index', $comment->post);
}
}
Nested API Resource Route
What if your application have an API, so a frontend application can query post comments, and create, update, or delete them through an API.
From the previous route list, you need to exclude routes that present HTML templates, such as the create and edit. For these routes, you can use the apiResource
method. Once again, we are going to use shallow nesting:
Route::apiResource('posts.comments', CommentController::class)->shallow();
This generates the following routes:
GET
/posts/{post}/comments
(index method)POST
/posts/{post}/comments
(store method)GET
/comments/{comment}
(show method)PUT/PATCH
/comments/{comment}
(update method)DELETE
/comments/{comment}
(destroy method)
You can use the controller generator command with the --api
option to generate your resource API controller:
php artisan make:controller API/CommentController --resource --model=Comment --parent=Post --api
In case you use shallow nesting, unfortunately, there is no option to take into account that during generation so you need to remove the unnecessary Parent parameters from the show, update, and destroy methods.
Nested resources can help you to organize your route structure better. You can apply middlewares on them as on any route. However, avoid deeply nested routes for readability and maintainability. And limit to two levels of nesting where possible.
If you want to support my work, you can donate using the button below. Thank you!