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
- Creating Your First Custom Cast
- Advanced Cast Patterns
- Testing Custom Casts
- Best Practices for Custom Casts
- Common Pitfalls to Avoid
- Conclusion
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.
Add Comment
No comments yet. Be the first to comment!