CodeIgniter 4 MySQL AJAX Nested Comment System

The example CodeIgniter 4 MySQL AJAX nested comment system will show you how to build a threaded comment system using PHP based web framework CodeIgniter 4, MySQL 8 and jQuery with AJAX technique. This nested comment system is also called hierarchical comment system.

This threaded or nested comment system in PHP AJAX accepts reply up to maximum of five level depth. If you want to accept unlimited level or customize level of depth, then you can modify the source code according to your needs.

For the first time, when users put comments about an article/blog/tutorial then all comments made by individual user are kept at the same level. So, there is no problem. But when users reply to comments then each level of reply should be clearly visible by introducing tab space or some space to the left of the reply. This way it will help all users to understand which reply belongs to which comment. That’s why we need threaded comment or nested comment.

Related Posts:

Prerequisites

PHP 7.4.8, CodeIgniter 4.1.4, MySQL 8.0.26, jQuery

Project Setup

It’s assumed that you have already setup PHP and CodeIgniter in Windows system. Now I will create a project root directory called codeigniter-mysql-nested-comments anywhere in the system.

Now move all the directories and files from CodeIgniter framework into the project root directory.

I may not mention the project root directory in subsequent sections, and I will assume that I am talking with respect to the project root directory.

codeigniter 4 threaded comment system

MySQL Table

For this example, I am going to create two tables called blog and blog_comment under roytuts database. The structures of tables are given below:

CREATE TABLE `blog` (
  `blog_id` int unsigned COLLATE utf8mb4_unicode_ci NOT NULL AUTO_INCREMENT,
  `blog_title` varchar(225) COLLATE utf8mb4_unicode_ci NOT NULL,
  `blog_slug` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `blog_text` text COLLATE utf8mb4_unicode_ci NOT NULL,
  PRIMARY KEY (`blog_id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `blog_comment` (
  `comment_id` int unsigned COLLATE utf8mb4_unicode_ci NOT NULL AUTO_INCREMENT,
  `comment_text` text COLLATE utf8mb4_unicode_ci NOT NULL,
  `comment_date` timestamp COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `parent_id` int unsigned COLLATE utf8mb4_unicode_ci NOT NULL,
  `blog_id` int unsigned COLLATE utf8mb4_unicode_ci NOT NULL,
  PRIMARY KEY (`comment_id`,`blog_id`),
  KEY `fk_blog_comment_blog1_idx` (`blog_id`),
  CONSTRAINT `fk_blog_comment_blog1` FOREIGN KEY (`blog_id`) REFERENCES `blog` (`blog_id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

I am only going to dump sample content into the blog table and I will keep blog_comment table empty. So when I am going to post comment then this table will be populated.

The following data I am inserting in the blog table otherwise I won’t get the reply form for the blog.

INSERT INTO blog (blog_id, blog_title, blog_slug, blog_text) VALUES (1,'test blog','test-blog','The topic of blogging seems to come up a lot in our social media training workshops. The benefits of a quality blog are obvious – fresh content is good for your readers and your rankings. Blogs are easy to set up and even easier to update. We often tell people that if they can use Microsoft Word… they can update a blog.rnrn                        As easy as they are to set up, they can be difficult to maintain. A good blog is filled with relevant, timely content that is updated on a regular basis. New bloggers often start out with a bang but then fizzle out when they realize that creating content can be challenging.'), (2, 'another blog', 'another-blog', 'content');

Database Configuration

You need to setup database connection in order to fetch or write data to the table. The following configuration is done in the file app/Config/Database.php under the default group of database setting. Make sure you change or update the configuration according to yours. You can also setup your database using the .env file.

The main properties I have shown below, and you can change according to your values:

...
'username' => 'root',
'password' => 'root',
'database' => 'roytuts',
...
'charset'  => 'utf8mb4',
'DBCollat' => 'utf8mb4_unicode_ci',
...

Helper Function

I am going to create two helper functions which will be used in the application. The helper function will be written in the file custom_helper.php file under the folder /app/Helpers.

<?php

if (!function_exists("my_each")) {
	function my_each(&$arr) {
		$key = key($arr);
		$result = ($key === null) ? false : [$key, current($arr), 'key' => $key, 'value' => current($arr)];
		next($arr);
		return $result;
	}
}

if (!function_exists('mysql_to_php_date')) {

    function mysql_to_php_date($mysql_date) {
        $datetime = strtotime($mysql_date);
        $format = date("F j, Y, g:i a", $datetime);
        return $format;
    }

}

The my_each() function is a custom each function as each() function from PHP has been deprecated in PHP version 7.2.0 or later.

The mysql_to_php_date() function will convert the MySQL date to its equivalent PHP date.

It is always good idea to check whether a function with the same name already exists or not otherwise PHP will throw error saying that the function with same name already exists.

Model Class

The model class is responsible for interacting with database and performing various database activities. The following source code is written into app/Models/BlogModel.php file.

In this model class I will create different functions for retrieving blog details and comments for a blog and also I will store comments posted by users. These functions will be called from controller class. I am not going to perform any database operations directly from controller class.

<?php

namespace App\Models;
use CodeIgniter\Model;

class BlogModel extends Model {

	private $blog = 'blog';
    private $blog_comment = 'blog_comment';
	
	//get blog details
    function get_blog_detail($blog_slug) {
        $query = $this->db->table($this->blog)->where('blog_slug', $blog_slug)->get();
		
        return $query->getRow();
    }

    //get blog comments for blog slug
    function get_blog_comments($blog_slug) {
        $query = $this->db->query('SELECT bc.comment_id, bc.blog_id, bc.parent_id, bc.comment_text, 
                    bc.comment_date FROM ' . $this->blog_comment . ' bc, ' . $this->blog . ' b
                    WHERE bc.blog_id=b.blog_id AND 
                        b.blog_slug=' . $this->db->escape($blog_slug) .
                ' ORDER BY bc.comment_date DESC');
				
        if ($query->resultID->num_rows > 0) {
            $items = array();
			
            foreach ($query->getResult() as $row) {
                $items[] = $row;
            }
			
			helper("custom");
			
            //return $items;
            $comments = $this->format_comments($items);
			
            return $comments;
        }
		
        return '<ul class="comment"></ul>';
    }

    //add blog comment
    function add_blog_comment($data) {
        $this->db->table($this->blog_comment)->insert($data);
        $inserted_id = $this->db->insertID();
		
        if ($inserted_id > 0) {
            $query = $this->db->query('SELECT bc.comment_id, bc.blog_id, bc.parent_id, bc.comment_text, 
                    bc.comment_date
                    FROM ' . $this->blog_comment . ' bc
                    WHERE bc.comment_id=' . $inserted_id);
            return $query->getResult();
        }
        return NULL;
    }

    //format comments for display on blog and article	
    private function format_comments($comments) {
        $html = array();
        $root_id = 0;
        foreach ($comments as $comment)
            $children[$comment->parent_id][] = $comment;

        // loop will be false if the root has no children (i.e., an empty comment!)
        $loop = !empty($children[$root_id]);

        // initializing $parent as the root
        $parent = $root_id;
        $parent_stack = array();

        // HTML wrapper for the comment (open)
        $html[] = '<ul class="comment">';
		
		//while ($loop && ( ( $option = each($children[$parent]) ) || ( $parent > $root_id ) )) { //PHP < 7.2
		while ($loop && ( ( $option = my_each($children[$parent]) ) || ( $parent > $root_id ) )) { //PHP 7.2+
            if ($option === false) {
                $parent = array_pop($parent_stack);

                // HTML for comment item containing childrens (close)
                $html[] = str_repeat("\t", ( count($parent_stack) + 1 ) * 2) . '</ul>';
                $html[] = str_repeat("\t", ( count($parent_stack) + 1 ) * 2 - 1) . '</li>';
            } elseif (!empty($children[$option['value']->comment_id])) {
                $tab = str_repeat("\t", ( count($parent_stack) + 1 ) * 2 - 1);

                // HTML for comment item containing childrens (open)
                $html[] = sprintf(
                        '%1$s<li id="li_comment_%2$s">' .
                        '%1$s%1$s<div><span class="comment_date">%3$s</span></div>' .
                        '%1$s%1$s<div style="margin-top:4px;">%4$s</div>' .
                        '%1$s%1$s<a href="#" class="reply_button" id="%2$s">reply</a>', $tab, // %1$s = tabulation
                        $option['value']->comment_id, //%2$s id
                        $option['value']->comment_text, // %4$s = comment
                        mysql_to_php_date($option['value']->comment_date) // %5$s = comment created_date
                );
                //$check_status = "";
                $html[] = $tab . "\t" . '<ul class="comment">';

                array_push($parent_stack, $option['value']->parent_id);
                $parent = $option['value']->comment_id;
            } else {
                // HTML for comment item with no children (aka "leaf") 
                $html[] = sprintf(
                        '%1$s<li id="li_comment_%2$s">' .
                        '%1$s%1$s<div><span class="comment_date">%3$s</span></div>' .
                        '%1$s%1$s<div style="margin-top:4px;">%4$s</div>' .
                        '%1$s%1$s<a href="#" class="reply_button" id="%2$s">reply</a>' .
                        '%1$s</li>', str_repeat("\t", ( count($parent_stack) + 1 ) * 2 - 1), // %1$s = tabulation
                        $option['value']->comment_id, //%2$s id
                        $option['value']->comment_text, // %4$s = comment
                        mysql_to_php_date($option['value']->comment_date) // %5$s = comment created_date
                );
            }
        }

        // HTML wrapper for the comment (close)
        $html[] = '</ul>';
        return implode("\r\n", $html);
    }
	
}

The function get_blog_detail() will fetch blog details for the given blog slug.

The function get_blog_comments() will fetch all comments for the given blog slug. It calls the function format_comments() to format the nested or threaded comments which will be displayed in the UI. The function format_comments() calls the helper functions my_each() and mysql_to_php_date().

The function add_blog_comment() is used to store comment in the blog_comment table.

Controller Class

The controller class is responsible for handling requests and responses coming from clients or end users.
The controller class performs the business logic for the application. The controller class is also responsible for validating, sanitizing, filtering the malformed request data before the data can be processed further for the application requirements.

The following code is written into the file app/Controllers/Blog.php.

<?php

namespace App\Controllers;

use App\Models\BlogModel;

class Blog extends BaseController {
	
    public function index() {
        $model = new BlogModel();
		
        $data['blog_detail'] = $model->get_blog_detail('test-blog'); //blog slug should not be hardcoded
        $data['blog_comments'] = $model->get_blog_comments('test-blog'); //blog slug should not be hardcoded
		
        return view('blog-details', $data);
    }

    function add_blog_comment() {
		if('post' === $this->request->getMethod() && $this->request->getPost('comment_text')) {
            $blog_id = $this->request->getPost('content_id');
            $parent_id = $this->request->getPost('reply_id');
            $comment_text = $this->request->getPost('comment_text');
            $data = array(
                'comment_text' => $comment_text,
                'parent_id' => $parent_id,
                'comment_date' => date('Y-m-d h:i:sa'),
                'blog_id' => $blog_id
            );
			
			$model = new BlogModel();
			
            $resp = $model->add_blog_comment($data);
			
			helper("custom");
			
            if ($resp != NULL) {
                foreach ($resp as $row) {
                    $date = mysql_to_php_date($row->comment_date);
                    echo "<li id=\"li_comment_{$row->comment_id}\">" .
                    "<div><span class=\"comment_date\">{$date}</span></div>" .
                    "<div style=\"margin-top:4px;\">{$row->comment_text}</div>" .
                    "<a href=\"#\" class=\"reply_button\" id=\"{$row->comment_id}\">reply</a>" .
                    "</li>";
                }
            } else {
                echo 'Error in adding comment';
            }
        } else {
            echo 'Error: Please enter your comment';
        }
    }
	
}

The index() function displays the blog details and all comments and nested replies on the UI (User Interface).
The add_blog_comment() function gets input data through AJAX request and saves to the blog_comment table by calling the model function add_blog_comment(). Finally the saved comment is sent in HTML format to the UI.

View File

The view or template file is responsible for displaying data to the end user.

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Nested Comment using Codeigniter 4, MySQL 8, AJAX</title>
	<meta name="description" content="The small framework with powerful features">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<link rel="shortcut icon" type="image/png" href="/favicon.ico"/>
	
	<!--[if IE]> <script> (function() { var html5 = ("abbr,article,aside,audio,canvas,datalist,details," + "figure,footer,header,hgroup,mark,menu,meter,nav,output," + "progress,section,time,video").split(','); for (var i = 0; i < html5.length; i++) { document.createElement(html5[i]); } try { document.execCommand('BackgroundImageCache', false, true); } catch(e) {} })(); </script> <![endif]-->
	<link rel="stylesheet" href="/css/comments.css"/>
</head>
<body>
	<div style="width: 800px; margin: auto;">
            <div class='fullpost clearfix'>
                <div class='entry'>
                    <h1 class='post-title'>
                        <?php echo $blog_detail->blog_title; ?>
                    </h1>
                    <div> </div>
                    <div class="entry">
                        <p><?php echo $blog_detail->blog_text; ?></p>
                    </div>
                    <div> </div>
                    <div style="width: 600px;">
                        <div id="comment_wrapper">
                            <div id="comment_form_wrapper">
                                <div id="comment_resp"></div>
                                <h4>Please Leave a Reply<a href="javascript:void(0);" id="cancel-comment-reply-link">Cancel Reply</a></h4>
                                <form id="comment_form" name="comment_form" action="" method="post">
                                    <div>
                                        Comment<textarea name="comment_text" id="comment_text" rows="6"></textarea>
                                    </div>
                                    <div>
                                        <input type="hidden" name="content_id" id="content_id" value="<?php echo $blog_detail->blog_id; ?>"/>
                                        <input type="hidden" name="reply_id" id="reply_id" value=""/>
                                        <input type="hidden" name="depth_level" id="depth_level" value=""/>
                                        <input type="submit" name="comment_submit" id="comment_submit" value="Post Comment" class="button"/>
                                    </div>
                                </form>
                            </div>
                            <?php
                            echo $blog_comments;
                            ?>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
	
	<script src="https://code.jquery.com/jquery-3.6.0.min.js" crossorigin="anonymous"></script>
	<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" crossorigin="anonymous"></script>
	<script src="/js/jquery-blockUI.js"></script>
	<script type= 'text/javascript' src="/js/blog-comments.js"></script>
</body>
</html>

The thumb of rule is that you should include your css (style sheets) in the head section of the page and javascript/jquery in the footer section.

In CodeIgniter 4, you should place your css/js files under the public folder of your project’s root directory.

Validating and Adding Comments – jQuery and AJAX

Jquery and AJAX code that send the request to the server side while comment is posted by a user.
This jquery code validates inputs given by users on comment form and accordingly appends nested comment to the parent and display without page refresh.

Create a JavaScript (js) file called js/blog-comments.js under public folder of project root directory with below code:

$(function () {
    $("#cancel-comment-reply-link").hide();
	$(".reply_button").on('click', function(event) {
        event.preventDefault();
        var id = $(this).attr("id");
		if ($("#li_comment_" + id).find('ul').length > 0) {
            $("#li_comment_" + id + " ul:first").prepend($("#comment_form_wrapper"));
        } else {
            $("#li_comment_" + id).append($("#comment_form_wrapper"));
        }
        $("#reply_id").attr("value", id);
        $("#cancel-comment-reply-link").show();
    });

    $("#cancel-comment-reply-link").bind("click", function (event) {
        event.preventDefault();
        $("#reply_id").attr("value", "");
        $("#comment_wrapper").prepend($("#comment_form_wrapper"));
        $(this).hide();
    });

    $("#comment_form").bind("submit", function (event) {
        event.preventDefault();
        if ($("#comment_text").val() == "") {
            alert("Please enter your comment");
            return false;
        }
        $.ajax({
            type: "POST",
            url: "http://localhost:8080/index.php/blog/add_blog_comment",
            data: $('#comment_form').serialize(),
            dataType: "html",
            beforeSend: function () {
                $('#comment_wrapper').block({
                    message: 'Please wait....',
                    css: {
                        border: 'none',
                        padding: '15px',
                        backgroundColor: '#ccc',
                        '-webkit-border-radius': '10px',
                        '-moz-border-radius': '10px'
                    },
                    overlayCSS: {
                        backgroundColor: '#ffe'
                    }
                });
            },
            success: function (comment) {
                var reply_id = $("#reply_id").val();
                if (reply_id == "") {
                    $("#comment_wrapper ul:first").prepend(comment);
                    if (comment.toLowerCase().indexOf("error") >= 0) {
                        $("#comment_resp_err").attr("value", comment);
                    }
                }
                else {
                    if ($("#li_comment_" + reply_id).find('ul').length > 0) {
                        $("#li_comment_" + reply_id + " ul:first").prepend(comment);
                    }
                    else {
                        $("#li_comment_" + reply_id).append('<ul class="comment">' + comment + '</ul>');
                    }
                }
                $("#comment_text").attr("value", "");
                $("#reply_id").attr("value", "");
                $("#cancel-comment-reply-link").hide();
                $("#comment_wrapper").prepend($("#comment_form_wrapper"));
                $('#comment_wrapper').unblock();
            }
        });
    });
});

Adding Styles

Add some styles to the nested comment system. Create css/comments.css file under the public folder of project’s root directory.

#comment_wrapper {
    font-family:Verdana, Geneva, sans-serif;
    width:100%;
}

#comment_form_wrapper {
    margin: 12px 12px 12px 12px;
    padding: 12px 0px 12px 12px; /* Note 0px padding right */
    background-color: #ebefee;
    border: thin dotted #39C;

}

#comment_form textarea {
    width: 93%;
    background: white;
    border: 4px solid #EEE;
    -moz-border-radius: 5px;
    border-radius: 5px;
    padding: 10px;
    font-family:Verdana, Geneva, sans-serif;
    font-size:14px;
}

#comment_resp_err{
    color: red;
    font-size: 13px;
}

ul.comment {
    width: 100%;
    /*    margin: 12px 12px 12px 0px;
        padding: 3px 3px 3px 3px;*/
}

ul.comment li {
    margin: 12px 12px 12px 12px;
    padding: 12px 0px 12px 12px; /* Note 0px padding right */
    list-style: none;             /* no glyphs before a list item */
    background-color: #ebefee;
    border: thin dotted #39C;
}

ul.comment li span.commenter {
    font-weight:bold;
    color:#369;
}

ul.comment li span.comment_date {
    color:#666;
}

#comment_wrapper .button,#comment_wrapper .reply_button {
    background: none repeat scroll 0 0 #5394A8;
    color: #FFFFFF;
    float: right;
    font-size: 10px;
    font-weight: bold;
    margin: -10px 5px ;
    padding: 3px 10px;
    text-transform: uppercase;
    text-decoration: none;
    cursor: pointer;
    border: 1px solid #369;
}
#comment_wrapper #comment_submit {
    float:none;
    margin: 0px 5px ;
}

#comment_wrapper .button:hover, #comment_wrapper .reply_button:hover {
    background: none repeat scroll 0 0 #069;
    text-decoration: underline;	
}
#cancel-comment-reply-link {
    color: #666;
    margin-left: 10px;
    margin-right:10px;
    text-decoration: none;
    font-size: 10px;
    font-weight: normal;
    float:right;
    text-transform: uppercase;
}

#cancel-comment-reply-link:hover{
    text-decoration: underline;
}

Route Configuration

You also need to configure route to point to your own controller file instead of the default controller that comes with the framework.

Search for the line $routes->setDefaultController('Home'); and replace it by $routes->setDefaultController('Blog');.
Search for the line $routes->get('/', 'Home::index'); and replace it by your controller name, for this example, $routes->get('/', 'Blog::index');.

These route configurations are done on the file app/Config/Routes.php.

Testing the Application

I am not going to use any external server but CLI command to run the application. Make sure you start the MySQL database server before you start your application. If you want to use external server to run your application you can use. Execute the following command on your project root directory to run your application.

php spark serve

Your application will be running on localhost and port 8080.

The URL http://localhost:8080/ will show you the following page on the browser.

codeigniter mysql nested comments

If you do not enter any comment and try to submit then you will see the following error alert message:

codeigniter mysql nested comments

When you type your comment and click on POST COMMENT button, you will see the waiting message and your comment will be displayed:

codeigniter mysql nested comments

Now when you click on the REPLY button for First Level Comment, you will see that the reply form gets wrapped with this comment:

codeigniter mysql nested comments

Now write Second Level Comment and submit and the following output is displayed:

codeigniter mysql nested comments

For each comment submission your UI will be blocked until processing is finished:

codeigniter mysql nested comments

Finally you would see the following output in the page:

codeigniter mysql nested comments

Hope you got an idea hot to build nested comments system using CodeIgniter 4 MySQL and AJAX.

Source Code

Download

4 thoughts on “CodeIgniter 4 MySQL AJAX Nested Comment System

Leave a Reply

Your email address will not be published. Required fields are marked *