connecteev's avatar

Using Laravel Eloquent to Return Data recursively from a table indexing into 2 tables - itself and another table

I'm not very adept with Eloquent so I hope someone more familiar with it can help.

I have a post and comments table (DB structure below). I am looking for a Laravel Eloquent way to return a tree of data containing the post being loaded and the recusrive tree of comments belonging to that post.

This is what I currently have in my API:

    public function show($authorSlug, $postSlug)
    {
        // Only return post with: status="published" AND is_removed_by_admin=false
        $returnedPost = Post::where([
            ['slug', '=', $postSlug],
        ])
        ;

        // Note: if you dont also return author_id here, the author object returned below will be null
        $returnedPost = $returnedPost
            ->select('id', 'title', 'slug', 'body', 'created_at', 'author_id')
            ->with([
                'comments' => function ($query) {
                    $query->where([
                        ['comment_on_type', '=', 'post']
                    ])->select('id', 'comment_on_post_id', 'comment_on_comment_id', 'body')
                        ->with(['comment_on_post', 'comment_on_comment']);
                },
            ]);

        // For posts on the user's profile page - change this to search by profile handle or username instead (currently using 'id')
        $returnedPost = $returnedPost->whereHas('author', function ($query) use ($authorSlug) {
            // Query the name field in author (users) table
            $query->where('id', $authorSlug);
        });

        // $returnedPost = $returnedPost->take(1)->get();
        $returnedPost = $returnedPost->firstOrFail();

        return new PostResource($returnedPost);
    }

With my API when I go to: http://localhost:8000/api/v1/open/posts/2/ut-eaque-laborum-dolore-vitae

The output Json currently looks like this https://jsonformatter.org/json-viewer/1cc007

I need to change this so that I get a tree of comments from any level, similar to what's seen here: https://dev.to/seattledataguy/the-interview-study-guide-for-software-engineers-764/comments or here (to see the sub-tree of a parent comment): https://dev.to/scottshipp/comment/fcmn or https://dev.to/eanx/comment/fep0

This is the DB table structure:


CREATE TABLE `posts` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `slug` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `body` longtext COLLATE utf8mb4_unicode_ci NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  `deleted_at` timestamp NULL DEFAULT NULL,
  `author_id` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `author_fk_272473` (`author_id`),
  CONSTRAINT `author_fk_272473` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=401 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `comments` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `comment_on_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `body` longtext COLLATE utf8mb4_unicode_ci NOT NULL,
  `user_id` int(10) unsigned NOT NULL,
  `comment_on_post_id` int(10) unsigned DEFAULT NULL,
  `comment_on_comment_id` int(10) unsigned DEFAULT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  `deleted_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `comment_on_post_fk_376132` (`comment_on_post_id`),
  KEY `comment_on_comment_fk_376133` (`comment_on_comment_id`),
  CONSTRAINT `comment_on_comment_fk_376133` FOREIGN KEY (`comment_on_comment_id`) REFERENCES `comments` (`id`),
  CONSTRAINT `comment_on_post_fk_376132` FOREIGN KEY (`comment_on_post_id`) REFERENCES `posts` (`id`),
  CONSTRAINT `user_fk_369255` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1401 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;


0 likes
5 replies
lostdreamer_nl's avatar
Level 53

Make your comment model a morphable like the example in the documentation: https://laravel.com/docs/5.7/eloquent-relationships#one-to-many-polymorphic-relations

This way you can do something like:

$post = Post::with('comments.comments.comments')->find(3);
dd($post->toArray());

And it will have all comments on this post, all comments on those comments, and all comments on those comments (3 levels deep with 4 queries, 1 for the post and 3 for all nested comments)

1 like
connecteev's avatar

@lostdreamer_nl thank you, I didn't know about morphTo and morphMany, this is definitely a step in the right direction.

Does this return up to 3 levels of comments to post with ID=1? Is there an eloquent way to retrieve an unlimited tree of comments?

$post = Post::with('comments.comments.comments')->find(1);

Doing

        $post = Post::with('comments.comments.comments.comments.comments.comments.comments.comments')->find(1);

doesnt make much sense.

Also, each of my posts and comments can have one or more 'likes' (it is similar to 'comments' in that it is also polymorphic).

How can I retrieve a post with:

  1. Unlimited nesting of comments
  2. For each level of comments, retrieve the likes to that comment from the likes table.
  3. likes to the post from the likes table

This doesn't quite work, I don't get the likes data back:

New attempt (your method, using commentable_id and commentable_type):

        $post = Post::with('comments.comments.comments')->with('likes')->find(1);

Older attempt (using a different method, neither of them work):

        // Note: if you dont also return author_id here, the author object returned below will be null
        $returnedCommentTree = $returnedCommentTree
            ->select('id', 'comment_hash', 'comment_on_type', 'comment_on_post_id', 'comment_on_comment_id', 'user_id', 'body', 'created_at')
            ->with([
                // Note: MUST also return the author_id field above
                'user' => function ($query) {
                    $query->where('deleted_at', NULL)
                        ->select('id', 'name');
                },

                'childrenComments' => function ($query) {
                    $query->where([
                        ['is_removed', '=', '0'],
                        ['deleted_at', '=', NULL],
                    ])
                        ->select('id', 'comment_hash', 'comment_on_type', 'comment_on_post_id', 'comment_on_comment_id', 'user_id', 'body', 'created_at')
                        ->with([
                            // Note: MUST also return the author_id field above
                            'user' => function ($query) {
                                $query->where('deleted_at', NULL)
                                    ->select('id', 'name');
                            },
                            'likes'
                        ]);
                },

                'likes'
            ]);

lostdreamer_nl's avatar

ok, now you are going into 'infinite nesting'.

Basically there are 2 ways:

  • using parent_id on the children (parent child)
  • using left / right on the parent (Nested set model )

(Google for "nested set model" to get more info on it)

They both have their pros and cons, the most important ones are:

  • parent child is fast with inserting new data, but slow with getting all the data back
  • Nested set is the other way around: adding children or moving them will get slower with more data, but getting all nested data is very fast.

It's up to you to decide which version is the best for your project (how many views vs how many data is being added)

There is ofcourse also the way of only getting the first layer of comments including the count of their comments and their likes. Now you can show the first comments, and a 'show replies' button underneath them. Clicking that button would trigger an ajax call to get the comments of this comment (and their comment count + likes),

1 like
connecteev's avatar

@lostdreamer_nl thanks....those approaches are interesting (parent-child vs nested set). This is what I have so far...based on your last suggestion. My application is going to be heavier on reads (versus writes/updates). Would you recommend any changes to this to speed things up?

I created a polymorphic 'comments' table as you suggested.

This is my comments table schema.

CREATE TABLE `comments` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `comment_hash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `body` longtext COLLATE utf8mb4_unicode_ci NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `commentable_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `commentable_id` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `comment_user_hash_parentid_unique` (`user_id`,`comment_hash`,`commentable_id`),
  KEY `comment_hash_index` (`comment_hash`),
  CONSTRAINT `user_fk_369255` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2048 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

This is a snippet from my Comments.php model:

class Comment extends Model
{
    use SoftDeletes;

    public $table = 'comments';


    /**
     * Get the owning commentable model.
     */
    public function commentable()
    {
        return $this->morphTo();
    }

    /**
     * Get all of the comments (single-level, not recursively)
     */
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    /**
     * Get all of the comments' comments recursively.
     */
    public function nestedComments()
    {
        // Get comments recursively, from https://laraveldaily.com/eloquent-recursive-hasmany-relationship-with-unlimited-subcategories/
        return $this->morphMany(Comment::class, 'commentable')->with('nestedComments');
    }

And this is my API / Controller code:


$max_top_level_comments = 20;

$post =
    Post::with([
        'comments' => function ($query) use ($max_top_level_comments) {
            $query
                ->where([
                    ['is_removed', '=', '0'],
                    ['deleted_at', '=', NULL],
                ])
                ->select('id', 'comment_hash', 'body', 'user_id', 'commentable_id', 'commentable_type', 'created_at')
                ->with([
                    'nestedComments' => function ($query) {
                        $query
                            ->where([
                                ['is_removed', '=', '0'],
                                ['deleted_at', '=', NULL],
                            ])
                            ->select('id', 'comment_hash', 'body', 'user_id', 'commentable_id', 'commentable_type', 'created_at');
                    },
                ])
                ->take($max_top_level_comments)
                ->get();
        },

    ])
    ->find(1);


Please or to participate in this conversation.