What causes the failure to display all article comments in my Laravel 8 blogging application?

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();
  1. What is the reason for this bug?
  2. What is the most robust way to fix it?