Managing Related Content with Extensions
Revision: 2025/12/21
This guide demonstrates how to create a modular extension system for managing related content using the Extensions architecture. Extensions allow you to organize related functionality into separate, reusable components that extend different parts of your module.
1. What are Extensions?
Extensions are modular components that extend the functionality of Abstract classes (Model, Controller, Module, GetDataBuilder, etc.) in MilkAdmin. They allow you to:
- Organize related functionality into separate folders
- Keep your main module classes clean and focused
- Reuse common functionality across multiple modules
- Extend models, controllers, and builders with additional capabilities
2. Extension Structure
An extension lives in a dedicated folder and can contain multiple files, each extending a different abstract class:
milkadmin/Modules/Recipe/Extensions/Comments/
├── Module.php # Extends AbstractModuleExtension
├── Model.php # Extends AbstractModelExtension
├── Controller.php # Extends AbstractControllerExtension
├── GetDataBuilder.php # Extends AbstractGetDataBuilderExtension
├── Service.php # Helper class (not an extension)
└── CommentsModel.php # Model class for comments table
3. Abstract Extension Classes
MilkAdmin provides several abstract classes for creating extensions:
| Abstract Class | Purpose | Key Methods |
|---|---|---|
| AbstractModuleExtension | Extends module configuration and lifecycle | configure(), bootstrap(), init() |
| AbstractModelExtension | Adds fields and behavior to models | configure(), onAttributeMethodsScanned() |
| AbstractControllerExtension | Adds controller actions | onInit(), onHookInit(), onHandleRoutes() |
| AbstractGetDataBuilderExtension | Extends TableBuilder/FormBuilder | configure(), beforeGetData(), afterGetData() |
4. Creating the Comments Extension
We'll create a Comments extension for the Recipe module. This extension will manage comments using the same fetch-based approach, but organized in a modular structure.
Step 1: Create the Extension Folder
Create the folder structure:
mkdir -p milkadmin/Modules/Recipe/Extensions/Comments
Step 2: Create CommentsModel
Create milkadmin/Modules/Recipe/Extensions/Comments/CommentsModel.php:
<?php
namespace Modules\Recipe\Extensions\Comments;
use App\Abstracts\AbstractModel;
use App\{Hooks, Get};
use App\Attributes\{ToDisplayValue, ToDatabaseValue};
!defined('MILK_DIR') && die();
class CommentsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipes_comments')
->id()
->int('recipe_id')
->formType('hidden')
->hideFromList()
->label('Recipe ID')
->text('comment')
->label('Comment')
->required();
}
}
Key points:
- Namespace:
Modules\Recipe\Extensions\Comments- follows the convention - Table:
#__recipes_comments- hardcoded for this specific module - Foreign key:
recipe_id- links to the recipes table - This is a standard AbstractModel, not an extension class
Step 3: Create Module Extension
Create milkadmin/Modules/Recipe/Extensions/Comments/Module.php:
<?php
namespace Modules\Recipe\Extensions\Comments;
use App\Abstracts\{AbstractModuleExtension, ModuleRuleBuilder};
!defined('MILK_DIR') && die();
class Module extends AbstractModuleExtension
{
public function configure(ModuleRuleBuilder $rule_builder): void
{
// Register CommentsModel as an additional model
// This allows the update command to create/update the comments table
$rule_builder->addModels(['comment' => CommentsModel::class]);
}
}
Understanding AbstractModuleExtension:
- Extends
AbstractModuleExtension- provides module extension capabilities - Receives
ModuleRuleBuilder $rule_builderin the configure() method configure()is called during the module's configuration phase->addModels()registers CommentsModel so the CLI update command creates its table- The Module extension also makes Controller extensions available automatically
Step 4: Create Model Extension
Create milkadmin/Modules/Recipe/Extensions/Comments/Model.php:
<?php
namespace Modules\Recipe\Extensions\Comments;
use App\Abstracts\{AbstractModelExtension, RuleBuilder};
use App\{Get, Hooks};
!defined('MILK_DIR') && die();
class Model extends AbstractModelExtension
{
protected $foreign_key = 'entity_id';
protected $entity_label = 'Entity';
protected $comment_field = 'comment';
public function configure(RuleBuilder $rule_builder): void
{
// Add hasMany relationship to comments
$rule_builder
->ChangeCurrentField($rule_builder->getPrimaryKey())
->hasMany('comments', CommentsModel::class, 'recipe_id');
}
}
Understanding AbstractModelExtension:
- Extends
AbstractModelExtension- provides model extension capabilities - Receives
RuleBuilder $rule_builderin the configure() method configure()is called after the RecipeModel's configure() method->ChangeCurrentField($rule_builder->getPrimaryKey())- positions on the ID field->hasMany('comments', CommentsModel::class, 'recipe_id')- adds the relationship:'comments'- property name for accessing comments ($recipe->comments)CommentsModel::class- the related model'recipe_id'- foreign key in the comments table
- The extension can also use
onAttributeMethodsScanned()to register custom handlers
Step 5: Create Service Class
Create milkadmin/Modules/Recipe/Extensions/Comments/Service.php:
<?php
namespace Modules\Recipe\Extensions\Comments;
use Builders\{TableBuilder, FormBuilder, TitleBuilder, SearchBuilder};
use App\Response;
use App\Abstracts\AbstractModel;
!defined('MILK_DIR') && die();
class Service
{
public static function getEntityRecord(AbstractModel $model): AbstractModel
{
$entity_id = $_POST['data']['entity_id'] ?? $_REQUEST['entity_id'] ?? 0;
if ($entity_id == 0) {
Response::json(['success' => false, 'msg' => 'Entity ID not provided']);
}
$entity = $model->getById($entity_id);
if ($entity->isEmpty()) {
Response::json(['success' => false, 'msg' => 'Entity not found']);
}
return $entity;
}
public static function getCommentsOffcanvasHtml(AbstractModel $entity): string
{
$primary_id = $entity->getPrimaryKey();
$entity_id_value = $entity->$primary_id;
$entity_name = $entity->name ?? $entity->title ?? $entity->label ?? "Entity #{$entity_id_value}";
$title = TitleBuilder::create($entity_name)
->addButton(
'New Comment',
'?page=' . self::getPageFromRequest() . '&action=comment-edit&entity_id=' . $entity_id_value,
'primary',
'',
'post'
);
$search = SearchBuilder::create('idTableComments')
->search('search')
->layout('full-width')
->placeholder('Type to search...');
$table = self::getCommentsTable($entity);
return $title->render() . '<br>' . $search->render() . '<br>' . $table->render();
}
public static function getCommentsTable(AbstractModel $entity): TableBuilder
{
$primary_id = $entity->getPrimaryKey();
$entity_id_value = $entity->$primary_id;
$page = self::getPageFromRequest();
$commentsModel = new CommentsModel();
return TableBuilder::create($commentsModel, 'idTableComments')
->activeFetch()
->setRequestAction('update-comment-table')
->where('recipe_id = ?', [$entity_id_value])
->field('comment')->truncate(100)
->field('created_by')
->field('created_at')
->field('updated_by')
->field('updated_at')
->addAction('edit', [
'label' => 'Edit',
'link' => '?page=' . $page . '&action=comment-edit&entity_id=' . $entity_id_value . '&id=%id%',
])
->addAction('delete', [
'label' => 'Delete',
'action' => [self::class, 'deleteComment'],
'class' => 'link-action-danger',
'confirm' => 'Are you sure you want to delete this comment?',
])
->customData('entity_id', $entity_id_value);
}
public static function getCommentForm(AbstractModel $entity): FormBuilder
{
$primary_id = $entity->getPrimaryKey();
$entity_id_value = $entity->$primary_id;
$page = self::getPageFromRequest();
$commentsModel = new CommentsModel();
return FormBuilder::create($commentsModel, $page)
->activeFetch()
->asModal()
->customData('entity_id', $entity_id_value)
->setTitle('New Comment', 'Edit Comment')
->dataListId('idTableComments')
->field('recipe_id')->value($entity_id_value)->readonly()
->field('comment')->required()
->setActions([
'save' => [
'label' => 'Save',
'class' => 'btn btn-primary',
'action' => FormBuilder::saveAction()
]
]);
}
public static function deleteComment($record, $request): array
{
if ($record->delete($record->id)) {
return ['success' => true, 'message' => 'Comment deleted successfully'];
}
return ['success' => false, 'message' => 'Delete failed'];
}
private static function getPageFromRequest(): string
{
return $_REQUEST['page'] ?? '';
}
}
Understanding the Service class:
- Not an extension - this is a regular helper class
- Contains all the business logic for building UI components
- Used by the Controller extension to generate responses
- Makes the code more organized and testable
Step 6: Create Controller Extension
Create milkadmin/Modules/Recipe/Extensions/Comments/Controller.php:
<?php
namespace Modules\Recipe\Extensions\Comments;
use App\Abstracts\AbstractControllerExtension;
use App\Attributes\{RequestAction, AccessLevel};
use App\Response;
!defined('MILK_DIR') && die();
class Controller extends AbstractControllerExtension
{
#[RequestAction('comments')]
public function viewComments()
{
$entity = $this->getEntityRecord();
Response::json([
'offcanvas_end' => [
'title' => 'Comments',
'body' => Service::getCommentsOffcanvasHtml($entity),
'size' => 'lg'
]
]);
}
#[RequestAction('update-comment-table')]
public function updateCommentTable()
{
$entity = $this->getEntityRecord();
$tableBuilder = Service::getCommentsTable($entity);
$response = $tableBuilder->getResponse();
// Reload the main entity list table if it exists
$response['list'] = [
"id" => "idTable" . ucfirst($this->module->get()->getPage()),
"action" => "reload"
];
Response::json($response);
}
#[RequestAction('comment-edit')]
public function commentEdit()
{
$entity = $this->getEntityRecord();
$formBuilder = Service::getCommentForm($entity);
Response::json($formBuilder->getResponse());
}
private function getEntityRecord()
{
$module = $this->module->get();
$model = $module->getModel();
return Service::getEntityRecord($model);
}
}
Understanding AbstractControllerExtension:
- Extends
AbstractControllerExtension- provides controller extension capabilities - Methods use
#[RequestAction('action-name')]attribute to register routes $this->module- WeakReference to the parent module (use->get())- Actions are automatically registered and routed by the system
- The Controller extension is loaded when the Module extension is loaded
Step 7: Create GetDataBuilder Extension
Create milkadmin/Modules/Recipe/Extensions/Comments/GetDataBuilder.php:
<?php
namespace Modules\Recipe\Extensions\Comments;
use App\Abstracts\AbstractGetDataBuilderExtension;
use App\{Get, Permissions};
!defined('MILK_DIR') && die();
class GetDataBuilder extends AbstractGetDataBuilderExtension
{
public function configure(object $builder): void
{
$page = $builder->getPage();
$builder->field('comments')
->label('Comments')
->fn(function ($row) use ($page) {
$commentsCount = count($row->comments);
return '<a href="?page=' . $page . '&action=comments&entity_id=' . $row->id . '" data-fetch="post">'
. $commentsCount . ' <i class="bi bi-chat-dots"></i></a>';
});
}
}
Understanding AbstractGetDataBuilderExtension:
- Extends
AbstractGetDataBuilderExtension- provides builder extension capabilities - Receives the builder instance (
TableBuilderorFormBuilder) configure()is called during the builder's configuration phase- Can add fields, modify existing fields, add actions, etc.
- Here we add a "comments" column that shows the count and links to the offcanvas
5. Loading Extensions in the Module
IMPORTANT: Extensions must be explicitly loaded in every component where they're needed. The system does not automatically propagate extensions.
5.1 Load Extension in RecipeModel
Update milkadmin/Modules/Recipe/RecipeModel.php:
<?php
namespace Modules\Recipe;
use App\Abstracts\AbstractModel;
class RecipeModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__recipes')
->id()
->title('name')->index()
->text('ingredients')->formType('textarea')
->extensions(['Comments']) // Load Model extension here
->select('difficulty', ['Easy', 'Medium', 'Hard']);
}
}
What happens:
->extensions(['Comments'])loadsModules\Recipe\Extensions\Comments\Model- The Model extension's
configure()method is called - The hasMany relationship is added to RecipeModel
- Now
$recipe->commentsis available
5.2 Load Extension in RecipeModule
Update milkadmin/Modules/Recipe/RecipeModule.php:
<?php
namespace Modules\Recipe;
use App\Abstracts\AbstractModule;
use App\Attributes\{RequestAction};
use Builders\{TableBuilder, FormBuilder};
use App\Response;
class RecipeModule extends AbstractModule
{
protected function configure($rule): void {
$rule->page('recipes')
->title('My Recipes')
->menu('Recipes', '', 'bi bi-book', 10)
->access('public')
->extensions(['Comments']) // Load Module extension here
->version(251221);
}
#[RequestAction('home')]
public function recipesList() {
$tableBuilder = TableBuilder::create($this->model, 'idTableRecipes')
->activeFetch()
->field('name')
->link('?page='.$this->page.'&action=edit&id=%id%')
->field('ingredients')->truncate(50)
->extensions(['Comments']) // Load GetDataBuilder extension here
->field('difficulty')
->setDefaultActions();
$response = array_merge($this->getCommonData(), $tableBuilder->getResponse());
Response::render(__DIR__ . '/Views/list_page.php', $response);
}
#[RequestAction('edit')]
public function recipeEdit() {
$response = ['page' => $this->page, 'title' => $this->title];
$response = array_merge($response, FormBuilder::create($this->model, $this->page)
->asOffcanvas()
->activeFetch()
->setTitle('New Recipe', 'Edit Recipe')
->dataListId('idTableRecipes')
->getResponse());
Response::json($response);
}
}
What happens:
->extensions(['Comments'])inconfigure():- Loads
Modules\Recipe\Extensions\Comments\Module - The Module extension registers CommentsModel
- Automatically loads the Controller extension
- Actions (comments, update-comment-table, comment-edit) become available
- Loads
->extensions(['Comments'])inTableBuilder:- Loads
Modules\Recipe\Extensions\Comments\GetDataBuilder - Adds the "comments" column to the table
- Loads
6. Extension Loading Summary
| Where to Load | Extension Class Loaded | Effect |
|---|---|---|
RecipeModel->extensions(['Comments']) |
Modules\Recipe\Extensions\Comments\Model |
Adds hasMany relationship to RecipeModel |
RecipeModule->extensions(['Comments']) |
Modules\Recipe\Extensions\Comments\ModuleModules\Recipe\Extensions\Comments\Controller |
Registers CommentsModel Adds controller actions |
TableBuilder->extensions(['Comments']) |
Modules\Recipe\Extensions\Comments\GetDataBuilder |
Adds comments column with count and link |
7. How Extensions are Resolved
When you call ->extensions(['Comments']), the system searches for the extension class in this order:
- Module-specific extension:
Modules\Recipe\Extensions\Comments\{Type}- Example:
Modules\Recipe\Extensions\Comments\Model
- Example:
- Global extension:
Extensions\Comments\{Type}- Example:
Extensions\Comments\Model
- Example:
Where {Type} is one of: Model, Module, Controller, GetDataBuilder, FormBuilder, etc.
8. Complete Flow Diagram
User clicks "Comments" link
↓
JavaScript: data-fetch="post" → makes fetch POST to ?page=recipes&action=comments&entity_id=X
↓
Router: finds action in Controller extension
↓
Controller::viewComments()
├─ Service::getEntityRecord() → validates entity_id
├─ Service::getCommentsOffcanvasHtml() → builds complete HTML
└─ Response::json(['offcanvas_end' => [...]])
↓
JavaScript: opens offcanvas with HTML
User sorts/searches comments table
↓
TableBuilder (with ->setRequestAction('update-comment-table'))
↓
Controller::updateCommentTable()
├─ Service::getCommentsTable() → builds updated table
├─ Adds 'list' => ['id' => 'idTableRecipes', 'action' => 'reload']
└─ Response::json()
↓
JavaScript: updates table + reloads main list
User clicks "Edit" comment
↓
Controller::commentEdit()
├─ Service::getCommentForm() → builds form with ->asModal()
└─ Response::json()
↓
JavaScript: opens modal
User saves form
↓
FormBuilder processes save
├─ Closes modal
├─ Reloads idTableComments (via ->dataListId())
└─ Which triggers update-comment-table
└─ Which reloads idTableRecipes
↓
Result: modal closed, both tables updated
9. Global Reusable Extensions
MilkAdmin includes a generic Comments extension in milkadmin/Extensions/Comments/ that can be used by any module without creating module-specific extension files.
Key Differences from Module-Specific Extensions
| Aspect | Module-Specific Extension | Global Extension |
|---|---|---|
| Location | milkadmin/Modules/Recipe/Extensions/Comments/ |
milkadmin/Extensions/Comments/ |
| Namespace | Modules\Recipe\Extensions\Comments |
Extensions\Comments |
| Foreign Key | Hardcoded: recipe_id |
Dynamic: entity_id (configurable) |
| Table Name | Hardcoded: #__recipes_comments |
Dynamic: {parent_table}_comments |
| Configuration | Fixed for Recipe module | Uses hooks for dynamic configuration |
| Reusability | Only for Recipe module | Works with any module |
Using the Global Comments Extension
To use the global extension instead of creating your own, simply use the same ->extensions(['Comments']) syntax. The system will automatically find and load the global extension if no module-specific one exists:
// RecipeModel.php
$rule->table('#__recipes')
->id()
->title('name')
->extensions(['Comments']) // Loads Extensions\Comments\Model
->text('content');
// RecipeModule.php
$rule->page('recipes')
->extensions(['Comments']); // Loads Extensions\Comments\Module and Controller
// In controller
TableBuilder::create($this->model, 'idTableRecipes')
->extensions(['Comments']); // Loads Extensions\Comments\GetDataBuilder
The global extension will automatically create a #__recipes_comments table with an entity_id foreign key, and provide all the same functionality without writing any extension code.
Available Global Extensions
MilkAdmin provides several ready-to-use global extensions in milkadmin/Extensions/:
- Comments - Complete commenting system
- Audit - Automatic tracking of created_by, created_at, updated_by, updated_at
- Author - Tracking of created_by only
- SoftDelete - Soft delete functionality
Each global extension can be loaded the same way: ->extensions(['ExtensionName'])
10. See Also
- Managing Related Content with Fetch-Based Interface - The fetch approach without extensions
- Managing Related Content in Separate Pages - Page-based approach
- FormBuilder Documentation
- TableBuilder Documentation