Navigation

Laravel

Modern Data Type Management with Custom Eloquent Casts

Learn to create powerful Laravel Eloquent custom casts for seamless data transformation. Master type safety, encapsulation, and advanced patterns with examples.

Laravel's Eloquent ORM provides powerful data transformation capabilities through custom casts, allowing developers to seamlessly convert database values to PHP data types and vice versa. Custom casts enable clean, maintainable code by encapsulating complex data transformations and ensuring type safety throughout your application.

Table Of Contents

Understanding Laravel Eloquent Casts

Eloquent casts automatically transform model attributes when accessing or mutating them. While Laravel provides built-in casts for common data types, custom casts unlock advanced data management patterns for complex business requirements.

Why Custom Casts Matter

Modern applications often deal with complex data structures that don't map directly to basic database types. Custom casts provide:

  • Type Safety: Ensure data integrity across your application
  • Encapsulation: Keep transformation logic centralized and reusable
  • Clean Models: Remove clutter from model classes
  • Consistency: Standardize data handling across different models

Creating Your First Custom Cast

Let's start with a practical example: managing monetary values with precision and currency information.

Step 1: Generate the Cast Class

php artisan make:cast MoneyCast

Step 2: Implement the Cast Interface

<?php

namespace App\Casts;

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

class MoneyCast implements CastsAttributes
{
    /**
     * Cast the given value to a Money object.
     */
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
    {
        if ($value === null) {
            return null;
        }

        // Assuming stored as JSON: {"amount": 1000, "currency": "USD"}
        $decoded = json_decode($value, true);
        
        return new Money(
            amount: $decoded['amount'] ?? 0,
            currency: $decoded['currency'] ?? 'USD'
        );
    }

    /**
     * Prepare the given value for storage.
     */
    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if ($value === null) {
            return [$key => null];
        }

        if ($value instanceof Money) {
            return [$key => json_encode([
                'amount' => $value->getAmount(),
                'currency' => $value->getCurrency(),
            ])];
        }

        // Handle array input
        if (is_array($value)) {
            return [$key => json_encode($value)];
        }

        throw new InvalidArgumentException('Value must be a Money instance or array');
    }
}

Step 3: Create the Money Value Object

<?php

namespace App\ValueObjects;

use JsonSerializable;

class Money implements JsonSerializable
{
    public function __construct(
        private int $amount,
        private string $currency = 'USD'
    ) {}

    public function getAmount(): int
    {
        return $this->amount;
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }

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

    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
        );
    }

    public function jsonSerialize(): array
    {
        return [
            'amount' => $this->amount,
            'currency' => $this->currency,
            'formatted' => $this->getFormattedAmount(),
        ];
    }
}

Step 4: Apply the Cast to Your Model

<?php

namespace App\Models;

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

class Product extends Model
{
    protected $fillable = ['name', 'price', 'discount_price'];

    protected $casts = [
        'price' => MoneyCast::class,
        'discount_price' => MoneyCast::class,
    ];

    /**
     * Get the effective selling price.
     */
    public function getSellingPrice(): Money
    {
        return $this->discount_price ?? $this->price;
    }

    /**
     * Calculate total savings if discount applied.
     */
    public function getSavings(): ?Money
    {
        if (!$this->discount_price) {
            return null;
        }

        return $this->price->subtract($this->discount_price);
    }
}

Advanced Cast Patterns

Parameterized Casts

Create flexible casts that accept configuration parameters:

<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class EncryptedCast implements CastsAttributes
{
    public function __construct(
        private string $algorithm = 'AES-256-CBC'
    ) {}

    public function get(Model $model, string $key, mixed $value, array $attributes): ?string
    {
        return $value ? decrypt($value) : null;
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return [$key => $value ? encrypt($value) : null];
    }
}

// Usage in model
protected $casts = [
    'sensitive_data' => EncryptedCast::class . ':AES-256-GCM',
];

Collection Casts for Complex Data Structures

Handle arrays and collections with type safety:

<?php

namespace App\Casts;

use App\ValueObjects\Address;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Collection;

class AddressCollectionCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Collection
    {
        if (!$value) {
            return collect();
        }

        $addresses = json_decode($value, true);
        
        return collect($addresses)->map(function ($addressData) {
            return new Address(
                street: $addressData['street'],
                city: $addressData['city'],
                country: $addressData['country'],
                postalCode: $addressData['postal_code']
            );
        });
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if ($value instanceof Collection) {
            $value = $value->toArray();
        }

        if (is_array($value)) {
            $addresses = array_map(function ($address) {
                if ($address instanceof Address) {
                    return $address->toArray();
                }
                return $address;
            }, $value);

            return [$key => json_encode($addresses)];
        }

        return [$key => null];
    }
}

Database-Aware Casts

Create casts that interact with related data:

<?php

namespace App\Casts;

use App\Models\Category;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class CategoryPathCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection
    {
        if (!$value) {
            return null;
        }

        $categoryIds = json_decode($value, true);
        
        return Category::whereIn('id', $categoryIds)
            ->orderByRaw('FIELD(id, ' . implode(',', $categoryIds) . ')')
            ->get();
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if ($value instanceof Collection) {
            return [$key => json_encode($value->pluck('id')->toArray())];
        }

        if (is_array($value)) {
            return [$key => json_encode($value)];
        }

        return [$key => null];
    }
}

Testing Custom Casts

Ensure your casts work correctly with comprehensive tests:

<?php

namespace Tests\Unit\Casts;

use App\Casts\MoneyCast;
use App\Models\Product;
use App\ValueObjects\Money;
use Tests\TestCase;

class MoneyCastTest extends TestCase
{
    public function test_money_cast_stores_and_retrieves_correctly()
    {
        $product = Product::create([
            'name' => 'Test Product',
            'price' => new Money(amount: 1999, currency: 'USD'),
        ]);

        $this->assertInstanceOf(Money::class, $product->fresh()->price);
        $this->assertEquals(1999, $product->fresh()->price->getAmount());
        $this->assertEquals('USD', $product->fresh()->price->getCurrency());
    }

    public function test_money_cast_handles_null_values()
    {
        $product = Product::create([
            'name' => 'Test Product',
            'price' => null,
        ]);

        $this->assertNull($product->fresh()->price);
    }

    public function test_money_cast_handles_array_input()
    {
        $product = Product::create([
            'name' => 'Test Product',
            'price' => ['amount' => 2999, 'currency' => 'EUR'],
        ]);

        $this->assertEquals(2999, $product->fresh()->price->getAmount());
        $this->assertEquals('EUR', $product->fresh()->price->getCurrency());
    }
}

Best Practices for Custom Casts

1. Keep Casts Focused and Single-Purpose

Each cast should handle one specific data transformation:

// Good: Focused on one responsibility
class EmailCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Email
    {
        return $value ? new Email($value) : null;
    }
}

// Avoid: Multiple responsibilities
class ContactInfoCast implements CastsAttributes
{
    // Handles email, phone, address all in one - too complex
}

2. Handle Edge Cases Gracefully

public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
    // Handle various edge cases
    if ($value === null || $value === '') {
        return null;
    }

    // Handle malformed JSON gracefully
    $decoded = json_decode($value, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        report(new Exception("Invalid JSON in {$key}: {$value}"));
        return null;
    }

    return new Money(
        amount: $decoded['amount'] ?? 0,
        currency: $decoded['currency'] ?? config('app.default_currency', 'USD')
    );
}

3. Optimize Database Queries

For casts that perform database queries, implement caching:

public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection
{
    if (!$value) {
        return collect();
    }

    $categoryIds = json_decode($value, true);
    $cacheKey = 'categories:' . md5($value);

    return Cache::remember($cacheKey, 3600, function () use ($categoryIds) {
        return Category::whereIn('id', $categoryIds)->get();
    });
}

Common Pitfalls to Avoid

1. Circular References in JSON Serialization

// Problematic: Can cause infinite loops
class UserCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): ?User
    {
        return User::find($value); // Could load relationships recursively
    }
}

// Better: Load minimal data or use lazy loading
public function get(Model $model, string $key, mixed $value, array $attributes): ?User
{
    return User::select(['id', 'name', 'email'])->find($value);
}

2. Performance Issues with Complex Casts

// Avoid: N+1 queries in casts
public function get(Model $model, string $key, mixed $value, array $attributes): Collection
{
    $ids = json_decode($value, true);
    return collect($ids)->map(fn($id) => Category::find($id)); // N+1 problem
}

// Better: Batch load related data
public function get(Model $model, string $key, mixed $value, array $attributes): Collection
{
    $ids = json_decode($value, true);
    return Category::whereIn('id', $ids)->get()->keyBy('id');
}

3. Inconsistent Data Types

public function set(Model $model, string $key, mixed $value, array $attributes): array
{
    // Handle multiple input formats consistently
    if ($value instanceof Money) {
        return [$key => $value->toJson()];
    }

    if (is_array($value) && isset($value['amount'])) {
        return [$key => json_encode($value)];
    }

    if (is_numeric($value)) {
        // Assume cents, default currency
        return [$key => json_encode([
            'amount' => (int) $value,
            'currency' => 'USD'
        ])];
    }

    throw new InvalidArgumentException("Invalid money value: " . gettype($value));
}

Conclusion

Custom Eloquent casts provide a powerful mechanism for managing complex data types in Laravel applications. By encapsulating transformation logic in dedicated cast classes, you create more maintainable, type-safe, and reusable code. The key is to keep casts focused, handle edge cases gracefully, and always consider performance implications.

As your application grows, well-designed custom casts become invaluable for maintaining data consistency and reducing code duplication across your models. Consider exploring Laravel's advanced Eloquent features like custom collections and model events to further enhance your data management strategies.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel