Beyond Simple Scalars: Elevating Domain Logic with Laravel Eloquent Custom Casts

Beyond Simple Scalars: Elevating Domain Logic with Laravel Eloquent Custom Casts

Mastering Laravel Eloquent Custom Casts for Value Objects

The Problem with Anemic Models

In many Laravel applications, we often treat our Eloquent models as simple Data Transfer Objects (DTOs) that wrap a database row. We retrieve a string, we manipulate a string, and we save a string. However, as an application grows in complexity, this 'primitive obsession' leads to what is known as an Anemic Domain Model. Business logic starts leaking into controllers, service classes, or worse—scattered across blade templates.

Consider a common scenario: handling monetary values. Storing money as a float is a recipe for rounding errors, so we store it as an integer representing cents. Without custom casting, every time you want to display or manipulate that price, you have to remember to divide by 100, format the currency, or handle exchange rates manually. This is where Laravel's CastsAttributes interface becomes a game-changer.

What are Custom Casts?

Laravel has long provided built-in casting for types like json, datetime, and boolean. Custom Casts allow you to define your own transformation logic that happens automatically when you get or set an attribute on an Eloquent model. This allows you to convert raw database values into rich 'Value Objects'—immutable objects that represent a descriptive entity in your domain.

Defining the Value Object

Before we implement the cast, let's define a Money Value Object. Using PHP 8.2+ features like readonly properties and constructor promotion, we can create a robust, immutable object.

namespace App\Domain\Values;

use InvalidArgumentException;

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

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

    public static function fromDecimal(float $decimal, string $currency = 'USD'): self
    {
        return new self((int) round($decimal * 100), $currency);
    }
}

Implementing the CastsAttributes Interface

To bridge the gap between our Money object and the database, we create a casting class. This class must implement the Illuminate\Contracts\Database\Eloquent\CastsAttributes interface, which requires two methods: get and set.

namespace App\Casts;

use App\Domain\Values\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;

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

        // Assume we store currency in a separate column or use a default
        $currency = $attributes['currency'] ?? 'USD';

        return new Money((int) $value, $currency);
    }

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

        if (!$value instanceof Money) {
            throw new InvalidArgumentException('The value must be an instance of Money.');
        }

        return [
            $key => $value->amount,
            'currency' => $value->currency,
        ];
    }
}

Applying the Cast to Your Model

Now, we simply reference the cast in the $casts property of our Eloquent model. This tells Laravel that whenever we interact with the price attribute, it should use our MoneyCast logic.

namespace App\Models;

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

class Product extends Model
{
    protected $casts = [
        'price' => MoneyCast::class,
    ];
}

The Practical Benefits in Your Workflow

By using this pattern, your code becomes significantly cleaner and more expressive. Instead of manual math, you interact with the domain object directly.

$product = Product::find(1);

// The 'get' method of the cast is triggered here
echo $product->price->getFormatted(); // "49.99 USD"

// The 'set' method ensures we only save valid Money objects
$product->price = new Money(5000, 'EUR');
$product->save();

This approach offers several advantages for mid-level and senior developers:

  • Encapsulation: Logic for formatting and currency conversion lives in the Money class, not the Model or Controller.
  • Type Safety: By requiring a Money instance in the set method, you prevent invalid data types from reaching your database.
  • Consistency: Every developer on your team will interact with monetary values in the exact same way across the entire application.

Advanced Usage: Casting Complex JSON Structures

Custom casts aren't limited to simple integers. They are incredibly powerful for handling complex JSON configurations stored in a single column. Imagine a UserSetting object that handles notification preferences. Instead of decoding a JSON array and checking keys manually, you can cast it to a dedicated Settings DTO.

public function get($model, $key, $value, $attributes)
{
    $data = json_decode($value, true) ?: [];
    return new UserSettings(
        emailNotifications: $data['email'] ?? false,
        darkMode: $data['theme'] === 'dark',
        timezone: $data['tz'] ?? 'UTC'
    );
}

This allows you to add helper methods to your UserSettings object, such as $user->settings->wantsEmail(), further cleaning up your conditional logic throughout the app.

Testing Your Custom Casts

One of the hallmarks of a senior engineer is ensuring that these architectural components are unit tested. Testing a cast is straightforward because it doesn't necessarily require a database connection if you test the get and set methods in isolation.

public function test_it_casts_integer_to_money_object()
{
    $cast = new MoneyCast();
    $model = new Product();
    
    $result = $cast->get($model, 'price', 1000, ['currency' => 'USD']);

    $this->assertInstanceOf(Money::class, $result);
    $this->assertEquals(1000, $result->amount);
    $this->assertEquals('USD', $result->currency);
}

Conclusion

Laravel's Custom Casts represent a shift from purely relational thinking to domain-centric design. By moving away from primitive types and toward rich Value Objects, you reduce the surface area for bugs and make your codebase significantly easier to navigate. Whether you are handling currencies, coordinates, encrypted payloads, or complex JSON structures, Custom Casts provide the perfect bridge between your database and your business logic.

Next time you find yourself repeating the same formatting logic or validation for a specific attribute, ask yourself: 'Should this be a Custom Cast?' In most cases, the answer is a resounding yes.

Selim Görmüş
Written by
Selim Görmüş

0 Comments

Share your thoughts

Your email address will not be published. Required fields are marked *

To leave a comment, please sign in to your account.