Simplifying Comment Querying and Editing with Pinia Colada
Part of the series Empower and Enhance Your VitePress Blog with a Laravel API
With authentication and the API connection in place, we can now bring the comment system to the frontend.
The backend already knows how to list, create, update, and delete comments. Our task is to expose those operations through a clear set of API functions, Pinia Colada queries and mutations, and small Vue components.
There are several moving parts, but each one follows patterns introduced earlier in the series. By the end of this article, the blog will be able to display, create, edit, and delete comments.
The Basics
The comment system is a new frontend feature, so we will create a folder in .vitepress/theme for its components, queries, mutations, and API calls.
mkdir -p .vitepress/theme/commentsAs with the user feature, put the API calls in an api.ts file:
export function fetchComments(postId: number) {
return api(`api/posts/${postId}/comments`)
}
export function postComment(postId: number, content: string) {
return api(`api/posts/${postId}/comments`, {
method: 'POST',
body: { content },
})
}
export function putComment(commentId: number, content: string) {
return api(`api/comments/${commentId}`, {
method: 'PUT',
body: { content },
})
}
export function deleteComment(commentId: number) {
return api(`api/comments/${commentId}`, {
method: 'DELETE',
})
}These four functions map to the four Laravel endpoints:
Route::get('/posts/{post}/comments', [PostCommentController::class, 'index']);
Route::post('/posts/{post}/comments', [PostCommentController::class, 'store']);
Route::put('/comments/{comment}', [CommentController::class, 'update']);
Route::delete('/comments/{comment}', [CommentController::class, 'destroy']);The first function retrieves a post's comments with GET, the second creates a comment with POST, the third updates a comment with PUT, and the last deletes one with DELETE.
As explained in the previous article, query functions start with fetch, while mutation functions start with the HTTP verb. This naming convention makes their purpose clear.
Define the comment type next. It mirrors the resource returned by the Laravel API.
import type { User } from '@/user/types/user'
export interface Comment {
id: number
content: string
author: User
}Showing Comments
With the API functions and type in place, create a query that retrieves the comments for the current post. We will use it in BlogShow.vue to display them.
export const useComments = defineQuery(() => {
const { frontmatter } = useData()
return useQuery<Comment[]>({
key: () => ['comments', frontmatter.value.id],
query: () =>
fetchComments(frontmatter.value.id).then(response => response.data),
enabled: typeof document !== 'undefined',
})
})defineQuery creates a composable that runs in the Vue context. That lets us use useData to read the current page's frontmatter and request comments for only that post.
In the automation article, we added an id property to each post's frontmatter. This unique value connects the frontend post to its backend record, so we can request comments with it.
The frontend calls this value
id, while the database calls itmeta_id.
When the frontend requests /api/posts/2/comments, 2 refers to meta_id, not the database's internal id. If the database record with internal ID 1 has meta_id 2, default route model binding would look for internal ID 2 instead. That would return the wrong post or no post at all.
Note
Route model binding lets Laravel inject the model matching a route parameter. For /posts/{post}, Laravel uses the parameter name and the controller argument type to determine which Post record to retrieve.
To correct this, we need to customize the route key name in the Post model. This way, we can tell Laravel to use the meta_id column instead of the default id column.
final class Post extends Model
{
/**
* Get the route key for the model.
*/
public function getRouteKeyName(): string
{
return 'meta_id';
}
}Add two tests to ensure that posts are always retrieved by meta_id:
test('route key name', function () {
expect((new Post())->getRouteKeyName())->toBe('meta_id');
});it('returns comments by using the post meta id', function () {
Comment::factory()
->for(Post::factory()->create(['id' => 1, 'meta_id' => 5]))
->create();
$this->getJson(route('posts.comments', ['post' => 5]))
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
1,
)
);
});Now that the query uses the correct post, display the comments in BlogShow.vue.
Create three components in .vitepress/theme/comments/components:
Comment.vue: Displays one comment.CommentList.vue: Displays a list of comments.CommentSection.vue: Displays the comment section and its list.
Comment.vue is a presentational component. It receives a comment through its props and displays it.
<script lang="ts">
import type { Comment } from '@/comments/types/comment'
const blogComment = tv({
slots: {
base: 'space-y-4',
content: '',
author: 'flex items-center gap-2 font-semibold',
authorAvatar: 'size-8 rounded-full',
},
})
export interface BlogCommentProps {
comment: Comment
class?: any
ui?: Partial<typeof blogComment.slots>
}
export interface BlogCommentEmits {}
export interface BlogCommentSlots {}
</script>
<script lang="ts" setup>
const props = defineProps<BlogCommentProps>()
defineEmits<BlogCommentEmits>()
defineSlots<BlogCommentSlots>()
const { mutate: deleteComment } = useDeleteComment()
const update = ref(false)
const ui = computed(() => blogComment())
</script>
<template>
<article :class="ui.base({ class: [props.class, props.ui?.base] })">
<p>
{{ props.comment.content }}
</p>
<div :class="ui.author({ class: props.ui?.author })">
<img
v-if="props.comment.author.avatar"
:src="props.comment.author.avatar"
:class="ui.authorAvatar({ class: props.ui?.authorAvatar })"
>
<template v-if="props.comment.author.name">
{{ props.comment.author.name }} ({{ props.comment.author.username }})
</template>
<template v-else>
{{ props.comment.author.username }}
</template>
</div>
</article>
</template>CommentList.vue is also presentational. It receives a list and renders each item with Comment.vue.
<script lang="ts">
import type { Comment } from '@/comments/types/comment'
const blogComments = tv({
slots: {
base: 'space-y-8',
empty: '',
},
})
export interface BlogCommentsProps {
comments: Comment[]
class?: any
ui?: Partial<typeof blogComments.slots>
}
export interface BlogCommentsEmits {}
export interface BlogCommentsSlots {}
</script>
<script lang="ts" setup>
const props = defineProps<BlogCommentsProps>()
defineEmits<BlogCommentsEmits>()
defineSlots<BlogCommentsSlots>()
const ui = computed(() => blogComments())
</script>
<template>
<ol :class="ui.base({ class: props.ui?.base })">
<li
v-if="props.comments.length === 0"
:class="ui.empty({ class: props.ui?.empty })"
>
No comments yet.
</li>
<template v-else>
<li v-for="comment in props.comments" :key="comment.id">
<Comment :comment="comment" />
</li>
</template>
</ol>
</template>Finally, CommentSection.vue displays the titled section and fetches the comments:
<script lang="ts">
const blogCommentsSection = tv({
slots: {
base: 'space-y-12',
title: 'text-2xl font-bold',
list: 'space-y-8',
},
})
export interface BlogCommentsSectionProps {
class?: any
ui?: Partial<typeof blogCommentsSection.slots>
}
export interface BlogCommentsSectionEmits {}
export interface BlogCommentsSectionSlots {}
</script>
<script lang="ts" setup>
const props = defineProps<BlogCommentsSectionProps>()
defineEmits<BlogCommentsSectionEmits>()
defineSlots<BlogCommentsSectionSlots>()
const { state: commentsState } = useComments()
const ui = computed(() => blogCommentsSection())
</script>
<template>
<section :class="ui.base({ class: [props.class, props.ui?.base] })">
<h2 :class="ui.title({ class: props.ui?.title })">
Comments
</h2>
<div v-if="commentsState.status === 'pending'">
Loading comments...
</div>
<div v-else-if="commentsState.status === 'error'">
Error loading comments
</div>
<template v-else>
<CommentList
:comments="commentsState.data"
:class="ui.list({ class: props.ui?.list })"
/>
</template>
</section>
</template>Use CommentSection in BlogShow.vue to display the post's comments.
<script lang="ts" setup>
const { frontmatter } = useData()
</script>
<template>
<Page>
<BlogArticle :title="frontmatter.title" />
<CommentSection />
<PageFooter :back-to="{ href: '/blog', label: 'Back to Blog' }" />
</Page>
</template>Splitting the feature into focused components makes it easier to add features, change the design, fix bugs, and write tests.
Start the frontend and backend with the seeded database to see the comments on a post.
Adding a Comment
To add a comment, we need a form with a textarea. The form will use the postComment mutation.
Create a CommentForm.vue component for the form.
Start with the template:
<template>
<form
:class="ui.base({ class: [props.class, props.ui?.base] })"
@submit.prevent="onSubmit"
>
<textarea
v-model="content"
placeholder="Write a comment..."
:class="ui.textarea({ class: props.ui?.textarea })"
/>
<span v-if="error" class="text-red-500">{{ error.data.message }}</span>
<Button
type="submit"
label="Comment"
:class="ui.button({ class: props.ui?.button })"
:disabled="isLoading"
/>
</form>
</template>Then define the mutation in the script:
<script lang="ts" setup>
const { frontmatter } = useData()
const queryCache = useQueryCache()
const content = ref('')
const {
mutate: storeComment,
isLoading,
error,
} = useMutation<
{
data: Comment
},
{ postId: number, content: string, parentId?: number },
{
data: {
message: string
errors: {
content: string[]
}
}
}
>({
mutation: ({ postId, content }) => postComment(postId, content),
onSuccess: () => {
content.value = ''
},
onSettled: () =>
queryCache.invalidateQueries({
key: ['comments', frontmatter.value.id],
exact: true,
}),
})
</script>The mutation exposes the action, loading state, and error. We can use them to show an error when the request fails and disable the button while it is in progress. A loading indicator could be added in the same place.
The error shape matches Laravel's validation response. Disabling the button prevents duplicate submissions while the request is running.
Note
To keep the example simple, the form only disables the submit button while it is busy.
Handle the submit event by calling the mutation:
<script lang="ts" setup>
// ...
function onSubmit() {
storeComment({ postId: frontmatter.value.id, content: content.value })
}
</script>Note
I know this form will eventually create and edit comments, so I am designing it for both cases now. That avoids duplicated code, as the next section will demonstrate.
The complete form looks like this:
<script lang="ts">
import type { Comment } from '@/comments/types/comment'
const commentForm = tv({
slots: {
base: 'flex flex-col gap-4',
textarea:
'border-4 border-black px-8 py-4 shadow-[4px_4px_0_black] focus:outline-none',
button: '',
},
})
export interface CommentFormProps {
comment?: Comment
class?: any
ui?: Partial<typeof commentForm.slots>
}
export interface CommentFormEmits {
submit: [void]
}
export interface CommentFormSlots {}
</script>
<script lang="ts" setup>
const props = defineProps<CommentFormProps>()
const emit = defineEmits<CommentFormEmits>()
defineSlots<CommentFormSlots>()
const { frontmatter } = useData()
const queryCache = useQueryCache()
const content = ref(props.comment?.content ?? '')
const {
mutate: storeComment,
isLoading,
error,
} = useMutation<
{
data: Comment
},
{ postId: number, content: string, parentId?: number },
{
data: {
message: string
errors: {
content: string[]
}
}
}
>({
mutation: ({ postId, content }) => postComment(postId, content),
onSuccess: () => {
content.value = ''
},
onSettled: () =>
queryCache.invalidateQueries({
key: ['comments', frontmatter.value.id],
exact: true,
}),
})
function onSubmit() {
storeComment({ postId: frontmatter.value.id, content: content.value })
}
const ui = computed(() => commentForm())
</script>
<template>
<form
:class="ui.base({ class: [props.class, props.ui?.base] })"
@submit.prevent="onSubmit"
>
<textarea
v-model="content"
placeholder="Write a comment..."
:class="ui.textarea({ class: props.ui?.textarea })"
/>
<span v-if="error" class="text-red-500">{{ error.data.message }}</span>
<Button
type="submit"
label="Comment"
:class="ui.button({ class: props.ui?.button })"
:disabled="isLoading"
/>
</form>
</template>Place the form in an AddComment.vue component with a title. Show the form only to authenticated users so visitors do not see a control they cannot use:
<script lang="ts">
const addComment = tv({
slots: {
base: 'space-y-4',
header: 'flex items-center gap-2',
avatar: 'size-8 rounded-full',
title: 'text-lg font-semibold',
},
})
export interface AddCommentProps {
class?: any
ui?: Partial<typeof addComment.slots>
}
export interface AddCommentEmits {}
export interface AddCommentSlots {}
</script>
<script lang="ts" setup>
const props = defineProps<AddCommentProps>()
defineEmits<AddCommentEmits>()
defineSlots<AddCommentSlots>()
const { state: userState } = useUser()
const loginUrl = `${import.meta.env.VITE_API_URL}/auth/github/redirect`
const ui = computed(() => addComment())
</script>
<template>
<div :class="ui.base({ class: [props.class, props.ui?.base] })">
<div :class="ui.header({ class: props.ui?.header })">
<img
v-if="userState.data && userState.data.avatar"
:src="userState.data.avatar"
:class="ui.avatar({ class: props.ui?.avatar })"
>
<h3 :class="ui.title({ class: props.ui?.title })">
Add a comment
</h3>
</div>
<CommentForm v-if="userState.data" />
<div v-else class="flex justify-center">
<Button label="Login to comment" :href="loginUrl" />
</div>
</div>
</template>Unauthenticated users see a button that redirects them to GitHub. We use useUser here just as we did in the header. Pinia Colada deduplicates the request and shares the result, so we do not need prop drilling or a high-level fetch far from where the data is used.
Finally, use AddComment in CommentSection.vue below the list:
<template>
<section :class="ui.base({ class: [props.class, props.ui?.base] })">
<!-- ... -->
<!-- eslint-disable-next-line vue/valid-v-else -->
<template v-else>
<!-- ... -->
<AddComment />
</template>
</section>
</template>Editing a Comment
Users should be able to edit their own comments, so we need to handle that state in the interface.
How do we know which comments the current user may edit? The backend uses CommentPolicy for that decision. The frontend should not display an edit button when the policy denies access.
We could duplicate the policy in the frontend, but that would create two sources of truth. Any backend policy change would then require a matching frontend change, and that duplication would become harder to maintain as the policy grows.
Instead, return a can property with each comment. The backend can populate it by reusing the policy, giving the frontend the permissions it needs without duplicating the rules.
In the backend, we already check if the user is allowed to edit or delete a comment in the controller:
final class CommentController extends Controller
{
public function destroy(Comment $comment): Response
{
Gate::authorize('delete', $comment);
}
}We also need to expose whether the user may delete the comment, so we will handle both permissions together.
In the CommentResource, we can create a new property called can that will contain the permissions of the user on the comment:
final class CommentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
// ...
'can' => [
'update' => $request->user()?->can('update', $this->resource) ?? false,
'delete' => $request->user()?->can('delete', $this->resource) ?? false,
],
];
}
}The frontend can now check both permissions. The response will look like this:
{
"data": {
"id": 1,
"content": "This is a comment",
"author": {
"id": 1,
"name": "John Doe",
"username": "johndoe",
"avatar": null
},
"can": {
"update": true,
"delete": true
}
}
}The frontend can use these values in v-if conditions to display the appropriate actions.
Update the tests to verify that can is populated correctly.
it('returns the comment', function () {
// ...
$this->actingAs($user)
->putJson(route('comments.update', $comment), [
'content' => 'Updated content',
])
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
fn (AssertableJson $json) => $json
// ...
->has(
'can',
fn (AssertableJson $json) => $json
->where('update', true)
->where('delete', true)
)
)
);
});it('returns comments', function () {
// ...
$this->getJson(route('posts.comments', $post))
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
2,
fn (AssertableJson $json) => $json
// ...
->has(
'can',
fn (AssertableJson $json) => $json
->where('update', false)
->where('delete', false)
)
)
);
});it('returns the created comment', function () {
// ...
$response
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
fn (AssertableJson $json) => $json
// ...
->has(
'can',
fn (AssertableJson $json) => $json
->where('update', true)
->where('delete', true)
)
)
);
});With the API response in place, add an edit button to Comment.vue.
Before changing the component, update the Comment interface to include can:
import type { User } from '@/user/types/user'
export interface Comment {
id: number
content: string
author: User
can: {
update: boolean
delete: boolean
}
}The button will be displayed only when the backend grants the update permission.
<script lang="ts">
const blogComment = tv({
slots: {
footer: 'flex items-center justify-between',
actions: 'flex items-center gap-2',
},
})
</script>
<script lang="ts" setup>
// ...
</script>
<template>
<article :class="ui.base({ class: [props.class, props.ui?.base] })">
<!-- ... -->
<div :class="ui.footer({ class: props.ui?.footer })">
<div :class="ui.author({ class: props.ui?.author })">
<!-- ... -->
</div>
<div :class="ui.actions({ class: props.ui?.actions })">
<button v-if="props.comment.can.update">
Edit
</button>
</div>
</div>
</article>
</template>The backend policy now controls whether this button is shown.
When the user clicks the button, show CommentForm.vue in place of the comment content so the comment can be edited inline.
Add a ref to Comment.vue to track edit mode:
<script lang="ts">
// ...
</script>
<script lang="ts" setup>
const update = ref(false)
</script>
<template>
<article :class="ui.base({ class: [props.class, props.ui?.base] })">
<CommentForm
v-if="update"
:comment="props.comment"
@submit="update = false"
/>
<p v-else>
{{ props.comment.content }}
</p>
<div :class="ui.footer({ class: props.ui?.footer })">
<!-- ... -->
<div :class="ui.actions({ class: props.ui?.actions })">
<button v-if="props.comment.can.update" @click="update = !update">
Edit
</button>
</div>
</div>
</article>
</template>Finally, update CommentForm.vue for edit mode. Pass the comment as a prop and initialize the textarea with its content. The presence of that prop tells the form which mode to use.
<script lang="ts">
import type { Comment } from '@/comments/types/comment'
const commentForm = tv({
slots: {
base: 'flex flex-col gap-4',
textarea:
'border-4 border-black px-8 py-4 shadow-[4px_4px_0_black] focus:outline-none',
button: '',
},
})
export interface CommentFormProps {
comment?: Comment
class?: any
ui?: Partial<typeof commentForm.slots>
}
export interface CommentFormEmits {
submit: [void]
}
export interface CommentFormSlots {}
</script>
<script lang="ts" setup>
const props = defineProps<CommentFormProps>()
const emit = defineEmits<CommentFormEmits>()
defineSlots<CommentFormSlots>()
const { frontmatter } = useData()
const queryCache = useQueryCache()
const content = ref(props.comment?.content ?? '')
const {
mutate: storeComment,
isLoading: isStoreCommentLoading,
error: storeCommentError,
} = useMutation<
{
data: Comment
},
{ postId: number, content: string, parentId?: number },
{
data: {
message: string
errors: {
content: string[]
}
}
}
>({
mutation: ({ postId, content }) => postComment(postId, content),
onSuccess: () => {
content.value = ''
},
onSettled: () =>
queryCache.invalidateQueries({
key: ['comments', frontmatter.value.id],
exact: true,
}),
})
const {
mutate: updateComment,
isLoading: isUpdateCommentLoading,
error: updateCommentError,
} = useMutation<
{
data: Comment
},
{ commentId: number, content: string },
{
data: {
message: string
errors: {
content: string[]
}
}
}
>({
mutation: ({ commentId, content }) => putComment(commentId, content),
onSuccess: (data) => {
content.value = ''
const comments
= queryCache.getQueryData<Comment[]>(['comments', frontmatter.value.id])
?? ([] as Comment[])
const index = comments.findIndex(
(comment: Comment) => comment.id === data.data.id,
)
if (index !== -1) {
comments[index] = data.data
queryCache.setQueryData(['comments', frontmatter.value.id], comments)
}
emit('submit')
},
onSettled: () =>
queryCache.invalidateQueries({
key: ['comments', frontmatter.value.id],
exact: true,
}),
})
function onSubmit() {
if (props.comment) {
updateComment({
commentId: props.comment.id,
content: content.value,
})
}
else {
storeComment({ postId: frontmatter.value.id, content: content.value })
}
}
const error = computed(
() => storeCommentError.value || updateCommentError.value,
)
const isLoading = computed(
() => isStoreCommentLoading.value || isUpdateCommentLoading.value,
)
const ui = computed(() => commentForm())
</script>
<template>
<form
:class="ui.base({ class: [props.class, props.ui?.base] })"
@submit.prevent="onSubmit"
>
<textarea
v-model="content"
placeholder="Write a comment..."
:class="ui.textarea({ class: props.ui?.textarea })"
/>
<span v-if="error" class="text-red-500">{{ error.data.message }}</span>
<Button
type="submit"
:label="props.comment ? 'Edit' : 'Comment'"
:class="ui.button({ class: props.ui?.button })"
:disabled="isLoading"
/>
</form>
</template>The textarea starts with the comment's content when the comment prop is set. We combine the store and update loading states so the button is disabled during either request, and combine their errors so one message can be displayed. The button label changes with the mode. On submit, the form checks whether comment exists and calls either updateComment or storeComment.
Tip
Reusing CommentForm.vue works well here because creation and editing differ only in the initial value and the API endpoint.
Deleting a Comment
To delete a comment, create a useDeleteComment mutation. It will use an optimistic update to give immediate feedback while the request is running.
import type { Comment } from '@/comments/types/comment'
export const useDeleteComment = defineMutation(() => {
const { frontmatter } = useData()
const queryCache = useQueryCache()
return useMutation({
mutation: ({ commentId }: { commentId: number }) =>
deleteComment(commentId),
onMutate: ({ commentId }) => {
const oldComments
= queryCache.getQueryData<Comment[]>([
'comments',
frontmatter.value.id,
]) || ([] as Comment[])
const newComments = JSON.parse(JSON.stringify(oldComments)) as Comment[]
newComments.splice(
newComments.findIndex(c => c.id === commentId),
1,
)
queryCache.setQueryData(['comments', frontmatter.value.id], newComments)
queryCache.cancelQueries({
key: ['comments', frontmatter.value.id],
exact: true,
})
return { oldComments }
},
onError: (_, __, { oldComments }) => {
if (oldComments) {
queryCache.setQueryData(
['comments', frontmatter.value.id],
oldComments,
)
}
},
onSettled: () =>
queryCache.invalidateQueries({
key: ['comments', frontmatter.value.id],
exact: true,
}),
})
})When called, the mutation reads the current comments from the cache, removes the selected comment, and writes the new list back. The comment disappears immediately instead of waiting for the server response.
If the request fails, it restores the previous list so the comment reappears. In either case, it invalidates the query so the next result reflects the server.
Then, we can use this mutation in the actions of the Comment.vue component:
<script lang="ts">
// ...
</script>
<script lang="ts" setup>
const { mutate: deleteComment } = useDeleteComment()
</script>
<template>
<article :class="ui.base({ class: [props.class, props.ui?.base] })">
<!-- ... -->
<div :class="ui.footer({ class: props.ui?.footer })">
<!-- ... -->
<div :class="ui.actions({ class: props.ui?.actions })">
<!-- ... -->
<button
v-if="props.comment.can.delete"
@click="deleteComment({ commentId: props.comment.id })"
>
Delete
</button>
</div>
</div>
</article>
</template>Final Thoughts
That brings the series to a close. We started with the Laravel project structure and quality tooling, added GitHub authentication and a comment API, synchronized post metadata, connected the frontend and backend, and finally built the interactive comment interface.
The implementation is deliberately small, but the architecture is reusable. The same separation between API functions, queries, mutations, and components can support future features without turning one component into a place for every concern.
If you build on this example, I would love to see what you create. Possible extensions include:
- Markdown support: Let users write comments in Markdown.
- Replies: Let users respond to an existing comment.
- Likes: Let users react to a comment.
Which feature would you like to see in a future series? Let me know in the comments.
Happy coding! 👨💻
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.