I am working on a blogging application (link to GitHub pull request) in Laravel 8.
In appHttpControllersArticlesController.php
I have methods for displaying article details, with comments included:
class ArticlesController extends FrontendController
{
protected $per_page = 12;
protected $comments_per_page = 10;
protected $comments_orderby_direction = 'desc';
public function show($slug)
{
$article = Article::visible()->where('slug', $slug)->firstOrFail();
// Throttle views
$sessionKey = 'article_viewed_' . $article->id;
$lastViewedAt = session($sessionKey);
if (!$lastViewedAt || Carbon::createFromTimestamp($lastViewedAt)->diffInMinutes(now()) >= 60) {
$article->increment('views');
session()->put($sessionKey, now()->timestamp);
}
// Older article
$old_article = Article::visible()
->where(function ($q) use ($article) {
$q->where('published_at', '<', $article->published_at)
->orWhere(function ($q2) use ($article) {
$q2->where('published_at', $article->published_at)
->where('id', '<', $article->id);
});
})
->orderBy('published_at', 'desc')
->orderBy('id', 'desc')
->first();
// Newer article
$new_article = Article::visible()
->where(function ($q) use ($article) {
$q->where('published_at', '>', $article->published_at)
->orWhere(function ($q2) use ($article) {
$q2->where('published_at', $article->published_at)
->where('id', '>', $article->id);
});
})
->orderBy('published_at', 'asc')
->orderBy('id', 'asc')
->first();
// Approved top-level comments with approved replies
$comments = Comment::where('article_id', $article->id)
->where('approved', 1)
->whereNull('parent_id')
->with(['replies' => function ($query) {
$query->where('approved', 1);
}])
->orderBy('id', 'desc')
->get();
$comments_count = $comments->count();
return view(
'themes/' . $this->theme_directory . '/templates/single',
array_merge($this->data, [
'categories' => $this->article_categories,
'article' => $article,
'old_article' => $old_article,
'new_article' => $new_article,
'comments' => $comments,
'comments_count' => $comments_count,
'comments_per_page' => $this->comments_per_page,
'tagline' => $article->title,
'is_infinitescroll' => false
])
);
}
public function get_comments_ajax(Request $request)
{
if (!$request->ajax()) exit();
$article_id = $request->post('article_id');
$page_number = $request->post('page');
$offset = $this->comments_per_page * $page_number;
$more_comments_to_display = true;
$data['comments'] = $this->get_commentQuery($article_id, $this->comments_per_page, $offset)->get();
$content = '';
if ($data['comments']->count()) {
$content .= view(
'themes/' . $this->theme_directory . '/partials/comments-list',
array_merge($data, [
'is_infinitescroll' => $this->is_infinitescroll,
'theme_directory' => $this->theme_directory,
'article_id' => $article_id
])
);
} else {
$more_comments_to_display = false;
}
echo json_encode([
'html' => $content,
'page' => $page_number,
'more_comments_to_display' => $more_comments_to_display,
'article_id' => $article_id
]);
exit();
}
private function get_commentQuery(int $article_id, int $limit = 0, int $offset = 0): object
{
$commentQuery = Comment::where(['article_id' => $article_id, 'approved' => 1])
->orderBy('id', $this->comments_orderby_direction)
->with(['replies' => function ($query) {
$query->where('approved', 1);
}]);
if ($offset > 0) $commentQuery = $commentQuery->offset($offset);
if ($limit > 0) $commentQuery = $commentQuery->limit($limit);
return $commentQuery;
}
}
The comment model:
class Comment extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'article_id',
'parent_id',
'body',
'approved'
];
// Join users to comments
public function user() {
return $this->belongsTo(User::class);
}
// Join articles to comments
public function article() {
return $this->belongsTo(Article::class);
}
// Join comment replies to comments
public function replies() {
return $this->hasMany(Comment::class, 'parent_id');
}
}
The Blade list of comments (and replies):
@if ($comments && count($comments))
<div class="comments mt-4" id="comments_container">
@foreach ($comments as $comment)
@if (is_null($comment->parent_id))
<div x-data="{ showEdit: false, showReply: false }" class="card bg-light comment mb-3" id="comment-{{ $comment->id }}">
<h5 class="card-header">
<span class="row">
<span class="col-md-6 text-dark avatar">
<img src="{{ asset('images/avatars/' . $comment->user->avatar) }}"
alt="{{ $comment->user->first_name }} {{ $comment->user->last_name }}"
class="rounded-circle me-1">
{{ $comment->user->first_name }} {{ $comment->user->last_name }} says:
</span>
<span class="col-md-6 text-dark d-none d-md-flex align-items-center justify-content-end">
{{ date('jS M Y', strtotime($comment->created_at)) }}
</span>
</span>
</h5>
<div class="card-body bg-white p-2">
<p class="comment__text">{{ $comment->body }}</p>
<ul class="comment-actions list-unstyled">
@if (Auth::check() && $comment->user->id !== Auth::id())
<li>
<a class="comment-reply" @click.prevent="showReply = !showReply">
<i class="fa-regular fa-comments"></i>
<span x-text="showReply ? 'Cancel' : 'Reply'"></span>
</a>
</li>
@endif
@if (Auth::check() && $comment->user->id === Auth::id())
<li>
<a class="comment-edit" @click.prevent="showEdit = !showEdit">
<i class="fa-regular fa-pen-to-square"></i>
<span x-text="showEdit ? 'Cancel' : 'Edit'"></span>
</a>
</li>
<li>
@include(
'themes/' . $theme_directory . '/partials/comment-delete-form',
['commentOrReply' => $comment]
)
</li>
@endif
</ul>
{{-- Edit form --}}
<div class="mt-2 comment-edit-form-wrapper" x-show="showEdit" x-transition>
@include('themes/' . $theme_directory . '/partials/comment-edit-form', [
'commentOrReply' => $comment,
])
</div>
{{-- Reply form --}}
@if (Auth::check())
<div class="mt-2 reply-form" x-show="showReply" x-transition>
@include('themes/' . $theme_directory . '/partials/comment-form', [
'article' => $article,
'parent_id' => $comment->id,
])
</div>
@endif
</div>
{{-- Replies --}}
@if ($comment->replies && count($comment->replies))
<div class="replies ps-4 mt-3">
@foreach ($comment->replies as $reply)
<div x-data="{ showEdit: false }" class="card bg-light comment mb-2 me-2"
id="comment-{{ $reply->id }}">
<h5 class="card-header">
<span class="row">
<span class="col-md-6 text-dark avatar">
<img src="{{ asset('images/avatars/' . $reply->user->avatar) }}"
alt="{{ $reply->user->first_name }} {{ $reply->user->last_name }}"
class="rounded-circle me-1">
{{ $reply->user->first_name }} {{ $reply->user->last_name }} says:
</span>
<span
class="col-md-6 text-dark d-none d-md-flex align-items-center justify-content-end">
{{ date('jS M Y', strtotime($reply->created_at)) }}
</span>
</span>
</h5>
<div class="card-body bg-white p-2">
<p class="comment__text">{{ $reply->body }}</p>
<ul class="comment-actions list-unstyled">
@if (Auth::check() && $reply->user->id === Auth::id())
<li>
<a class="comment-edit" @click.prevent="showEdit = !showEdit">
<i class="fa-regular fa-pen-to-square"></i>
<span x-text="showEdit ? 'Cancel' : 'Edit'"></span>
</a>
</li>
<li>
@include(
'themes/' .
$theme_directory .
'/partials/comment-delete-form',
['commentOrReply' => $reply]
)
</li>
@endif
</ul>
{{-- Edit form --}}
<div class="mt-2 comment-edit-form-wrapper" x-show="showEdit" x-transition>
@include(
'themes/' . $theme_directory . '/partials/comment-edit-form',
['commentOrReply' => $reply]
)
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endif
@endforeach
</div>
@endif
The problem
When loading comments on page scroll via AJAX is disabled (I have an option in the “Settings” section of the CMS for that), for a reason I was unable to spot, not all the article comments and replies are loaded, although I have approved all comments related to the article I checked.
I suspect the bug is in the ArticlesController
, but I was unable to spot it.
This pat seems redundant because there is a get_commentQuery
method already:
// Approved top-level comments with approved replies
$comments = Comment::where('article_id', $article->id)
->where('approved', 1)
->whereNull('parent_id')
->with(['replies' => function ($query) {
$query->where('approved', 1);
}])
->orderBy('id', 'desc')
->get();
- What is the reason for this bug?
- What is the most robust way to fix it?