Milk Admin

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.

Prerequisite: This guide assumes familiarity with the fetch-based approach described in Managing Related Content with Fetch-Based Interface.

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_builder in 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_builder in 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 (TableBuilder or FormBuilder)
  • 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']) loads Modules\Recipe\Extensions\Comments\Model
  • The Model extension's configure() method is called
  • The hasMany relationship is added to RecipeModel
  • Now $recipe->comments is 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']) in configure():
    • 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
  • ->extensions(['Comments']) in TableBuilder:
    • Loads Modules\Recipe\Extensions\Comments\GetDataBuilder
    • Adds the "comments" column to the table

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\Module
Modules\Recipe\Extensions\Comments\Controller
Registers CommentsModel
Adds controller actions
TableBuilder
->extensions(['Comments'])
Modules\Recipe\Extensions\Comments\GetDataBuilder Adds comments column with count and link
Important: Each extension must be loaded explicitly in the appropriate location. Extensions are not automatically propagated from one component to another.

7. How Extensions are Resolved

When you call ->extensions(['Comments']), the system searches for the extension class in this order:

  1. Module-specific extension: Modules\Recipe\Extensions\Comments\{Type}
    • Example: Modules\Recipe\Extensions\Comments\Model
  2. Global extension: Extensions\Comments\{Type}
    • Example: Extensions\Comments\Model

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

Loading...