14
Php

Laravel – Nested Relationship Revised

Back in 2014, I’d posted an article about Model Self Relationship using the title Laravel – Model Relationship To Itself, where I tried to explain how one can make relationship using Eloquent ORM in Laravel to make relationship with a model to load the related models where the related model is the parent model itself. Sounds confusing? Let’s discuss about a real world example.

So, imagine that, you’ve a blog and blog has many articles/posts where readers can leave comments for posts and you want to allow the readers to reply on comments. So, when you’ll load a post to show it on your post page (post.single.blade.php) you would load the post with related comments with all the replies of each comment. To produce such a system we need a few models such as Post, Comment and User and we have to define relationship between these models and also we have to design the database accordingly.

Hence, this article is kind of a follow up post of my previous article Laravel – Model Relationship To Itself, here I’m trying to reproduce the system with code with a slight modification. Why I’m doing this, writing the same article using a different title because I’ve got some requests from some readers of my blog to post the code including the presentation logic and I did it on my previous post but everyone didn’t get the idea so mainly, I’m going to post the implementation of code here as a separate article so it won’t get messy. So let’s begin.

If you didn’t read the previous article then you don’t need to read that to understand this one but you may try that.
Our Requirements:

We need to create at least three tables: users, posts and comments. We only need to setup migrations for the posts table and comments table and we’ll leave users table because by default, Laravel comes with a users table which will serve the purpose here but you can modify the table according to your need. So let’s create the migration for posts table at first:


use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePostsTable extends Migration
{
    
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('user_id')->unsigned();
            $table->string('title');
            $table->string('body');
            $table->timestamps();
        });
    }

    
    public function down()
    {
        Schema::drop('posts');
    }
}

This is a very basic example of posts table to store the posts but you may add more fields to extend it’s functionality. The “user_id” field is a required field here so we can make relationship with users table to identify the posts of a particular user. Now create the migration class for comments table:


use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateCommentsTable extends Migration
{
    
    public function up()
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('post_id')->unsigned();
            $table->integer('user_id')->unsigned();
            $table->integer('parent_id')->unsigned()->default(0);
            $table->text('body');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::drop('comments');
    }
}

The comments table will store the comments of posts written by user. In this case, the “post_id” is a foreign key which will allow us to identify the post, the comment belongs to and the “user_id” is another foreign key which will tell us who made the comment. Also, notice the field “parent_id”, this will contain the id of a particular comment in the comments table if the comment is a reply of the comment. So, these fields are required as foreign keys. By default, the “comment_id” field will contain “0” unless it’s a reply to another comment.

Now, let’s create the models. Remember that, the User is also required and Laravel comes with the App\User model out of the box so we don’t need to create that model but we may need to add some methods in that. So, let’s create the Post model in “/app” (The default namespace is “App” for models).


namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model {

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

    public function parentComments()
    {
        return $this->comments()->where('parent_id', 0);
    }
}

Now let’s create the Comment model in the same location (“/app” folder) where both the Post model and the User model is stored.


namespace App;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model {

    public function replies()
    {
        return $this->hasMany(__CLASS__, 'parent_id');
    }

    public function allRepliesWithOwner()
    {
        return $this->replies()->with(__FUNCTION__, 'owner');
    }

    public function owner()
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

Okay! We’ve just created our models and declared the relationship methods so now we can try this out from anywhere, I’ll use an anonymous function within a route declaration for this example. So let’s declare a route to test it from a browser. To fetch a single post from the database by “posts.id” we may declare a route like one given below:

Route::get('/posts/{id}', function ($id)
{
    $post = App\Post::with([
    	'comments',
    	'parentComments.owner',
    	'parentComments.allRepliesWithOwner'
    ]);
    
    return view('post.single')->withPost(
    	$post->findOrFail($id)
    );
});

So, we need a template to generate the html response for the browser so let’s create a template inside “resources/views/post” folder and name it “single.blade.php”. The code for the template is given below and I’m not using any base layout file, just using a hardcoded html template for this example but you may use one layout and wrap the content within the “section” directive. So, the code may look something like the following:

@extends('layouts.master')

@section('content')
    

{{$post->title}}

{{$post->body}}

{{$count = $post->comments->count()}} Comments

@if($count && $post->relationLoaded('parentComments')) @include('post.comments', ['comments' => $post->parentComments]) @endif
@stop

We also need to create a partial template (comments.blade.php) to show the comment list so let’s create it inside “resources/views/post” folder and paste the following code inside that file:

@foreach($comments as $comment)
    
  • {{$comment->body}} - By - {{$comment->owner->name}}
  • @if($comment->relationLoaded('allRepliesWithOwner')) @include('post.comments', ['comments' => $comment->allRepliesWithOwner]) @endif
@endforeach

Now, if you did everything right and navigate to following URI: http://localhost:8000/posts/1 from your browser (Make sure the local development server is running, I’m using PHP‘s builtin development server) then, you may look something similar to this:

screen-shot-2016-09-15-at-5-00-13-am

Well, I’m seeing this in my browser because I’ve also seeded my database with some fake data (users, posts, comments) so make sure you have some data to test it out.

I owe you a little explanation:

The Post model and Comment model. The Post contains two relationship methods, while the comments method will load the comments of a post, including the child ones as a single collection (without any nesting) and the parentComments method will load only the parent comments (with “parent_id” of 0).

In the Comment model, we’ve three methods, the replies method will load all the replies (direct children) of a particular Comment, the allRepliesWithOwner method will load all the replies just like replies method by calling the replies method but it’ll also load the owner of the comment and the __FUNCTION__ within the with method will recursively call the same method (__FUNCTION__ will return the calling function name) again and again. So, the replies method is actually making the relationship with the Comment model itself because __CLASS__ pre-defined constant will return the class name which is “Comment” so actually we are writing something like this:

public function replies()
{
    return $this->hasMany("Comment", 'parent_id');
}

This method is making the actual relationship using the “parent_id” field of a comment. The owner method is making the relationship with the user model which will give us the user who wrote the comment. So, that’s it.

Now, let’s talk a little bit about the used code to fetch the Post and all the related replies and owner:

$post = App\Post::with([
    'comments', // comments() method to call count() method to get the count o all comments including replies
    'parentComments.owner', // parentComments()->owner() method to load the owner of a parent comment only
    'parentComments.allRepliesWithOwner' // load all the replies including their replies recursively and owner of each
]);

So, without the “parentComments.owner” we won’t get the “owner/User” objects of parent comments and “comments”is being used to get the count of all the comments of a Post so we can show the total comment count in the template, something like “8 Comments” in the template by calling $post->comments->count(). Finally the “parentComments.allRepliesWithOwner” is responsible to load all the nested comments/replies recursively.

Now, let’s check the templates, at first let’s check the “single.blade.php” file:

{{$post->title}}

{{$post->body}}

{{$count = $post->comments->count()}} Comments

@if($count && $post->relationLoaded('parentComments')) @include('post.comments', ['comments' => $post->parentComments]) @endif

In the above snippet, the main important part to print out the comment list is the last “if” statement, where we are checking, if $count variable is truthy because the $count = $post->comments->count() will give us zero or some number if comments exists, so if we’ve some comments then include the partial comment template and when we are including the partial comment template, we are also passing the comment list into the $comments variable using ['comments' => $post->parentComments] so we can use the $comments variable to iterate the comment list. Now check the partial:

@foreach($comments as $comment)
    
  • {{$comment->body}} - By - {{$comment->owner->name}}
  • @if($comment->relationLoaded('allRepliesWithOwner')) @include('post.comments', ['comments' => $comment->allRepliesWithOwner]) @endif
@endforeach

This is very strait forward, we are looping over the comments we sent from the “single.blade.php” file and if each comment $comment in the loop has it’s own allRepliesWithOwner as relation key loaded in the $comment object then include the same file within it so it’ll be included recursively. The “relationLoaded” tells whether the relationship collection is already loaded or not because if we just call $comment->relationLoaded, then the Eloquent will call the method directly from the view, I found this helpful but if you want to allow dynamic calls to your relationship method then remove the code and check the count instead using @if($comment->allRepliesWithOwner->count()). That’s all about it. I tried to explain it in short so please forgive me if I missed anything or made it less understandable. My intention was only to produce the code not any discussion but I tried a little in hurry. Hope you enjoyed it.

Latest Blog

0
Php

PHP – 8.0 Match Expression

In PHP 8.0 there is a new feature or I should say a new language construct (keyword) going to be introduced,  which has been implemented depending […]