Milk Admin

Model Extensions

Model Extensions allow you to add fields, validation, data processing, and lifecycle hooks to your models without modifying the model class itself. They extend the AbstractModelExtension class.

Creating a Model Extension

namespace Extensions\MyExtension;

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

class Model extends AbstractModelExtension
{
    // Configuration parameters
    protected $my_option = true;

    // Add fields during configuration
    public function configure(RuleBuilder $rule_builder): void
    {
        $rule_builder
            ->int('my_field')
            ->default(0);
    }

    // Process data before saving
    #[ToDatabaseValue('my_field')]
    public function processMyField($current_record)
    {
        return $current_record->my_field * 2;
    }

    // Format data for display
    #[ToDisplayValue('my_field')]
    public function formatMyField($current_record)
    {
        return "Value: " . $current_record->my_field;
    }
}

Extension Parameters

Extensions can have configurable parameters defined as protected properties:

class Model extends AbstractModelExtension
{
    protected $show_username = true;
    protected $show_email = false;
    protected $max_records = 100;
}

Pass parameters when adding the extension:

$rule_builder->addExtension('MyExtension', [
    'show_username' => false,
    'max_records' => 50
]);

Configuration Hook

The configure() method is called during model initialization. Use it to add fields:

public function configure(RuleBuilder $rule_builder): void
{
    $rule_builder
        ->int('created_by')
        ->nullable(true)
        ->default(0)
        ->label('Created By')

        ->timestamp('created_at')
        ->default('CURRENT_TIMESTAMP')
        ->label('Created At');
}

Attribute-Based Methods

Model extensions support the same attributes as models for automatic method registration:

#[ToDatabaseValue('field_name')]

Process field value before saving to database:

#[ToDatabaseValue('created_by')]
public function setCreatedBy($current_record)
{
    // Auto-fill with current user ID on insert
    if (empty($current_record->created_by)) {
        $user = Get::make('Auth')->getUser();
        return $user->id ?? 0;
    }
    return $current_record->created_by;
}

#[ToDisplayValue('field_name')]

Format field value for display:

#[ToDisplayValue('created_by')]
public function formatCreatedBy($current_record)
{
    $user = Get::make('Auth')->getUser($current_record->created_by);
    return $user ? $user->username : '-';
}

#[SetValue('field_name')]

Customize how field values are set:

#[SetValue('tags')]
public function setTags($current_record)
{
    // Convert array to comma-separated string
    if (is_array($current_record->tags)) {
        return implode(',', $current_record->tags);
    }
    return $current_record->tags;
}

#[Validate('field_name')]

Add custom validation:

#[Validate('title')]
public function validateTitle($value, $field_name, $current_record)
{
    if (strlen($value) < 3) {
        return 'Title must be at least 3 characters';
    }
    return true;
}

Lifecycle Hooks

Hook Parameters Description
configure() RuleBuilder $rule_builder Called during model configuration to add fields and rules
afterSave() array $records, array $results Called after records are saved (insert/update)
beforeDelete() array $ids Called before records are deleted
afterDelete() array $ids Called after records are deleted

afterSave() Example

public function afterSave($records_array, $save_results)
{
    foreach ($save_results as $result) {
        $action = $result['action'];  // 'insert' or 'edit'
        $id = $result['id'];

        // Log the save operation
        error_log("Record {$id} was {$action}ed");
    }
}

afterDelete() Example

public function afterDelete($ids)
{
    foreach ($ids as $id) {
        // Clean up related data
        error_log("Record {$id} was deleted");
    }
}

Accessing the Parent Model

Extensions have access to the parent model via $this->model->get():

public function myMethod()
{
    $model = $this->model->get();

    $table = $model->getTable();
    $primary_key = $model->getPrimaryKey();
    $rules = $model->getRules();

    // Use model methods
    $record = $model->getById(123);
}

Complete Example: Audit Trail Extension

This extension creates a complete audit trail by saving record snapshots:

namespace Extensions\Audit;

class Model extends AbstractModelExtension
{
    protected static $maxAuditRecords = 0;  // 0 = unlimited

    public function configure(RuleBuilder $rule_builder): void
    {
        // Audit data is stored in a separate table
        // No fields needed in main model
    }

    public function afterSave($records_array, $save_results)
    {
        $parent_model = $this->model->get();
        $table_audit = $parent_model->getTable() . "_audit";
        $auditModel = new AuditModel();

        foreach ($save_results as $result) {
            $action = $result['action'];
            $recordId = $result['id'];

            // Find the saved record data
            $recordData = null;
            foreach ($records_array as $record) {
                if ($record[$parent_model->getPrimaryKey()] == $recordId) {
                    $recordData = $record;
                    break;
                }
            }

            // Save to audit table
            $audit_record = $recordData;
            $audit_record['audit_action'] = $action;
            $audit_record['audit_record_id'] = $recordId;
            $audit_record['audit_timestamp'] = time();
            $audit_record['audit_user_id'] = Get::make('Auth')->getUser()->id ?? 0;

            $auditModel->store($audit_record);
        }
    }

    public function afterDelete($ids)
    {
        $auditModel = new AuditModel();

        foreach ($ids as $deleted_id) {
            $audit_record = [
                'audit_action' => 'delete',
                'audit_record_id' => $deleted_id,
                'audit_timestamp' => time(),
                'audit_user_id' => Get::make('Auth')->getUser()->id ?? 0
            ];

            $auditModel->store($audit_record);
        }
    }
}

Best Practices

  • Keep extensions focused - Each extension should handle one specific behavior
  • Use parameters - Make extensions configurable for different use cases
  • Document parameters - Add docblocks to protected properties
  • Handle edge cases - Check for null values and missing data
  • Use WeakReference - The parent model is stored as a WeakReference to prevent memory leaks
  • Cache data - Use static caches for frequently accessed data (like user lookups)

Testing Extensions

Test your extension by adding it to a model:

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

            // Add your extension
            ->addExtension('MyExtension', [
                'my_option' => true
            ]);
    }
}

See Also

Loading...