HardТеория7 min

Mutators и Casts

Attribute casting, custom casts (CastsAttributes), cast parameters, inbound casts, value objects, enum casting, encrypted casting

Laravel предоставляет мощную систему трансформации атрибутов при чтении и записи. Casts объявляются декларативно, а mutators дают полный контроль.

Attribute Casting

Метод casts() определяет автоматическую трансформацию атрибутов:

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    // Modern syntax (Laravel 11+): method instead of property
    protected function casts(): array
    {
        return [
            'is_admin' => 'boolean',
            'email_verified_at' => 'datetime',
            'options' => 'array',
            'balance' => 'decimal:2',
            'metadata' => 'collection',
            'birthday' => 'date',
            'login_count' => 'integer',
            'rating' => 'float',
            'secret_token' => 'hashed',
        ];
    }
}

Подвох на экзамен: В Laravel 11+ рекомендуется определять casts через метод casts(), а не через свойство $casts. Метод позволяет использовать логику и ссылки на классы.

Все встроенные типы cast

<?php

protected function casts(): array
{
    return [
        // Primitive types
        'count' => 'integer',       // int
        'price' => 'float',         // float
        'amount' => 'double',       // double (alias for float)
        'price' => 'decimal:2',     // string with 2 decimal places
        'is_active' => 'boolean',   // bool
        'name' => 'string',         // string

        // Real (keeps precision better than float)
        'precise_amount' => 'real',

        // Date/Time
        'created_at' => 'datetime',           // Carbon instance
        'birthday' => 'date',                 // Carbon (date only)
        'alarm_at' => 'datetime:Y-m-d H:i',  // Custom format
        'expires_at' => 'immutable_datetime', // CarbonImmutable
        'born_on' => 'immutable_date',        // CarbonImmutable (date only)
        'unix_time' => 'timestamp',           // Unix timestamp (integer)

        // Complex types
        'options' => 'array',         // JSON -> array
        'settings' => 'json',         // Alias for array
        'tags' => 'collection',       // JSON -> Collection
        'data' => 'object',           // JSON -> stdClass

        // Security
        'password' => 'hashed',       // Auto bcrypt on set
        'secret' => 'encrypted',      // Encrypt/decrypt automatically
        'config' => 'encrypted:array', // Encrypt + cast to array
        'notes' => 'encrypted:collection', // Encrypt + cast to Collection
        'data' => 'encrypted:object', // Encrypt + cast to object

        // Enum (PHP 8.1+)
        'status' => OrderStatus::class, // Backed Enum
    ];
}

Разница между array, json, object, collection

<?php

$user = User::find(1);

// 'array' cast: returns PHP array
$user->options; // ['theme' => 'dark', 'lang' => 'ru']
$user->options['theme']; // 'dark'

// 'object' cast: returns stdClass
$user->settings; // stdClass { theme: 'dark', lang: 'ru' }
$user->settings->theme; // 'dark'

// 'collection' cast: returns Illuminate\Support\Collection
$user->tags; // Collection(['php', 'laravel'])
$user->tags->contains('php'); // true
$user->tags->push('vue'); // Does NOT save automatically!

// IMPORTANT: Mutations on array/collection casts
$user->options = array_merge($user->options, ['font' => 'mono']); // OK
$user->save();

// This WON'T work with array cast:
$user->options['font'] = 'mono'; // Change not detected!
$user->save(); // options NOT updated

Критический подвох на экзамене: При cast array или collection прямая мутация вложенных элементов ($user->options['key'] = 'value') НЕ определяется Eloquent как изменение. Нужно переприсвоить весь атрибут или использовать AsArrayObject / AsCollection:

<?php

use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Casts\AsCollection;

protected function casts(): array
{
    return [
        // These detect nested mutations!
        'options' => AsArrayObject::class,
        'tags' => AsCollection::class,
    ];
}

// Now this works:
$user->options['font'] = 'mono'; // Change IS detected
$user->save(); // options IS updated

Enum Casting

<?php

declare(strict_types=1);

namespace App\Enums;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match($this) {
            self::Pending => 'In attesa',
            self::Processing => 'In lavorazione',
            self::Shipped => 'Spedito',
            self::Delivered => 'Consegnato',
            self::Cancelled => 'Annullato',
        };
    }
}
<?php

declare(strict_types=1);

namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
        ];
    }
}

// Usage
$order = Order::find(1);
$order->status; // OrderStatus::Pending (enum instance)
$order->status->value; // 'pending' (string)
$order->status->label(); // 'In attesa'

// Setting value
$order->status = OrderStatus::Shipped;
$order->save();

// Querying
Order::where('status', OrderStatus::Pending)->get();

Подвох на экзамене: Enum cast работает только с Backed Enums (string или int). Обычные Enums без backing type НЕ поддерживаются для cast.

Encrypted Casting

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Patient extends Model
{
    protected function casts(): array
    {
        return [
            'ssn' => 'encrypted',              // String encrypted
            'medical_data' => 'encrypted:array', // Array + encrypted
            'notes' => 'encrypted:collection',   // Collection + encrypted
        ];
    }
}

// Values are encrypted in DB, decrypted when accessed
$patient = Patient::find(1);
$patient->ssn; // '123-45-6789' (decrypted)

// In database: eyJpdiI6Ik1UQ... (encrypted string)

// Searchable encrypted columns are NOT possible with standard encryption
// For searchable encryption, consider dedicated solutions

Важно: Зашифрованные столбцы нельзя индексировать и искать по ним напрямую в SQL. Каждый раз значение шифруется по-разному (используется IV).

Custom Casts

CastsAttributes Interface

<?php

declare(strict_types=1);

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class MoneyCast implements CastsAttributes
{
    public function __construct(
        private readonly string $currency = 'USD',
    ) {}

    // Called when reading from database
    public function get(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): ?Money {
        if ($value === null) {
            return null;
        }

        return new Money(
            amount: (int) $value,
            currency: $this->currency,
        );
    }

    // Called when writing to database
    public function set(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): int {
        if ($value instanceof Money) {
            return $value->amount;
        }

        return (int) $value;
    }
}

Value Object:

<?php

declare(strict_types=1);

namespace App\ValueObjects;

final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency = 'USD',
    ) {}

    public function formatted(): string
    {
        return number_format($this->amount / 100, 2) . ' ' . $this->currency;
    }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException('Cannot add different currencies');
        }

        return new self($this->amount + $other->amount, $this->currency);
    }
}

Использование:

<?php

declare(strict_types=1);

namespace App\Models;

use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected function casts(): array
    {
        return [
            'price' => MoneyCast::class,       // Default: USD
            'cost' => MoneyCast::class . ':EUR', // With parameter
        ];
    }
}

// Usage
$product = Product::find(1);
$product->price; // Money(amount: 1999, currency: 'USD')
$product->price->formatted(); // '19.99 USD'

$product->price = new Money(2999, 'USD');
$product->save();

Cast Parameters

<?php

// Passing parameters to custom casts
protected function casts(): array
{
    return [
        // Single parameter
        'price' => MoneyCast::class . ':EUR',

        // Multiple parameters
        'amount' => MoneyCast::class . ':USD,true',

        // Using class string with constructor
        'balance' => MoneyCast::class,
    ];
}

// In the cast class, parameters are received through constructor:
class MoneyCast implements CastsAttributes
{
    public function __construct(
        private readonly string $currency = 'USD',
        private readonly bool $cents = false,
    ) {}
}

Inbound Casts

Inbound cast применяется ТОЛЬКО при записи (set), не при чтении (get):

<?php

declare(strict_types=1);

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Database\Eloquent\Model;

class HashCast implements CastsInboundAttributes
{
    public function __construct(
        private readonly string $algorithm = 'sha256',
    ) {}

    // Only called when SETTING value
    public function set(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): string {
        return hash($this->algorithm, $value);
    }

    // NO get() method - value is returned as-is from database
}
<?php

protected function casts(): array
{
    return [
        'api_token' => HashCast::class . ':sha256',
    ];
}

// Usage
$user->api_token = 'my-secret-token';
// Stored as: hash('sha256', 'my-secret-token')
// Read as: raw hash string (no transformation on get)

Подвох на экзамене: CastsInboundAttributes реализует только set(). Нет метода get(). Это полезно для необратимых трансформаций (хеширование). Отличие от CastsAttributes, который требует оба метода.

Cast с множественными столбцами

<?php

declare(strict_types=1);

namespace App\Casts;

use App\ValueObjects\Address;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class AddressCast implements CastsAttributes
{
    public function get(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): Address {
        return new Address(
            street: $attributes['address_street'],
            city: $attributes['address_city'],
            state: $attributes['address_state'],
            zip: $attributes['address_zip'],
        );
    }

    public function set(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): array {
        if (!$value instanceof Address) {
            throw new \InvalidArgumentException('Value must be Address instance');
        }

        // Return multiple columns
        return [
            'address_street' => $value->street,
            'address_city' => $value->city,
            'address_state' => $value->state,
            'address_zip' => $value->zip,
        ];
    }
}
<?php

class User extends Model
{
    protected function casts(): array
    {
        return [
            'address' => AddressCast::class,
        ];
    }
}

// Usage
$user->address; // Address(street: '123 Main', city: 'NYC', ...)
$user->address = new Address('456 Oak', 'LA', 'CA', '90001');
$user->save(); // Updates 4 columns at once

Подвох на экзамене: Custom cast может работать с несколькими столбцами БД сразу, возвращая массив в методе set(). Виртуальный атрибут address не существует в базе, но cast читает и записывает несколько реальных столбцов.

Castable Interface

Позволяет Value Object самому определять свой cast:

<?php

declare(strict_types=1);

namespace App\ValueObjects;

use App\Casts\MoneyCast;
use Illuminate\Contracts\Database\Eloquent\Castable;

final readonly class Money implements Castable
{
    public function __construct(
        public int $amount,
        public string $currency = 'USD',
    ) {}

    // Define which cast class to use
    public static function castUsing(array $arguments): string
    {
        return MoneyCast::class;
    }
}
<?php

// Now you can use the value object directly as cast
class Product extends Model
{
    protected function casts(): array
    {
        return [
            'price' => Money::class, // Uses Money::castUsing()
        ];
    }
}

Accessors + Casts взаимодействие

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected function casts(): array
    {
        return [
            'balance' => 'integer',
        ];
    }

    // Accessor works ON TOP of cast
    protected function balance(): Attribute
    {
        return Attribute::make(
            // $value is already cast to integer
            get: fn (int $value) => $value / 100,
            set: fn (float $value) => (int) ($value * 100),
        );
    }

    // Result: DB stores 1999 (integer cents)
    // PHP sees 19.99 (float dollars)
}

Подвох на экзамене: Если и cast, и accessor определены для одного атрибута, cast выполняется ПЕРВЫМ, затем accessor получает уже cast-ированное значение.

Dirty Checking с Casts

<?php

$user = User::find(1);

// Cast attributes participate in dirty checking
$user->options = ['theme' => 'dark'];
$user->isDirty('options'); // true

// But nested changes with 'array' cast are NOT detected!
$user->options['theme'] = 'light';
$user->isDirty('options'); // false (BUG-like behavior)

// Solution: use AsArrayObject
// or reassign: $user->options = [...$user->options, 'theme' => 'light'];

Date Casting и сериализация

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Event extends Model
{
    protected function casts(): array
    {
        return [
            'starts_at' => 'datetime:Y-m-d H:i',
            'ends_at' => 'immutable_datetime',
        ];
    }

    // Global date serialization format
    protected function serializeDate(\DateTimeInterface $date): string
    {
        return $date->format('Y-m-d H:i:s');
    }
}

$event = Event::find(1);
$event->starts_at; // Carbon instance
$event->starts_at->format('d.m.Y'); // '15.03.2024'
$event->starts_at->diffForHumans(); // '2 days ago'

// immutable_datetime returns CarbonImmutable
$event->ends_at->addDay(); // Returns NEW instance (original unchanged)

Проверь себя

Какой тип Enum поддерживает Eloquent casting?

Чем CastsInboundAttributes отличается от CastsAttributes?

Как custom cast может записать значение в несколько столбцов базы данных одновременно?

Что произойдёт, если определить и cast, и accessor для одного атрибута?

Какой cast нужно использовать для автоматического обнаружения вложенных изменений массива?