Milk Admin

Managing Related Content in Separate Pages

Revision: 2025/12/13

This guide demonstrates how to manage related content (Comments for Posts) in separate pages. Each comment list and edit form loads in a new page, not via AJAX.

Prerequisite: This guide extends the Posts Module Tutorial. Complete that tutorial first to have the Posts module ready.
Important: This example uses the same views (list_page.php and edit_page.php) for both posts and comments. The views are designed to be reusable for different content types.

Step 1: Create the CommentsModel

Create milkadmin/Modules/Posts/CommentsModel.php:

<?php
namespace Modules\Posts;
use App\Attributes\{Validate};
use App\Abstracts\AbstractModel;

class CommentsModel extends AbstractModel
{
    protected function configure($rule): void
    {
        $rule->table('#__comm')
            ->id()
            ->int('post_id')
            ->text('comment')->formType('editor');
    }
}

Explanation:

  • table('#__comm') - Table name for comments
  • int('post_id') - Foreign key to link comments to posts
  • text('comment')->formType('editor') - Comment content with rich text editor

Step 2: Register CommentsModel in PostsModule

Update milkadmin/Modules/Posts/PostsModule.php to register the CommentsModel:

<?php
namespace Modules\Posts;
use App\Abstracts\AbstractModule;

class PostsModule extends AbstractModule
{
    protected function configure($rule): void
    {
        $rule->page('posts')
             ->title('Posts')
             ->menu('Posts', '', 'bi bi-file-earmark-post-fill', 10)
             ->access('authorized')
             ->permissions(['access' => 'Access'])
             ->addModels(['comment'=>CommentsModel::class])
             ->version(251201);
    }

}

Explanation:

  • ->addModels(['comment'=>CommentsModel::class]) - Registers CommentsModel as an additional model accessible via the alias 'comment'

Step 3: Define hasMany Relationship in PostsModel

Update milkadmin/Modules/Posts/PostsModel.php to define the relationship:

<?php
namespace Modules\Posts;
use App\Attributes\{Validate};
use App\Abstracts\AbstractModel;

class PostsModel extends AbstractModel
{

    protected function configure($rule): void
    {
        $rule->table('#__posts')
            ->id()
            ->hasMany('comments', CommentsModel::class, 'post_id', 'CASCADE', false)
            ->title()->index()
            ->text('content')->formType('editor');

    }

    #[Validate('title')]
    public function validateTitle($current_record_obj): string {
        $value = $current_record_obj->title;
        if (strlen($value) < 5) {
            return 'Title must be at least 5 characters long';
        }
        return '';
    }

    protected function afterCreateTable(): void {
        $sql =  "INSERT INTO `".$this->table."` (`id`, `title`, `content`) VALUES
    (1, 'Post Title 1', 'Content of post 1'),
    (2, 'Post Title 2', 'Content of post 2'),
    (3, 'Post Title 3', 'Content of post 3');";
            $this->db->query($sql);
    }
}

Explanation:

  • ->hasMany('comments', CommentsModel::class, 'post_id', 'CASCADE', false) - Defines a one-to-many relationship:
    • 'comments' - Alias for accessing related comments
    • CommentsModel::class - The related model
    • 'post_id' - Foreign key in the comments table
    • 'CASCADE' - Delete all comments when a post is deleted
    • false - Cascade save disabled (comments managed separately)
Important - Relationship Positioning:

The hasMany() method must be placed immediately after the field it connects to. In this case:

  • ->id() creates the primary key field (posts.id)
  • ->hasMany(..., 'post_id', ...) declares that the comments table has a post_id field that links to this id
  • Therefore, hasMany() is placed immediately after ->id()

Step 4: Update PostsController with Comments Actions

Complete milkadmin/Modules/Posts/PostsController.php:

<?php
namespace Modules\Posts;
use App\Abstracts\AbstractController;
use App\{Response, Route};
use App\Attributes\AccessLevel;
use App\Attributes\RequestAction;
use Builders\{ListBuilder, FormBuilder, TableBuilder};

class PostsController extends AbstractController
{
    #[RequestAction('home')]
    public function postsList() {
        $tableBuilder = TableBuilder::create($this->model, 'idTablePosts')
            ->field('content')->truncate(50)
            ->field('title')->link('?page=posts&action=edit&id=%id%')
            ->field('comments')->label('Comments')->fn(function($row) {
                return count($row->comments);
            })
            ->addAction('comment',  [
                'label' => 'Comments',
                'link' => '?page=posts&action=comments&post_id=%id%'
            ])
            ->setDefaultActions();
        $response = array_merge($this->getCommonData(), $tableBuilder->getResponse());
        $response['title_btns'] = [['label'=>'Add New', 'link'=>'?page=posts&action=edit']];
        Response::render(__DIR__ . '/Views/list_page.php', $response);
    }

    #[RequestAction('edit')]
    public function postEdit() {
        $response = $this->getCommonData();
        $response['form'] = FormBuilder::create($this->model, $this->page)
        ->getForm();
        $response['title'] = ($_REQUEST['id'] ?? 0) > 0 ? 'Edit Post' : 'Add Post';
        Response::render(__DIR__ . '/Views/edit_page.php', $response);
    }

    #[RequestAction('comments')]
    public function postComments() {
        $response = $this->getCommonData();
        $post_id = $this->requirePostId();
        $model = $this->getAdditionalModels('comment');
        $post = $this->model->getById($post_id);
        $tableBuilder = TableBuilder::create($model, 'idTableComments')
            ->where('post_id = ?', [$post_id])
            ->addAction('edit', ['label'=>'Edit', 'link'=>"?page=posts&action=comment-edit&id=%id%&post_id=".$post_id])
            ->customData('post_id', $post_id);
        // Handle comments listing/editing for a specific post
        $response = array_merge($response, $tableBuilder->getResponse());
        $response['title_btns'] = [['label'=>'Add New', 'link'=>'?page=posts&action=comment-edit&post_id='.$post_id], ['label'=>'Go Back', 'link'=>'?page=posts']];
        $response['title'] = 'Post Comments';
        $response['description'] = 'Comments for post ' . $post->title;
        Response::render(__DIR__ . '/Views/list_page.php', $response);
    }

    #[RequestAction('comment-edit')]
    public function commentEdit() {
        $post_id = $this->requirePostId();
        $model = $this->getAdditionalModels('comment');
        $response = $this->getCommonData();
        $response['form'] = FormBuilder::create($model, $this->page, '?page=posts&action=comments&post_id=' . $post_id)
            ->customData('post_id', $post_id)
            ->addStandardActions(true, '?page=posts&action=comments&post_id=' . $post_id)
            ->field('post_id')
            ->value($post_id)
            ->readonly()
            ->field('comment')->required()
            ->getForm();
        $response['title'] = ($_REQUEST['id'] ?? 0) > 0 ? 'Edit Comment' : 'Add Comment';
        Response::render(__DIR__ . '/Views/edit_page.php', $response);
    }

    private function requirePostId(): int {
        $post_id = ($_REQUEST['post_id'] ?? '0');
        if ($post_id == 0) {
            if (Response::isJson()) {
                Response::json(['success'=>false, 'msg' => 'No post ID provided']);
            } else {
                Route::redirectError('?page=posts', 'No post ID provided');
            }
        }
        return (int)$post_id;
    }

}

Explanation:

postsList() Method

  • ->field('comments')->label('Comments')->fn(...) - Adds a column showing the number of comments for each post using the hasMany relationship
  • ->addAction('comment', [...]) - Adds a "Comments" button that links to the comments list page for that post

postComments() Method

  • #[RequestAction('comments')] - Routes to ?page=posts&action=comments&post_id=X
  • $this->requirePostId() - Validates that post_id is provided in the URL
  • $this->getAdditionalModels('comment') - Retrieves the CommentsModel instance registered in the module
  • ->where('post_id = ?', [$post_id]) - Filters comments to show only those for the current post
  • ->customData('post_id', $post_id) - Passes post_id to the delete action
  • Response::render(__DIR__ . '/Views/list_page.php', $response) - Uses the same list view as posts

commentEdit() Method

  • #[RequestAction('comment-edit')] - Routes to ?page=posts&action=comment-edit&id=X&post_id=Y
  • FormBuilder::create($model, $this->page, '?page=posts&action=comments&post_id=' . $post_id) - Creates form with redirect URL after save
  • ->customData('post_id', $post_id) - Ensures post_id is included in the form submission
  • ->addStandardActions(true, '?page=posts&action=comments&post_id=' . $post_id) - Adds Save and Cancel buttons with proper redirect
  • ->field('post_id')->value($post_id)->readonly() - Pre-fills and locks the post_id field
  • Response::render(__DIR__ . '/Views/edit_page.php', $response) - Uses the same edit view as posts

requirePostId() Helper Method

  • Validates that post_id parameter is present in the request
  • Redirects to posts list with error message if missing
  • Handles both JSON and HTML responses

Step 5: Reusing Views

The existing views Views/list_page.php and Views/edit_page.php are used for both posts and comments without modification.

How it works:

  • The controller passes different data to the same view templates
  • list_page.php receives $title, $title_btns, $description, and $html variables
  • edit_page.php receives $title and $form variables
  • The views render whatever content is passed to them, making them reusable

Step 6: Install the Comments Table

After creating the CommentsModel, create the database table:

php milkadmin/cli.php posts:update

This command will:

  • Add the #__comm table to the database
  • Create the id, post_id, and comment fields
  • Set up the CASCADE delete constraint on the post_id foreign key

Step 7: Testing the Implementation

Navigate to the Posts module and test the following workflow:

  1. View the posts list - you should see a "Comments" column showing the count
  2. Click the "Comments" button for a post - you'll be taken to a new page showing comments for that post
  3. Click "Add New" to create a comment - a new page loads with the comment form
  4. The post_id field is pre-filled and read-only
  5. Save the comment - you're redirected back to the comments list for that post
  6. Click "Go Back" to return to the posts list
  7. Delete a post - all its comments are automatically deleted (CASCADE)

Key Concepts

Additional Models:
  • Use ->addModels() to register multiple models within a single module
  • Access additional models in the controller with $this->getAdditionalModels('alias')
  • This allows managing related content without creating separate modules
Page-Based Navigation:
  • Each action loads a complete new page (not AJAX)
  • URLs follow the pattern: ?page=posts&action=comments&post_id=X
  • Post context is maintained via the post_id URL parameter
  • Navigation buttons ("Go Back") provide clear user flow
View Reusability:
  • Same view templates serve different content types
  • Controller prepares data and passes to generic views
  • Views use variables like $title, $html, $form regardless of content type
CASCADE Delete:
  • When a post is deleted, all its comments are automatically deleted
  • Defined in the hasMany relationship: 'CASCADE' parameter
  • Database foreign key constraint handles the deletion
Loading...