Milk Admin

Model Attributes

Revision: 2025/12/25

Model attributes allow you to define custom handlers for formatting, transforming, validating field values, and building reusable query scopes. Using PHP 8 attributes, you can attach custom methods to specific fields and queries to control how data is displayed, stored, validated, and filtered.

Quick Reference

Attribute Method Parameters Method Return Value Description
#[ToDisplayValue(field_name)] object $current_record Return the formatted value Called when accessing a field in formatted mode (for display)
#[ToDatabaseValue(field_name)] object $current_record Return the transformed value to store in the database Called when preparing data for database storage
#[SetValue(field_name)] array $current_record, mixed $value Return the transformed value Called when assigning a value to a field (via fill() or $model->field = value)
#[Validate(field_name)] object $current_record Return true if valid, or error message string if invalid< Called during validate() operation
#[DefaultQuery] Query $query Return the modified Query object Automatically applied to all SELECT queries (persistent)
#[Query('name')] Query $query Return the modified Query object Named query scope, applied on-demand with withQuery('name') (temporary)

How Attributes Work

When you create a Model instance, the AbstractModel automatically scans all public methods in your Model class looking for these attributes. When found, it registers them as handlers for specific fields and operations.

#[ToDisplayValue] - Custom Display Formatting

Use this attribute to define how a field should be displayed to users. The formatted value is used when you access fields after calling setOutputMode('formatted') or when using getFormattedData().

Signature

#[ToDisplayValue('field_name')]
public function methodName($current_record_obj): mixed
{
    // $current_record_obj is an object containing all fields of the current record
    // Return the formatted value
}

Example: Combining Multiple Fields

#[ToDisplayValue('full_name')]
public function getFormattedFullName($current_record_obj) {
    $parts = [];
    if (!empty($current_record_obj->first_name)) {
        $parts[] = $current_record_obj->first_name;
    }
    if (!empty($current_record_obj->last_name)) {
        $parts[] = $current_record_obj->last_name;
    }
    return implode(' ', $parts);
}

// Note: 'full_name' doesn't need to be a real database field
// It can be a virtual field computed from other fields

#[ToDatabaseValue] - Custom SQL Value

Use this attribute to control how a field value is prepared before saving to the database. This is useful for data transformation or encoding before storage.

Signature

#[ToDatabaseValue('field_name')]
public function methodName($current_record_obj): mixed
{
    // $current_record_obj is an object containing all fields
    // Return the value ready for SQL storage
}

Example: Encrypt Before Saving

#[ToDatabaseValue('credit_card')]
public function getSqlCreditCard($current_record_obj) {
    if (isset($current_record_obj->credit_card) && !empty($current_record_obj->credit_card)) {
        // Encrypt the credit card number before saving
        return openssl_encrypt(
            $current_record_obj->credit_card,
            'AES-256-CBC',
            ENCRYPTION_KEY,
            0,
            ENCRYPTION_IV
        );
    }
    return null;
}

#[SetValue] - Custom Value Transformation

Use this attribute to transform or sanitize values when they are assigned to a field. This is called automatically when you use fill() or direct assignment.

Signature

#[SetValue('field_name')]
public function methodName($current_record_array, $value): mixed
{
    // $current_record_array is an array with all current field values
    // $value is the incoming value being set
    // Return the transformed value
}

Example: Clean Phone Number

#[SetValue('phone')]
public function setPhoneValue($current_record_array, $value) {
    // Remove all non-numeric characters
    return preg_replace('/[^0-9+]/', '', $value);
}

// Input: "(555) 123-4567"
// Stored: "5551234567"

#[Validate] - Custom Field Validation

Use this attribute to define custom validation logic for a specific field. This is called during the validate() operation.

Signature

#[Validate('field_name')]
public function methodName($current_record): bool|string
{
    // $current_record is an object with all current record 
    // Return true if valid, or error message string if invalid
}

Example: Cross-Field Validation

#[Validate('end_date')]
public function validateEndDate($current_record) {
    $value = $current_record->end_date;
    if (empty($value)) {
        return true; // Optional field
    }

    // Access other fields through the model
    $start_date = $current_record->start_date;

    if (empty($start_date)) {
        return "Start date must be set before end date";
    }

    if ($value < $start_date) {
        return "End date must be after start date";
    }

    return true;
}

Working with Relationships

You can also use attributes to format or handle relationship fields. Use the notation "relationship_alias.field_name":

class AppointmentsModel extends AbstractModel
{
    protected function configure($rule): void {
        $rule->table('#__appointments')
            ->id()
            ->int('doctor_id')
            ->belongsTo('doctor', DoctorsModel::class, 'doctor_id');
    }

    // Format the related doctor's name
    #[ToDisplayValue('doctor.name')]
    public function getFormattedDoctorName($current_record_obj) {
        if (isset($current_record_obj->doctor->name)) {
            return 'Dr. ' . $current_record_obj->doctor->name;
        }
        return '';
    }
}

// Usage
$appointment = $model->include('doctor')->getById(1);
$appointment->setOutputMode('formatted');
echo $appointment->doctor->name;  // "Dr. John Smith"

#[DefaultQuery] - Global Query Scopes

Use this attribute to define query scopes that are automatically applied to all SELECT queries on the model. Default queries are persistent and remain active until explicitly disabled.

Use cases:

  • Soft deletes: automatically filter out deleted records
  • Multi-tenancy: filter records by tenant ID
  • Status filters: show only active/published records
  • Security: apply row-level security filters

Signature

#[DefaultQuery]
protected function methodName($query): Query
{
    // $query is the Query object being built
    // Return the modified Query object
}

Example: Soft Deletes

class OrdersModel extends AbstractModel
{
    protected function configure($rule): void {
        $rule->table('orders')
            ->id()
            ->string('status', 20)
            ->datetime('deleted_at');
    }

    // This query scope is applied to ALL queries automatically
    #[DefaultQuery]
    protected function onlyActive($query) {
        return $query->where('deleted_at IS NULL');
    }
}

// Usage:
$orders = $model->getAll();  // Only non-deleted orders
// SQL: SELECT * FROM orders WHERE deleted_at IS NULL

$orders = $model->where('status = ?', ['completed'])->getResults();
// SQL: SELECT * FROM orders WHERE deleted_at IS NULL AND status = 'completed'

Example: Multi-Tenancy

class DocumentsModel extends AbstractModel
{
    protected function configure($rule): void {
        $rule->table('documents')
            ->id()
            ->int('tenant_id')
            ->string('title');
    }

    #[DefaultQuery]
    protected function filterByTenant($query) {
        $currentTenantId = getCurrentTenantId(); // Your auth logic
        return $query->where('tenant_id = ?', [$currentTenantId]);
    }
}

// All queries automatically filter by current tenant
$docs = $model->getAll();  // Only current tenant's documents

Disabling Default Queries

// Disable a specific default query (persistent)
$orders = $model->withoutGlobalScope('onlyActive')->getAll();
// Returns ALL orders, including deleted

// Subsequent queries still have the scope disabled
$orders = $model->getAll();  // Still includes deleted orders

// Re-enable the scope
$model->enableGlobalScope('onlyActive');
$orders = $model->getAll();  // Back to only non-deleted orders

// Disable ALL default queries
$orders = $model->withoutGlobalScopes()->getAll();

#[Query('name')] - Named Query Scopes

Use this attribute to define reusable query constraints that can be applied on-demand. Named queries are temporary and only affect the current query.

Differences from DefaultQuery:

  • #[DefaultQuery]: Applied automatically, persistent until disabled
  • #[Query('name')]: Applied manually with withQuery('name'), temporary (one query only)

Signature

#[Query('scopeName')]
protected function methodName($query): Query
{
    // $query is the Query object being built
    // Return the modified Query object
}

Example: Commonly Used Filters

class OrdersModel extends AbstractModel
{
    protected function configure($rule): void {
        $rule->table('orders')
            ->id()
            ->string('status')
            ->decimal('total')
            ->datetime('created_at');
    }

    // Default: only active orders
    #[DefaultQuery]
    protected function onlyActive($query) {
        return $query->where('status != ?', ['cancelled']);
    }

    // Named scopes - apply on demand
    #[Query('recent')]
    protected function scopeRecent($query) {
        $thirtyDaysAgo = date('Y-m-d', strtotime('-30 days'));
        return $query->where('created_at > ?', [$thirtyDaysAgo]);
    }

    #[Query('highValue')]
    protected function scopeHighValue($query) {
        return $query->where('total > ?', [1000]);
    }

    #[Query('pending')]
    protected function scopePending($query) {
        return $query->where('status = ?', ['pending']);
    }

    #[Query('ordered')]
    protected function scopeOrdered($query) {
        return $query->order('created_at', 'DESC');
    }
}

// Usage - apply named scopes when needed:
$orders = $model->getAll();
// Only default scope applied: active orders

$orders = $model->withQuery('recent')->getAll();
// Default + recent: active orders from last 30 days

$orders = $model->withQuery('highValue')->withQuery('ordered')->getAll();
// Default + highValue + ordered: active orders over $1000, sorted by date

// Named scopes are temporary - next query doesn't include them
$orders = $model->getAll();
// Back to just default scope: active orders

Example: Complex Filtering

class PostsModel extends AbstractModel
{
    #[DefaultQuery]
    protected function published($query) {
        return $query->where('status = ? AND publish_date <= ?',
                            ['published', date('Y-m-d H:i:s')]);
    }

    #[Query('featured')]
    protected function scopeFeatured($query) {
        return $query->where('is_featured = ?', [1]);
    }

    #[Query('byCategory')]
    protected function scopeByCategory($query) {
        // This is a dynamic scope - you can access model properties
        if (isset($this->filter_category)) {
            return $query->where('category_id = ?', [$this->filter_category]);
        }
        return $query;
    }
}

// Usage:
$model = new PostsModel();
$model->filter_category = 5;

$posts = $model->withQuery('featured')->withQuery('byCategory')->getAll();
// Published posts that are featured in category 5

Combining with Manual Queries

// You can combine scopes with manual where/order/limit
$orders = $model
    ->withQuery('recent')
    ->where('customer_id = ?', [123])
    ->order('total', 'DESC')
    ->limit(0, 10)
    ->getResults();

// SQL equivalent:
// SELECT * FROM orders
// WHERE status != 'cancelled'           -- default scope
//   AND created_at > '2024-11-25'       -- named scope 'recent'
//   AND customer_id = 123               -- manual where
// ORDER BY total DESC                    -- manual order
// LIMIT 0, 10                           -- manual limit

Query Scope Management Methods

The ScopeTrait provides several methods to inspect and manage query scopes:

Method Description Returns
withoutGlobalScope('name') Disable a default scope (persistent) $this
withoutGlobalScopes() Disable all default scopes (persistent) $this
enableGlobalScope('name') Re-enable a previously disabled scope $this
withQuery('name') Apply a named query scope (temporary, one query only) $this
getDefaultQueries() Get list of all registered default query scopes array
getNamedQueries() Get list of all registered named query scopes array
getDisabledScopes() Get list of currently disabled scopes array
hasDefaultQuery('name') Check if a default query exists bool
hasNamedQuery('name') Check if a named query exists bool

Example: Scope Inspection

$model = new OrdersModel();

// Check what scopes are available
$defaultScopes = $model->getDefaultQueries();
// Returns: ['onlyActive']

$namedScopes = $model->getNamedQueries();
// Returns: ['recent', 'highValue', 'pending', 'ordered']

// Check if specific scope exists
if ($model->hasNamedQuery('recent')) {
    $orders = $model->withQuery('recent')->getAll();
}

// Check what's currently disabled
$model->withoutGlobalScope('onlyActive');
$disabled = $model->getDisabledScopes();
// Returns: ['onlyActive']

Best Practices

  • Use DefaultQuery for: Security filters, soft deletes, multi-tenancy, status filtering that should ALWAYS apply
  • Use Query for: Common filters that users might want (recent, featured, by category), optional ordering
  • Naming: Use clear, descriptive names for scopes (onlyActive, recent, highValue)
  • Method naming: Prefix default scope methods with a verb (onlyActive, filterByTenant), named scopes can use scope prefix or descriptive names
  • Chaining: All scope methods return $this, so you can chain multiple scopes together
  • Testing: Remember that default scopes are persistent - use withoutGlobalScopes() in tests when you need all records
  • Performance: Query scopes are applied at query build time, not after fetching - they don't impact performance
Loading...