Advanced Eloquent Custom Casts &amp; Value Objects | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Advanced Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms

 Advanced Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms
=========================================================================================

 Go beyond built-in Eloquent casts. Learn to build custom cast classes, wrap primitives in value objects, and apply inbound-only transforms — keeping your models clean without leaking domain logic into the database layer.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 27 Jun 2026 · Updated 27 Jun 2026 · 4 min read

ShareCopy linkCopied

 ![Advanced Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms](https://cdn.msaied.com/307/d9832b90141b009f63e8e55ea856cb3a.png) 

  On this page +1. [Why Built-in Casts Are Not Enough](#why-built-in-casts-are-not-enough)
2. [Anatomy of a Custom Cast](#anatomy-of-a-custom-cast)
3. [Value Objects as First-Class Citizens](#value-objects-as-first-class-citizens)
4. [Inbound-Only Casts](#inbound-only-casts)
5. [Casting to Multiple Columns](#casting-to-multiple-columns)
6. [Testing the Cast in Isolation](#testing-the-cast-in-isolation)
7. [Key Takeaways](#key-takeaways)

 Why Built-in Casts Are Not Enough
---------------------------------

Laravel ships with a solid set of primitive casts — `integer`, `boolean`, `array`, `encrypted`, `AsCollection`, and friends. They cover 80% of everyday needs. The remaining 20% is where models quietly accumulate logic they should never own: formatting phone numbers, normalising currency, converting units, enforcing invariants.

Custom cast classes move that responsibility to a dedicated type, tested in isolation, reusable across models.

---

Anatomy of a Custom Cast
------------------------

A cast class implements `CastsAttributes`. The contract is simple:

```php
namespace App\Casts;

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

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

    /** @param int $value raw pence stored in DB */
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return Money::fromMinorUnits((int) $value, $this->currency);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): int
    {
        if ($value instanceof Money) {
            return $value->minorUnits();
        }

        return (int) $value;
    }
}

```

Declare it on the model using the constructor-argument syntax introduced in Laravel 9:

```php
protected function casts(): array
{
    return [
        'price' => MoneyCast::class . ':USD',
        'tax'   => MoneyCast::class,          // defaults to GBP
    ];
}

```

The model now returns a `Money` value object from `$order->price`, and accepts either a `Money` instance or a raw integer on assignment.

---

Value Objects as First-Class Citizens
-------------------------------------

A value object should be immutable and self-validating:

```php
final class Money
{
    private function __construct(
        private readonly int    $minorUnits,
        private readonly string $currency,
    ) {
        if ($minorUnits < 0) {
            throw new \DomainException('Money cannot be negative.');
        }
    }

    public static function fromMinorUnits(int $units, string $currency): self
    {
        return new self($units, strtoupper($currency));
    }

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

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException('Currency mismatch.');
        }
        return new self($this->minorUnits + $other->minorUnits, $this->currency);
    }

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

```

The invariant (`>= 0`) is enforced at construction time — not scattered across service classes.

---

Inbound-Only Casts
------------------

Sometimes you only need to transform data *on the way in* — hashing a token, normalising a slug, uppercasing a country code. Implement `CastsInboundAttributes` instead:

```php
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;

class NormaliseCountryCode implements CastsInboundAttributes
{
    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        return strtoupper(trim((string) $value));
    }
}

```

The `get` side is intentionally absent — the raw database value is returned as-is. This is perfect for write-time normalisation without the overhead of a full bidirectional cast.

---

Casting to Multiple Columns
---------------------------

A single value object can map to *multiple* database columns by returning an array from `set`:

```php
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
    return [
        'amount'   => $value->minorUnits(),
        'currency' => $value->currency(),
    ];
}

public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
    return Money::fromMinorUnits(
        (int) $attributes['amount'],
        $attributes['currency'],
    );
}

```

Declare the virtual key on the model:

```php
'price' => MoneyCast::class,

```

Eloquent will merge the returned array into the dirty attributes automatically.

---

Testing the Cast in Isolation
-----------------------------

Because the cast is a plain PHP class, you can test it without booting the framework:

```php
it('converts minor units to a Money value object', function () {
    $cast  = new MoneyCast('EUR');
    $model = new class extends Model {};

    $money = $cast->get($model, 'price', 1999, []);

    expect($money)->toBeInstanceOf(Money::class)
        ->and($money->minorUnits())->toBe(1999)
        ->and($money->currency())->toBe('EUR');
});

it('rejects negative minor units', function () {
    expect(fn () => Money::fromMinorUnits(-1, 'EUR'))
        ->toThrow(\DomainException::class);
});

```

No database, no HTTP — fast, deterministic, and meaningful.

---

Key Takeaways
-------------

- Use `CastsAttributes` for bidirectional transforms; use `CastsInboundAttributes` when you only need write-time normalisation.
- Pass constructor arguments via the `ClassName:arg1,arg2` syntax to make casts reusable across currencies, locales, or units.
- Return an array from `set` to map a single virtual attribute to multiple physical columns.
- Keep value objects immutable and self-validating — the cast is just the bridge, not the domain logic.
- Test cast classes as plain PHP; no `RefreshDatabase` needed.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [eloquent](https://www.msaied.com/public/articles?search=eloquent)
- [value-objects](https://www.msaied.com/public/articles?search=value-objects)
- [domain-driven-design](https://www.msaied.com/public/articles?search=domain-driven-design)
- [testing](https://www.msaied.com/public/articles?search=testing)

 Frequently asked questions 
---------------------------

  Can a custom cast class be shared across multiple models?Yes. A cast class is a plain PHP class with no model-specific coupling. Declare it in the `casts()` method of any model that needs it, optionally passing constructor arguments to vary behaviour per model.

   What happens if the database column is NULL and my cast tries to construct a value object?The `$value` parameter will be `null`. Guard against it explicitly in `get()` — either return `null` (and type-hint the return as `?Money`) or return a sensible default such as `Money::zero($currency)`.

   Does returning an array from `set()` work with mass assignment and `fill()`?Yes. Eloquent merges the returned array into the model's attributes before persisting. Ensure the physical column names (`amount`, `currency`) are included in `$fillable` or that `$guarded` is empty, otherwise the merge is silently dropped.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

[Mohamed Said](https://www.msaied.com/public#person)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleAdvanced Filament: Custom Field Plugins, Custom Columns, and Render Hooks](https://www.msaied.com/public/articles/advanced-filament-custom-field-plugins-custom-columns-and-render-hooks) [Next articleShip AI with Laravel: Test Your AI System with Zero API Calls](https://www.msaied.com/public/articles/ship-ai-with-laravel-test-your-ai-system-with-zero-api-calls)  

   On this page
-------------

1. [Why Built-in Casts Are Not Enough](#why-built-in-casts-are-not-enough)
2. [Anatomy of a Custom Cast](#anatomy-of-a-custom-cast)
3. [Value Objects as First-Class Citizens](#value-objects-as-first-class-citizens)
4. [Inbound-Only Casts](#inbound-only-casts)
5. [Casting to Multiple Columns](#casting-to-multiple-columns)
6. [Testing the Cast in Isolation](#testing-the-cast-in-isolation)
7. [Key Takeaways](#key-takeaways)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/743/8998fac3a41451ab3fe1588194e17a43.png) Filament · 3 min read### Securing Filament Plugins with Plumb: Automated Security Scoring for PHP Packages

5 Oct 2026 ](https://www.msaied.com/public/articles/securing-filament-plugins-with-plumb-automated-security-scoring-for-php-packages) [ ![](https://cdn.msaied.com/742/2d02018669cdeedccb5de2efb898f0ee.png) Filament · 3 min read### Filament v3.3.56 Released: File Hash Names and Livewire Upload Fix

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v3356-released-file-hash-names-and-livewire-upload-fix) [ ![](https://cdn.msaied.com/741/5b55c123ad08e4d34e1f4b99ad6a428b.png)  · 3 min read### Filament v4 Schema-Based Forms, Infolists, and the Unified Schema API

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v4-schema-based-forms-infolists-and-the-unified-schema-api-5) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
