Milk Admin

Creating Custom Extensions

Revision: 2025/12/02

Learn how to create your own reusable extensions to standardize behaviors across your modules.

Extension Structure

Extensions can have multiple components:

Extensions/
└── MyExtension/
    ├── Module.php        # Module extension (UI, permissions)
    ├── Model.php         # Model extension (fields, data processing)
    ├── Install.php       # Optional: Installation/migration logic
    ├── Controller.php    # Optional: Custom pages/actions
    ├── Shell.php         # Optional: CLI commands
    ├── Api.php           # Optional: API endpoints
    ├── Hook.php          # Optional: System hooks
    ├── Assets/           # Optional: JavaScript/CSS
    │   ├── script.js
    │   └── style.css
    └── Views/            # Optional: Custom views
        └── page.php

Step-by-Step: Creating a Timestamp Extension

Let's create an extension that automatically adds created_at and updated_at timestamps to any model.

Step 1: Create Extension Folder

# For global extensions
mkdir -p milkadmin/Extensions/Timestamp

# For module-specific extensions
mkdir -p Modules/MyModule/Extensions/Timestamp

Step 2: Create Model Extension

File: Extensions/Timestamp/Model.php

<?php
namespace Extensions\Timestamp;

use App\Abstracts\{AbstractModelExtension, RuleBuilder};
use App\Attributes\{ToDatabaseValue};

!defined('MILK_DIR') && die();

/**
 * Timestamp Model Extension
 *
 * Automatically adds and manages created_at and updated_at timestamps.
 */
class Model extends AbstractModelExtension
{
    /**
     * Include created_at field
     * @var bool
     */
    protected $include_created_at = true;

    /**
     * Include updated_at field
     * @var bool
     */
    protected $include_updated_at = true;

    /**
     * Add timestamp fields to the model
     */
    public function configure(RuleBuilder $rule_builder): void
    {
        if ($this->include_created_at) {
            $rule_builder
                ->timestamp('created_at')
                ->default('CURRENT_TIMESTAMP')
                ->label('Created At');
        }

        if ($this->include_updated_at) {
            $rule_builder
                ->timestamp('updated_at')
                ->default('CURRENT_TIMESTAMP')
                ->onUpdate('CURRENT_TIMESTAMP')
                ->label('Updated At');
        }
    }

    /**
     * Preserve created_at on updates
     */
    #[ToDatabaseValue('created_at')]
    public function preserveCreatedAt($current_record)
    {
        // On insert, use default
        if (empty($current_record->created_at)) {
            return date('Y-m-d H:i:s');
        }

        // On update, preserve original value
        $id_field = $this->model->get()->getPrimaryKey();
        if (!empty($current_record->$id_field)) {
            $old_record = $this->model->get()->getById($current_record->$id_field);
            if ($old_record && isset($old_record->created_at)) {
                return $old_record->created_at;
            }
        }

        return $current_record->created_at;
    }

    /**
     * Always update updated_at on save
     */
    #[ToDatabaseValue('updated_at')]
    public function setUpdatedAt($current_record)
    {
        return date('Y-m-d H:i:s');
    }
}

Step 3: Create Module Extension (Optional)

File: Extensions/Timestamp/Module.php

<?php
namespace Extensions\Timestamp;

use App\Abstracts\{AbstractModuleExtension, ModuleRuleBuilder};

!defined('MILK_DIR') && die();

/**
 * Timestamp Module Extension
 *
 * No UI components needed - all functionality is in Model extension.
 */
class Module extends AbstractModuleExtension
{
    public function configure(ModuleRuleBuilder $rule_builder): void
    {
        // No additional module configuration needed
    }
}

Step 4: Use the Extension

class MyModel extends AbstractModel
{
    public function configure(RuleBuilder $rule_builder): void
    {
        $rule_builder
            ->table('#__my_table')
            ->id()
            ->string('title', 255)

            // Add timestamp extension
            ->addExtension('Timestamp', [
                'include_created_at' => true,
                'include_updated_at' => true
            ]);
    }
}

Advanced Example: Soft Delete Extension

An extension that adds soft delete functionality (mark as deleted instead of removing):

<?php
namespace Extensions\SoftDelete;

use App\Abstracts\{AbstractModelExtension, RuleBuilder};
use App\Attributes\{ToDatabaseValue};

class Model extends AbstractModelExtension
{
    /**
     * Field name for deleted flag
     * @var string
     */
    protected $field_name = 'deleted_at';

    /**
     * Add soft delete field
     */
    public function configure(RuleBuilder $rule_builder): void
    {
        $rule_builder
            ->timestamp($this->field_name)
            ->nullable(true)
            ->default(null)
            ->label('Deleted At');
    }

    /**
     * Override beforeDelete to mark as deleted instead
     */
    public function beforeDelete($ids)
    {
        $model = $this->model->get();

        // Mark records as deleted instead of removing
        foreach ($ids as $id) {
            $record = $model->getById($id);
            if ($record) {
                $record->{$this->field_name} = date('Y-m-d H:i:s');
                $model->store((array)$record);
            }
        }

        // Prevent actual deletion
        return false;
    }

    /**
     * Hook into queries to filter out deleted records
     */
    public function onAttributeMethodsScanned(): void
    {
        parent::onAttributeMethodsScanned();

        // Auto-filter deleted records in queries
        $model = $this->model->get();
        $model->where("{$this->field_name} IS NULL");
    }
}

Extension Development Checklist

Step Description
1. Plan Define what behavior the extension should provide
2. Structure Decide which components you need: Module, Model, Shell, Api, Hook, Controller
3. Parameters Define configurable protected properties
4. Configure Add fields, permissions, UI elements in configure()
5. Attributes Use attributes for data processing and validation
6. Hooks Implement lifecycle hooks (bootstrap, init, afterSave, etc.)
7. Test Test with a simple module first
8. Document Add docblocks and usage examples

Best Practices

Naming Conventions

  • Extension folder: PascalCase (e.g., Timestamp, SoftDelete)
  • Class files: Module.php, Model.php
  • Namespace: Extensions\ExtensionName
  • Parameters: snake_case (e.g., $include_created_at)

Code Quality

  • Single Responsibility - Each extension should handle one specific behavior
  • Configurable - Use parameters to make extensions flexible
  • Non-Breaking - Extensions should not break existing functionality
  • Documented - Add docblocks for all properties and methods
  • Tested - Test with different configurations and edge cases

Performance

  • Lazy Loading - Load dependencies only when needed
  • Caching - Use static caches for repeated queries
  • Avoid N+1 - Batch database queries when possible
  • WeakReference - Parent model/module are stored as WeakReferences

Testing Your Extension

1. Create a Test Module

class TestExtensionModule extends AbstractModule
{
    public function configure(ModuleRuleBuilder $rule_builder): void
    {
        $rule_builder
            ->page('test_extension')
            ->title('Test Extension')
            ->addExtension('MyExtension', [
                'parameter1' => true
            ]);
    }
}

2. Create a Test Model

class TestExtensionModel extends AbstractModel
{
    public function configure(RuleBuilder $rule_builder): void
    {
        $rule_builder
            ->table('#__test_extension')
            ->id()
            ->string('title', 255)
            ->addExtension('MyExtension');
    }
}

3. Test Scenarios

  • Test with default parameters
  • Test with custom parameters
  • Test insert, update, delete operations
  • Test data formatting (raw, formatted, sql)
  • Test permissions and UI elements
  • Test edge cases (null values, missing data)

Debugging Extensions

public function configure(RuleBuilder $rule_builder): void
{
    // Log extension initialization
    error_log("MyExtension initialized with parameters: " . json_encode([
        'param1' => $this->param1,
        'param2' => $this->param2
    ]));

    // Debug parent model
    $model = $this->model->get();
    error_log("Parent model table: " . $model->getTable());
}

#[ToDatabaseValue('my_field')]
public function processMyField($current_record)
{
    // Log processing
    $value = $current_record->my_field;
    error_log("Processing my_field: {$value}");

    return $processed_value;
}

Sharing Extensions

To share your extension with other projects:

  1. Document parameters - List all configurable options with defaults
  2. Add usage examples - Show how to use the extension
  3. List requirements - Note any dependencies or required modules
  4. Package as folder - Share the entire extension folder
  5. Include Install.php - For extensions requiring setup

Common Extension Patterns

Auto-Fill Current User

#[ToDatabaseValue('user_id')]
public function setUserId($current_record)
{
    if (empty($current_record->user_id)) {
        $user = Get::make('Auth')->getUser();
        return $user->id ?? 0;
    }
    return $current_record->user_id;
}

Preserve Field on Update

#[ToDatabaseValue('created_at')]
public function preserveCreatedAt($current_record)
{
    $id_field = $this->model->get()->getPrimaryKey();

    if (!empty($current_record->$id_field)) {
        $old = $this->model->get()->getById($current_record->$id_field);
        return $old->created_at ?? $current_record->created_at;
    }

    return $current_record->created_at;
}

Format for Display

#[ToDisplayValue('status')]
public function formatStatus($current_record)
{
    $statuses = [
        0 => 'Inactive',
        1 => 'Active',
        2 => 'Pending'
    ];

    return $statuses[$current_record->status] ?? 'Unknown';
}

Complete Extension Example

Here's a complete example showing how to use an extension with all its components:

// In your Model
class MyModel extends AbstractModel
{
    public function configure(RuleBuilder $rule_builder): void
    {
        $rule_builder
            ->table('#__my_table')
            ->id()
            ->string('title', 255)

            // Add the extension with parameters
            ->addExtension('MyExtension', [
                'enable_api' => true,
                'enable_hooks' => true,
                'custom_setting' => 'value'
            ]);
    }
}

// In your Module
class MyModule extends AbstractModule
{
    public function configure(ModuleRuleBuilder $rule_builder): void
    {
        $rule_builder
            ->page('my_module')
            ->title('My Module')

            // Add the extension to the module too
            ->addExtension('MyExtension', [
                'enable_api' => true
            ]);
    }
}

The extension will automatically load all its components (Module.php, Model.php, Shell.php, Api.php, Hook.php) and pass the parameters to each component.

Shell, API, and Hook Extensions

Extensions can also include Shell, API, and Hook components to extend CLI commands, API endpoints, and system hooks.

Shell Extension (CLI Commands)

Shell extensions add CLI command functionality to your extension.

File: Extensions/MyExtension/Shell.php

<?php
namespace Extensions\MyExtension;

use App\Abstracts\AbstractShellExtension;

!defined('MILK_DIR') && die();

/**
 * MyExtension Shell Extension
 */
class Shell extends AbstractShellExtension
{
    /**
     * Hook called after Shell initialization
     * At this point, module/model are not yet available
     */
    public function onInit(): void
    {
        // Initialize CLI helpers or global command handlers
        // Example: Register custom CLI handlers
    }

    /**
     * Hook called after setHandleShell
     * Module, page, and model are now available
     */
    public function onSetup(): void
    {
        // Shell is now fully configured
        // Access module: $this->module->get()
        // Access model: $this->module->get()->getModel()
        // Access page: $this->module->get()->getPage()

        // Example: Log CLI initialization
        \App\Cli::info('MyExtension Shell loaded for: ' . $this->module->get()->getPage());
    }
}

API Extension (REST Endpoints)

API extensions add REST API endpoints to your extension using the #[ApiEndpoint] attribute.

File: Extensions/MyExtension/Api.php

<?php
namespace Extensions\MyExtension;

use App\Abstracts\AbstractApiExtension;
use App\Attributes\ApiEndpoint;

!defined('MILK_DIR') && die();

/**
 * MyExtension API Extension
 */
class Api extends AbstractApiExtension
{
    /**
     * Hook called after API initialization
     * At this point, module/model are not yet available
     */
    public function onInit(): void
    {
        // Set up API middleware or global handlers
    }

    /**
     * Hook called after setHandleApi
     * Module, page, and model are now available
     */
    public function onSetup(): void
    {
        // API is now fully configured
        // Access module: $this->module->get()
        // Access model: $this->module->get()->getModel()
        // Access page: $this->module->get()->getPage()
    }

    /**
     * Example API endpoint
     * Accessible at: /api/myextension/status
     */
    #[ApiEndpoint('/myextension/status', 'GET')]
    public function getStatus()
    {
        return [
            'status' => 'ok',
            'module' => $this->module->get()->getPage(),
            'extension' => 'MyExtension'
        ];
    }

    /**
     * POST endpoint with data processing
     * Accessible at: /api/myextension/process
     */
    #[ApiEndpoint('/myextension/process', 'POST')]
    public function processData()
    {
        $input = file_get_contents('php://input');
        $data = json_decode($input, true);

        // Process data using the model
        $model = $this->module->get()->getModel();
        $result = $model->store($data);

        return [
            'success' => true,
            'id' => $result
        ];
    }
}

Hook Extension (System Hooks)

Hook extensions register callbacks to system hooks using the #[HookCallback] attribute.

File: Extensions/MyExtension/Hook.php

<?php
namespace Extensions\MyExtension;

use App\Abstracts\AbstractHookExtension;
use App\Attributes\HookCallback;

!defined('MILK_DIR') && die();

/**
 * MyExtension Hook Extension
 */
class Hook extends AbstractHookExtension
{
    /**
     * Hook called after Hook initialization
     */
    public function onInit(): void
    {
        // Set up extension state or logging
    }

    /**
     * Hook called after all hooks are registered
     * Main class and extension hooks are now registered
     */
    public function onRegisterHooks(): void
    {
        // All hooks have been registered
        // Access registered hooks: $this->hook->getRegisteredHooks();
    }

    /**
     * Example hook callback for initialization
     * Called when Hooks::run('init') is executed
     *
     * @param array $data Hook data
     * @return array Modified data
     */
    #[HookCallback('init', 10)]
    public function onInitHook($data = [])
    {
        // Execute custom logic on init
        // Priority: 10 (lower number = higher priority)
        return $data;
    }

    /**
     * Example hook for user login events
     * Called when Hooks::run('user.login', $userData) is executed
     *
     * @param array $user_data User login data
     * @return array Modified user data
     */
    #[HookCallback('user.login', 20)]
    public function onUserLogin($user_data)
    {
        // Log user login or perform additional checks
        $model = $this->module->get()->getModel();

        // Store login event
        $model->store([
            'user_id' => $user_data['id'] ?? 0,
            'event' => 'login',
            'timestamp' => date('Y-m-d H:i:s')
        ]);

        return $user_data;
    }

    /**
     * Example hook for before save operations
     *
     * @param object $record Record being saved
     * @return object Modified record
     */
    #[HookCallback('before.save', 5)]
    public function beforeSave($record)
    {
        // Modify record before saving
        $record->modified_by_extension = 'MyExtension';
        return $record;
    }
}

Lifecycle Hooks Summary

Extension Type Lifecycle Hooks Purpose
Module configure(), bootstrap(), init() UI configuration, module setup, page initialization
Model configure(), onAttributeMethodsScanned() Field configuration, attribute registration
Shell onInit(), onSetup() CLI initialization, command setup
Api onInit(), onSetup() API initialization, endpoint setup
Hook onInit(), onRegisterHooks() Hook initialization, callback registration

Attributes Reference

Attribute Extension Type Usage
#[ToDatabaseValue] Model Process field value before saving to database
#[ToDisplayValue] Model Format field value for display
#[SetValue] Model Set field value programmatically
#[Validate] Model Custom field validation
#[ApiEndpoint] Api Register REST API endpoint (path, method)
#[HookCallback] Hook Register system hook callback (name, priority)

See Also

Loading...