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:
- Document parameters - List all configurable options with defaults
- Add usage examples - Show how to use the extension
- List requirements - Note any dependencies or required modules
- Package as folder - Share the entire extension folder
- 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) |