Building a Simple Comment System for User Engagement
Part of the series Empower and Enhance Your VitePress Blog with a Laravel API
With GitHub authentication in place, we can now build the feature it enables: comments.
Before writing any code, let's define the first version of the comment system. We want it to support the core interactions without taking on more complexity than necessary:
- Users should be able to leave comments on blog posts.
- Users should be able to edit or delete their comments.
- Replying to comments is not planned.
- Supporting Markdown is not planned.
For now, we want a deliberately simple comment system.
Note
I want to keep the code simple for two reasons. First, the process and the decisions behind it are more important than the amount of code. Second, more advanced features can be explored in a future series.
Building the Comment System
Now that the requirements are clear, let us map them to the concepts in our application.
A comment is a message left by a user on a blog post. In our system, the model will have these properties:
id: The unique identifier of the comment.post_id: The identifier of the post to which the comment belongs.user_id: The identifier of the user who left the comment.content: The content of the comment.created_at: The timestamp when the comment was created.updated_at: The timestamp when the comment was last updated.
user_id references the users table, and the User model will have a comments relationship. What about post_id?
For post_id, we need a Post model with these properties:
id: The unique identifier of the post.meta_id: The unique identifier coming from the frontend.title: The title of the post.created_at: The timestamp when the post was created.updated_at: The timestamp when the post was last updated.
The Post model will have a comments relationship, allowing us to associate multiple comments with each post. We will discuss meta_id in the next article, so you can ignore its purpose for now.
The relationships will look like this:
With the models and relationships defined, we can design the API for retrieving, creating, editing, and deleting comments.
First, list the endpoints we need:
- List all comments for a post, from anyone
- Create a comment, from an authenticated user
- Edit a comment, from the user who created it
- Delete a comment, from the user who created it
Then, we can map the endpoints to routes:
GET /posts/{post}/comments: List all comments for a postPOST /posts/{post}/comments: Create a commentPUT /comments/{comment}: Edit a commentDELETE /comments/{comment}: Delete a comment
The mapping is straightforward. Why use /comments/{comment} instead of /posts/{post}/comments/{comment} for the last two routes? A comment ID is unique, so we can identify the comment without repeating the post ID. In Laravel, this is called shallow nesting.
Summary before Writing Code
Before writing code, we can summarize the implementation steps:
- Create a
Postmodel with its migration.
php artisan make:model Post -m -f- Create a
Commentmodel with its migration.
php artisan make:model Comment -m -f- Create a
CommentControllerinapp/Http/Api.
php artisan make:controller Api/CommentController- Create a
PostCommentControllerinapp/Http/Api.
php artisan make:controller Api/PostCommentController- Add the routes to
routes/api.php.
We will add the tests as we go.
Note
I will not explain every test in detail. I will focus on the most important ones.
Ready? Let's go!
Viewing and Creating Comments
Create the post model with Artisan:
php artisan make:model Post -m -fNow, let's jump to the migration file and add the following code:
return new class extends Migration
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->unsignedInteger('meta_id')->unique();
$table->timestamps();
});
};Update the factory to match the migration:
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'meta_id' => fake()->unique()->numberBetween(1, 1000),
];
}
}Add a test to ensure that the model has the expected shape.
First, we need to create a test file:
mkdir -p tests/Unit/Models && touch tests/Unit/Models/PostTest.phpThen, we can add the following code to the test file:
<?php
declare(strict_types=1);
use App\Models\Post;
test('to array', function () {
$post = Post::factory()->create()->fresh();
expect(array_keys($post->toArray()))
->toEqual([
'id',
'title',
'meta_id',
'created_at',
'updated_at',
]);
});This minimal test verifies the model's persisted properties. It can catch an accidental column removal or addition in a migration. We will add a similar test for each model we create.
To make sure that our test is working, we need to enable the tests/Unit folder to have access to a working database. To do this, we can add Unit to the in method of the tests/Pest.php file:
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Http', 'Unit');Create the comment model in the same way:
php artisan make:model Comment -m -fUpdate its migration:
use App\Models\Post;
use App\Models\User;
return new class extends Migration
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Post::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
$table->text('content');
$table->timestamps();
});
};Update the factory to match the migration:
use App\Models\Post;
use App\Models\User;
// ...
class CommentFactory extends Factory
{
public function definition(): array
{
return [
'post_id' => Post::factory(),
'user_id' => User::factory(),
'content' => fake()->paragraph(),
];
}
}Add the corresponding test:
<?php
declare(strict_types=1);
use App\Models\Comment;
test('to array', function () {
$comment = Comment::factory()->create()->fresh();
expect(array_keys($comment->toArray()))
->toEqual([
'id',
'post_id',
'user_id',
'content',
'created_at',
'updated_at',
]);
});Now add the relationships. A post has many comments, a comment belongs to a post, a comment belongs to a user, and a user has many comments.
Note
We use belongs to instead of has one because a comment is a child of a post and cannot exist without one. A has one relationship describes a parent model's single child, such as a user having one profile while the profile belongs to the user.
These relationships are defined as follows:
class Post extends Model
{
/**
* Get all of the comments for the Post
*
* @return HasMany<Comment, covariant Post>
*/
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}class Comment extends Model
{
/**
* Get the post that owns the comment.
*
* @return BelongsTo<Post, covariant Comment>
*/
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
/**
* Get the user that owns the comment.
*
* @return BelongsTo<User, covariant Comment>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}class User extends Authenticatable
{
/**
* Get comments that belong to the user.
*
* @return HasMany<Comment, covariant User>
*/
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}These relationships simplify Eloquent queries. To get all comments for a post, we can write:
Post::find(1)->comments;They also help when using factory methods such as has and for to create related models.
Add relationship tests to ensure that the models are connected as expected.
Note
When working with models, I often write the tests after the first implementation because the design can change quickly. Writing the model helps me clarify what the tests should verify.
use App\Models\Comment;
test('relations', function () {
$post = Post::factory()
->hasComments(1)
->create();
expect($post->comments)->each->toBeInstanceOf(Comment::class);
});use App\Models\Post;
use App\Models\User;
test('relations', function () {
$comment = Comment::factory()
->forPost()
->create();
expect($comment->post)->toBeInstanceOf(Post::class)
->and($comment->user)->toBeInstanceOf(User::class);
});<?php
declare(strict_types=1);
use App\Models\Comment;
use App\Models\User;
test('to array', function () {
$user = User::factory()->create()->fresh();
expect(array_keys($user->toArray()))
->toEqual([
'id',
'name',
'username',
'email',
'email_verified_at',
'github_id',
'avatar',
'created_at',
'updated_at',
]);
});
test('relations', function () {
$user = User::factory()
->hasComments(1)
->create();
expect($user->comments)->each->toBeInstanceOf(Comment::class);
});We had not added any tests for User, so this is a good opportunity to add its array-shape test as well.
The pattern is consistent, and Laravel makes each step straightforward.
Creating the Controllers
Now that the models are ready, we can create the controllers.
PostCommentController will have two methods:
index: lists all comments for a post throughGET /posts/{post}/comments.store: creates a comment throughPOST /posts/{post}/comments.
As in the previous article, let's start by writing a test for the index method:
<?php
declare(strict_types=1);
use App\Models\Comment;
use App\Models\Post;
use Illuminate\Testing\Fluent\AssertableJson;
it('returns comments', function () {
$post = Post::factory()->create();
$comments = Comment::factory()
->count(2)
->for($post)
->create();
$this->getJson(route('posts.comments', $post))
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
2,
fn (AssertableJson $json) => $json
->where('id', $comments->first()->id)
->where('content', $comments->first()->content)
->has(
'author',
fn (AssertableJson $json) => $json
->where('name', $comments->first()->user->name)
->where('username', $comments->first()->user->username)
->where('avatar', $comments->first()->user->avatar)
)
)
);
});For HTTP tests, I use the AAA pattern:
- Arrange: prepare the data
- Act: call the method
- Assert: check the result
In this test, we create a post and two comments for it. That is the Arrange step. We then call GET /posts/{post}/comments, which is the Act step. Finally, we verify that the response contains two comments and has the expected structure, which is the Assert step.
The failing test tells us to add a route in routes/api.php:
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\PostCommentController;
Route::get('/posts/{post}/comments', [PostCommentController::class, 'index'])
->name('posts.comments');Then, we must instruct Laravel to read this file by updating the bootstrap/app.php file:
<?php
declare(strict_types=1);
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)Finally, create the controller with an index method:
php artisan make:controller Api/PostCommentController<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class PostCommentController extends Controller
{
public function index(Request $request)
{
//
}
}The route now exists, but the test reports that the response is not valid JSON because the controller does not return anything. 😄
Use that failure as the next implementation step:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\CommentResource;
use App\Models\Comment;
use Illuminate\Http\Resources\Json\ResourceCollection;
final class PostCommentController extends Controller
{
public function index(): ResourceCollection
{
$comments = Comment::query()
->with('user')
->get();
return CommentResource::collection($comments);
}
}The index method retrieves the comments, eager-loads the user relationship, and returns a CommentResource collection.
Note
Retrieving every record is usually not a good practice; pagination is safer for a large collection. In this example, we expect only a small number of comments, so returning the collection is acceptable. Pagination can be added later if that assumption changes.
Now, we need to create the CommentResource class:
php artisan make:resource CommentResource<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Comment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Comment
*/
final class CommentResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'content' => $this->content,
'author' => UserResource::make($this->whenLoaded('user')),
];
}
}And a second resource for the author:
php artisan make:resource UserResource<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin User
*/
final class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'name' => $this->name,
'username' => $this->username,
'avatar' => $this->avatar,
];
}
}What are these resources?
A resource is a transformation layer between our Eloquent models and the JSON responses sent to the client. It defines how a model is transformed into JSON.
Here, we return a comment's id, content, and author. The nested author resource exposes only the user's name, username, and avatar. This lets us change the response shape without changing the controller and prevents accidental exposure of fields such as the user's email.
Now, we can run our test and it should pass. But let's not be too hasty and add a test to verify that this method only returns the comments for the post:
it('returns comments for a post', function () {
Comment::factory()
->count(2)
->create();
$this->getJson(route('posts.comments', Post::factory()->create()))
->assertJson(
fn (AssertableJson $json) => $json
->has('data', 0)
);
});This test creates two comments for one post, calls the route with another post, and expects an empty response.
The test fails because the controller is not filtering comments by post.
To fix this, we can update the index method:
use App\Models\Post;
final class PostCommentController extends Controller
{
public function index(Post $post): ResourceCollection
{
$comments = Comment::query()
->whereBelongsTo($post)
->with('user')
->get();
return CommentResource::collection($comments);
}
}Laravel's route model binding gives us the Post instance directly. Because the route contains {post} and the method accepts a Post, Laravel finds the matching record and injects it. This keeps the controller concise.
Now, we can run our tests and they should pass. 👌
Now move on to the store method.
You know the drill, let's start by writing a test:
<?php
use App\Models\Post;
it('requires authentication', function () {
$post = Post::factory()->create();
$this->postJson(route('posts.comments.store', $post))
->assertUnauthorized();
});This test verifies that authentication is required. Add the route:
Route::middleware('auth')->group(function () {
Route::post('/posts/{post}/comments', [PostCommentController::class, 'store'])
->name('posts.comments.store');
});Continuing with another test:
use App\Models\User;
// ...
it('stores a comment', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user)
->postJson(route('posts.comments.store', $post), [
'content' => 'This is a comment',
]);
$this->assertDatabaseHas('comments', [
'post_id' => $post->id,
'user_id' => $user->id,
'content' => 'This is a comment',
]);
});This test verifies that the comment is stored with the correct post_id, user_id, and content. The controller sets the two foreign keys.
Now, we can implement the store method:
final class PostCommentController extends Controller
{
public function store(Request $request, Post $post)
{
Comment::create([
'post_id' => $post->id,
'user_id' => $request->user()->id,
'content' => $request->input('content')
]);
}
}Note
We are writing only the minimum code needed to make the tests pass. That does not mean the implementation is complete. For example, the request still needs validation, which we will add with the next test.
Add a test for request validation:
it('requires a valid data', function (array $badData, array|string $errors) {
$post = Post::factory()->create();
$response = $this->actingAs(User::factory()->create())
->postJson(route('posts.comments.store', $post), $badData);
$response->assertJsonValidationErrors($errors);
})->with([
[['content' => ''], ['content' => 'The content field is required.']],
[['content' => 1], ['content' => 'The content field must be a string.']],
[['content' => 'a'], ['content' => 'The content field must be at least 6 characters.']],
[['content' => 'a'.str_repeat('a', 2001)], ['content' => 'The content field must not be greater than 2000 characters.']],
]);Pest runs this test four times with different data. A dataset lets us cover several invalid inputs without duplicating the test body.
Create a StoreCommentRequest class to hold these rules:
php artisan make:request StoreCommentRequest<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCommentRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:2000', 'min:6'],
];
}
}Update store to use the new request:
use App\Http\Requests\StoreCommentRequest;
use Illuminate\Http\Request;
final class PostCommentController extends Controller
{
public function store(StoreCommentRequest $request, Post $post)
{
// ...
}
}Laravel now validates the request before the controller runs. ✨
Finally, we can add a test to check that the returned data is correct:
it('returns the created comment', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->make();
$response = $this->actingAs($user)
->postJson(route('posts.comments.store', $post), [
'content' => $comment->content,
]);
$comment = Comment::first();
$response
->assertCreated()
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
fn (AssertableJson $json) => $json
->where('id', $comment->id)
->where('content', $comment->content)
->has(
'author',
fn (AssertableJson $json) => $json
->where('name', $user->name)
->where('username', $user->username)
->where('avatar', $user->avatar)
)
)
);
});This test verifies the response data and checks that the status code is 201 Created with assertCreated.
Now, let's update the store method to return the created comment:
use Illuminate\Http\Resources\Json\JsonResource;
final class PostCommentController extends Controller
{
public function store(StoreCommentRequest $request, Post $post): JsonResource
{
$comment = Comment::create([
'post_id' => $post->id,
'user_id' => $request->user()->id,
'content' => $request->input('content')
]);
$comment->load('user');
return CommentResource::make($comment);
}
}All tests should now pass.
These tests are representative of the tests you can write for other controllers.
Editing and Deleting Comments
Now we can implement comment editing and deletion.
Note
I will move faster here because the workflow is familiar. The complete tests are included so you can still follow each requirement.
For the edition, we need to create a new test file:
mkdir -p tests/Http/Api/Comment && touch tests/Http/Api/Comment/UpdateTest.phpThen, we can add the following test:
<?php
declare(strict_types=1);
use App\Models\Comment;
use App\Models\User;
use Illuminate\Testing\Fluent\AssertableJson;
it('requires authentication', function () {
$comment = Comment::factory()->create();
$this->putJson(route('comments.update', $comment))
->assertUnauthorized();
});
it('requires the user to be the author of the comment', function () {
$user = User::factory()->create();
$comment = Comment::factory()->create();
$this->actingAs($user)
->putJson(route('comments.update', $comment), [
'content' => 'Updated content',
])
->assertForbidden();
});
it('updates a comment', function () {
$user = User::factory()->create();
$comment = Comment::factory()
->for($user)
->create();
$this->actingAs($user)
->putJson(route('comments.update', $comment), [
'content' => 'Updated content',
]);
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
'content' => 'Updated content',
]);
});
it('requires a valid data', function (array $badData, array|string $errors) {
$user = User::factory()->create();
$comment = Comment::factory()->for($user)->create();
$this->actingAs($user)
->putJson(route('comments.update', $comment), $badData)
->assertJsonValidationErrors($errors);
})->with([
[['content' => ''], ['content' => 'The content field is required.']],
[['content' => 1], ['content' => 'The content field must be a string.']],
[['content' => 'a'], ['content' => 'The content field must be at least 6 characters.']],
[['content' => 'a' . str_repeat('a', 2001)], ['content' => 'The content field must not be greater than 2000 characters.']],
]);
it('returns the comment', function () {
$user = User::factory()->create();
$comment = Comment::factory()->for($user)->create();
$this->actingAs($user)
->putJson(route('comments.update', $comment), [
'content' => 'Updated content',
])
->assertOk()
->assertJson(
fn(AssertableJson $json) => $json
->has(
'data',
fn(AssertableJson $json) => $json
->where('id', $comment->id)
->where('content', 'Updated content')
->has(
'author',
fn(AssertableJson $json) => $json
->where('name', $user->name)
->where('username', $user->username)
->where('avatar', $user->avatar)
)
)
);
});Then, we can add the route:
<?php
use App\Http\Controllers\Api\CommentController;
Route::middleware('auth')->group(function () {
Route::put('/comments/{comment}', [CommentController::class, 'update'])
->name('comments.update');
});Create the controller:
php artisan make:controller Api/CommentControllerImplement the update method:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateCommentRequest;
use App\Http\Resources\CommentResource;
use App\Models\Comment;
use Illuminate\Http\Resources\Json\JsonResource;
final class CommentController extends Controller
{
public function update(UpdateCommentRequest $request, Comment $comment): JsonResource
{
$comment->update($request->validated());
$comment->load('user');
return CommentResource::make($comment);
}
}This method needs a dedicated form request:
php artisan make:request UpdateCommentRequestThen, we can implement the request:
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Comment;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Comment $comment
*/
final class UpdateCommentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->can('update', $this->comment);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:2000', 'min:6'],
];
}
}The request's authorize method checks whether the current user may update the comment. It delegates that decision to a policy, which we need to create.
php artisan make:policy CommentPolicyThen, we can implement the policy:
<?php
namespace App\Policies;
use App\Models\Comment;
use App\Models\User;
class CommentPolicy
{
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
}The update flow is now complete.
Finish the article by adding comment deletion:
<?php
declare(strict_types=1);
use App\Models\Comment;
use App\Models\User;
it('requires authentification', function () {
$comment = Comment::factory()->create();
$this->deleteJson(route('comments.destroy', $comment))
->assertUnauthorized();
});
it('requires the user to be the author of the comment', function () {
$user = User::factory()->create();
$comment = Comment::factory()->create();
$this->actingAs($user)
->deleteJson(route('comments.destroy', $comment))
->assertForbidden();
});
it('deletes a comment', function () {
$user = User::factory()->create();
$comment = Comment::factory()->for($user)->create();
$this->actingAs($user)
->deleteJson(route('comments.destroy', $comment));
$this->assertDatabaseMissing('comments', [
'id' => $comment->id,
]);
});<?php
// ...
Route::middleware('auth')->group(function () {
// ...
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])
->name('comments.destroy');
});use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
final class CommentController extends Controller
{
// ...
public function destroy(Comment $comment): Response
{
Gate::authorize('delete', $comment);
$comment->delete();
return response()->noContent();
}
}The Gate facade authorizes the deletion. Laravel resolves CommentPolicy from the model passed to authorize.
final class CommentPolicy
{
// ...
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
}The comment system is ready, and the tests give us confidence that its main behaviors work without requiring a manual click-through.
What's Next
The comment API is now in place. It supports listing, creating, updating, and deleting comments, and the tests describe the behavior and permissions that keep those operations safe.
Working test-first kept the implementation focused: each failure pointed to the next piece of behavior, and the completed tests now protect us from regressions.
Next, we will connect the static frontend to the backend. We will generate post metadata during the VitePress build and use Laravel to synchronize the posts table automatically.
Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below, it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.