Advanced Eloquent Custom Casts &amp; Value Objects | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com) [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles  ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

  [ home ](https://www.msaied.com)    [ articles ](https://www.msaied.com/articles)    Advanced Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms        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)

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

  #laravel   #eloquent   #value-objects   #domain-driven-design   #testing  

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

     27 Jun 2026      4 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

1. [  01   Why Built-in Casts Are Not Enough  ](#why-built-in-casts-are-not-enough)
2. [  02   Anatomy of a Custom Cast  ](#anatomy-of-a-custom-cast)
3. [  03   Value Objects as First-Class Citizens  ](#value-objects-as-first-class-citizens)
4. [  04   Inbound-Only Casts  ](#inbound-only-casts)
5. [  05   Casting to Multiple Columns  ](#casting-to-multiple-columns)
6. [  06   Testing the Cast in Isolation  ](#testing-the-cast-in-isolation)
7. [  07   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.

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fadvanced-eloquent-casts-custom-cast-classes-value-objects-and-inbound-only-transforms&text=Advanced+Eloquent+Casts%3A+Custom+Cast+Classes%2C+Value+Objects%2C+and+Inbound-Only+Transforms) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fadvanced-eloquent-casts-custom-cast-classes-value-objects-and-inbound-only-transforms) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  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. 

      Q02  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)`. 

      Q03  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. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://www.msaied.com/articles) 

 [ ![Octane Worker Lifecycle, State Leakage, and Memory Management in Production](https://cdn.msaied.com/554/8cc265358b47e59601a66d1e247eba9a.png) laravel octane performance 

### Octane Worker Lifecycle, State Leakage, and Memory Management in Production

Laravel Octane keeps workers alive across requests, which means static state, resolved singletons, and stale d...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 16 Aug 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/octane-worker-lifecycle-state-leakage-and-memory-management-in-production-2) [ ![Job Batching with Laravel Horizon: Reliable Async Workflows at Scale](https://cdn.msaied.com/553/b794b736bfd84f3cbcc6218319916544.png) laravel queues horizon 

### Job Batching with Laravel Horizon: Reliable Async Workflows at Scale

Learn how to combine Laravel's job batching API with Horizon's queue supervision to build fault-tolerant async...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 15 Aug 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/job-batching-with-laravel-horizon-reliable-async-workflows-at-scale) [ ![Contextual Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms](https://cdn.msaied.com/552/a7825c0c6f53d934f84fce522573eafb.png) laravel eloquent value-objects 

### Contextual Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms

Go beyond primitive casts. Learn how to build custom Eloquent cast classes that hydrate value objects, handle...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 15 Aug 2026     1 min read  

  Read    

 ](https://www.msaied.com/articles/contextual-eloquent-casts-custom-cast-classes-value-objects-and-inbound-only-transforms) 

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

Explore

- [Home](https://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [Contact](https://www.msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
