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.
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 commentsint('post_id')- Foreign key to link comments to poststext('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 commentsCommentsModel::class- The related model'post_id'- Foreign key in the comments table'CASCADE'- Delete all comments when a post is deletedfalse- Cascade save disabled (comments managed separately)
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 actionResponse::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=YFormBuilder::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 fieldResponse::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.phpreceives$title,$title_btns,$description, and$htmlvariablesedit_page.phpreceives$titleand$formvariables- 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
#__commtable 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:
- View the posts list - you should see a "Comments" column showing the count
- Click the "Comments" button for a post - you'll be taken to a new page showing comments for that post
- Click "Add New" to create a comment - a new page loads with the comment form
- The post_id field is pre-filled and read-only
- Save the comment - you're redirected back to the comments list for that post
- Click "Go Back" to return to the posts list
- Delete a post - all its comments are automatically deleted (CASCADE)
Key Concepts
- 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
- 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
- Same view templates serve different content types
- Controller prepares data and passes to generic views
- Views use variables like
$title,$html,$formregardless of content type
- 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