Advanced Laravel
Advanced Eloquent: Custom Casts & Value Objects
Understanding Custom Casts
Laravel's custom casts allow you to convert database values to rich PHP objects automatically when retrieving models. This enables you to work with complex data types while maintaining clean, object-oriented code.
Why Custom Casts? They encapsulate data transformation logic, ensure data consistency, enable type safety, and make your models work with value objects instead of primitive types.
Built-in Casts Review
Laravel provides several built-in casts out of the box:
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $casts = [
// Scalar types
'age' => 'integer',
'price' => 'float',
'active' => 'boolean',
'bio' => 'string',
// Date/Time
'email_verified_at' => 'datetime',
'published_at' => 'date',
'created_at' => 'timestamp',
// Collections
'options' => 'array',
'metadata' => 'json',
'tags' => 'collection',
// Object
'settings' => 'object',
// Encrypted
'secret' => 'encrypted',
'encrypted_array' => 'encrypted:array',
'encrypted_json' => 'encrypted:json',
];
}
Creating Custom Cast Classes
Custom cast classes implement the CastsAttributes interface with get() and set() methods.
// Generate cast class
php artisan make:cast MoneyValueObject
// app/Casts/MoneyValueObject.php
namespace App\Casts;
use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class MoneyValueObject implements CastsAttributes
{
/**
* Cast the given value (from database to model)
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
// Convert database value to Money value object
if (is_null($value)) {
return new Money(0, $attributes['currency'] ?? 'USD');
}
return new Money(
(float) $value,
$attributes['currency'] ?? 'USD'
);
}
/**
* Prepare the given value for storage (from model to database)
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
if (!$value instanceof Money) {
throw new \InvalidArgumentException('Value must be a Money instance');
}
return [
$key => $value->amount(),
'currency' => $value->currency(),
];
}
}
Value Object Implementation
// app/ValueObjects/Money.php
namespace App\ValueObjects;
class Money
{
private float $amount;
private string $currency;
public function __construct(float $amount, string $currency = 'USD')
{
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
$this->amount = $amount;
$this->currency = strtoupper($currency);
}
public function amount(): float
{
return $this->amount;
}
public function currency(): string
{
return $this->currency;
}
public function add(Money $other): self
{
$this->ensureSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(Money $other): self
{
$this->ensureSameCurrency($other);
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(float $multiplier): self
{
return new self($this->amount * $multiplier, $this->currency);
}
public function divide(float $divisor): self
{
if ($divisor == 0) {
throw new \InvalidArgumentException('Cannot divide by zero');
}
return new self($this->amount / $divisor, $this->currency);
}
public function format(): string
{
$symbols = [
'USD' => '$',
'EUR' => '€',
'GBP' => '£',
'JPY' => '¥',
];
$symbol = $symbols[$this->currency] ?? $this->currency;
return $symbol . number_format($this->amount, 2);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
private function ensureSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Cannot operate on different currencies: {$this->currency} and {$other->currency}"
);
}
}
public function __toString(): string
{
return $this->format();
}
}
Using Custom Casts in Models
namespace App\Models;
use App\Casts\MoneyValueObject;
use App\ValueObjects\Money;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = ['name', 'price', 'currency', 'cost', 'sale_price'];
protected $casts = [
'price' => MoneyValueObject::class,
'cost' => MoneyValueObject::class,
'sale_price' => MoneyValueObject::class,
];
// Now you work with Money objects instead of floats
public function profit(): Money
{
return $this->price->subtract($this->cost);
}
public function profitMargin(): float
{
if ($this->price->amount() === 0.0) {
return 0.0;
}
return ($this->profit()->amount() / $this->price->amount()) * 100;
}
public function applyDiscount(float $percentage): Money
{
$discount = 1 - ($percentage / 100);
return $this->price->multiply($discount);
}
}
// Usage
$product = Product::find(1);
// price is automatically a Money object
echo $product->price->format(); // $99.99
// Perform operations
$profit = $product->profit();
echo "Profit: {$profit->format()}"; // Profit: $30.00
// Create new product with Money object
$newProduct = Product::create([
'name' => 'Laptop',
'price' => new Money(1299.99, 'USD'),
'cost' => new Money(899.99, 'USD'),
]);
// The Money object is automatically converted to database values
// price = 1299.99, currency = 'USD'
Inbound and Outbound Casting
You can create casts that only work in one direction using CastsInboundAttributes.
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Database\Eloquent\Model;
class UppercaseCast implements CastsInboundAttributes
{
/**
* Only handles setting values (model to database)
* No get() method needed
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return strtoupper($value);
}
}
class User extends Model
{
protected $casts = [
'name' => UppercaseCast::class,
];
}
$user = new User();
$user->name = 'john doe';
$user->save(); // Stored as 'JOHN DOE'
echo $user->name; // Outputs: JOHN DOE (raw value from database)
Cast Parameters
Custom casts can accept parameters to customize their behavior.
Syntax: Use colon to pass parameters:
'field' => CastClass::class . ':param1,param2'
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class RoundedFloat implements CastsAttributes
{
public function __construct(private int $decimals = 2) {}
public function get(Model $model, string $key, mixed $value, array $attributes): float
{
return round((float) $value, $this->decimals);
}
public function set(Model $model, string $key, mixed $value, array $attributes): float
{
return round((float) $value, $this->decimals);
}
}
class Product extends Model
{
protected $casts = [
'price' => RoundedFloat::class . ':2', // 2 decimals
'weight' => RoundedFloat::class . ':3', // 3 decimals
'tax_rate' => RoundedFloat::class . ':4', // 4 decimals
];
}
$product = new Product();
$product->price = 19.999; // Stored and retrieved as 20.00
$product->weight = 1.23456; // Stored and retrieved as 1.235
Complex Value Object: Email
// app/ValueObjects/Email.php
namespace App\ValueObjects;
class Email
{
private string $address;
public function __construct(string $address)
{
if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email address: {$address}");
}
$this->address = strtolower($address);
}
public function address(): string
{
return $this->address;
}
public function domain(): string
{
return substr(strrchr($this->address, "@"), 1);
}
public function username(): string
{
return strstr($this->address, '@', true);
}
public function isGmail(): bool
{
return $this->domain() === 'gmail.com';
}
public function obfuscate(): string
{
$username = $this->username();
$obfuscated = substr($username, 0, 2)
. str_repeat('*', strlen($username) - 2)
. '@' . $this->domain();
return $obfuscated;
}
public function equals(Email $other): bool
{
return $this->address === $other->address;
}
public function __toString(): string
{
return $this->address;
}
}
// app/Casts/EmailValueObject.php
namespace App\Casts;
use App\ValueObjects\Email;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class EmailValueObject implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?Email
{
return $value ? new Email($value) : null;
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (is_null($value)) {
return null;
}
if (is_string($value)) {
$value = new Email($value);
}
if (!$value instanceof Email) {
throw new \InvalidArgumentException('Value must be an Email instance or string');
}
return $value->address();
}
}
// Model usage
class User extends Model
{
protected $casts = [
'email' => EmailValueObject::class,
];
}
$user = User::find(1);
echo $user->email->domain(); // gmail.com
echo $user->email->obfuscate(); // jo***@gmail.com
Working with JSON Casts
// app/ValueObjects/Address.php
namespace App\ValueObjects;
class Address
{
public function __construct(
public string $street,
public string $city,
public string $state,
public string $zipCode,
public string $country
) {}
public function fullAddress(): string
{
return "{$this->street}, {$this->city}, {$this->state} {$this->zipCode}, {$this->country}";
}
public function toArray(): array
{
return [
'street' => $this->street,
'city' => $this->city,
'state' => $this->state,
'zip_code' => $this->zipCode,
'country' => $this->country,
];
}
public static function fromArray(array $data): self
{
return new self(
$data['street'] ?? '',
$data['city'] ?? '',
$data['state'] ?? '',
$data['zip_code'] ?? '',
$data['country'] ?? ''
);
}
}
// app/Casts/AddressValueObject.php
namespace App\Casts;
use App\ValueObjects\Address;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class AddressValueObject implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?Address
{
if (is_null($value)) {
return null;
}
$data = json_decode($value, true);
return Address::fromArray($data);
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (is_null($value)) {
return null;
}
if (is_array($value)) {
$value = Address::fromArray($value);
}
if (!$value instanceof Address) {
throw new \InvalidArgumentException('Value must be an Address instance');
}
return json_encode($value->toArray());
}
}
// Model
class Customer extends Model
{
protected $casts = [
'shipping_address' => AddressValueObject::class,
'billing_address' => AddressValueObject::class,
];
}
// Usage
$customer = new Customer();
$customer->shipping_address = new Address(
'123 Main St',
'New York',
'NY',
'10001',
'USA'
);
echo $customer->shipping_address->fullAddress();
// 123 Main St, New York, NY 10001, USA
Using PHP 8.1+ Enums
Laravel 9+ has built-in support for PHP enums as casts.
// app/Enums/OrderStatus.php
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 => 'Pending Payment',
self::PROCESSING => 'Processing Order',
self::SHIPPED => 'Shipped',
self::DELIVERED => 'Delivered',
self::CANCELLED => 'Cancelled',
};
}
public function color(): string
{
return match($this) {
self::PENDING => 'yellow',
self::PROCESSING => 'blue',
self::SHIPPED => 'purple',
self::DELIVERED => 'green',
self::CANCELLED => 'red',
};
}
public function isCompleted(): bool
{
return in_array($this, [self::DELIVERED, self::CANCELLED]);
}
}
// Model
use App\Enums\OrderStatus;
class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class,
];
}
// Usage
$order = Order::find(1);
// Type-safe enum
if ($order->status === OrderStatus::PENDING) {
// Process payment
}
echo $order->status->label(); // Pending Payment
echo $order->status->color(); // yellow
// Query by enum
$pendingOrders = Order::where('status', OrderStatus::PENDING)->get();
// Create with enum
$order = Order::create([
'status' => OrderStatus::PENDING,
'total' => 99.99,
]);
Performance Note: Value objects are created on every model access. For high-performance scenarios, consider caching or lazy loading complex value objects.
Exercise 1: Create a Phone value object with cast:
- Validate phone number format (E.164 or national)
- Methods: format(), isInternational(), countryCode()
- Support multiple formats (US, international, etc.)
- Use in User model for phone number field
Exercise 2: Build a Coordinates value object with cast:
- Store latitude and longitude as JSON
- Methods: distanceTo(), isValid(), toGoogleMapsUrl()
- Validate coordinate ranges (-90 to 90 for lat, -180 to 180 for long)
- Use Haversine formula for distance calculation
Exercise 3: Create an ImageDetails value object:
- Properties: url, width, height, alt text, mime type
- Methods: thumbnail(), resize(), aspectRatio()
- Store as JSON in database
- Create custom cast with validation
Best Practices: Use value objects for complex data with behavior (Money, Email). Use enums for fixed sets of values (Status, Role). Use simple casts for basic transformations (dates, booleans). Always validate in value object constructors.