Custom Eloquent Casts: Value Objects &amp; Composites | 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. Custom Eloquent Casts: Value Objects, Enums, and Encrypted Composites

 Custom Eloquent Casts: Value Objects, Enums, and Encrypted Composites
======================================================================

 Go beyond primitive storage with Eloquent's CastsAttributes contract. Build reusable value-object casts, handle composite columns, and encrypt sensitive fields without a single accessor.

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

ShareCopy linkCopied

 ![Custom Eloquent Casts: Value Objects, Enums, and Encrypted Composites](https://cdn.msaied.com/659/44f701e1dc43e64d0b7ecc984d0b34bc.png) 

  On this page +1. [Why Custom Casts Beat Accessors and Mutators](#why-custom-casts-beat-accessors-and-mutators)
2. [1. Value-Object Cast](#1-value-object-cast)
3. [2. Composite-Column Cast](#2-composite-column-cast)
4. [3. Encrypted JSON Cast with Constructor Arguments](#3-encrypted-json-cast-with-constructor-arguments)
5. [Testing Casts in Isolation](#testing-casts-in-isolation)
6. [Takeaways](#takeaways)

 Why Custom Casts Beat Accessors and Mutators
--------------------------------------------

Getters and setters scattered across a model are hard to test in isolation and impossible to reuse across models. Laravel's `CastsAttributes` contract gives you a first-class way to encapsulate that logic into a dedicated class, keep your models thin, and make the transformation testable on its own.

This article covers three practical cast patterns: a value-object cast, a composite-column cast, and an encrypted-JSON cast.

---

1. Value-Object Cast
--------------------

Suppose you have a `Money` value object that wraps an integer amount and a currency string.

```php
// app/Values/Money.php
final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}

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

```

The cast serialises to a single JSON column:

```php
// app/Casts/MoneyCast.php
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        $data = json_decode($value, true);
        return new Money($data['amount'], $data['currency']);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        if (! $value instanceof Money) {
            throw new \InvalidArgumentException('Expected Money instance.');
        }
        return json_encode(['amount' => $value->amount, 'currency' => $value->currency]);
    }
}

```

Usage on the model:

```php
protected $casts = [
    'price' => MoneyCast::class,
];

```

---

2. Composite-Column Cast
------------------------

Sometimes a logical concept spans multiple physical columns — for example, a `DateRange` stored as `starts_at` and `ends_at`. Implement `CastsAttributes` and return multiple keys from `set()`:

```php
class DateRangeCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): DateRange
    {
        return new DateRange(
            Carbon::parse($attributes['starts_at']),
            Carbon::parse($attributes['ends_at']),
        );
    }

    /** @return array */
    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return [
            'starts_at' => $value->start->toDateTimeString(),
            'ends_at'   => $value->end->toDateTimeString(),
        ];
    }
}

```

When `set()` returns an array, Eloquent merges those keys directly into the attributes bag — no extra columns needed in `$casts`.

> **Gotcha:** the `$key` passed to `get()` is whatever key you registered in `$casts`. For composite casts, that key is virtual; the real columns live in `$attributes`. Read from `$attributes`, not `$value`.

---

3. Encrypted JSON Cast with Constructor Arguments
-------------------------------------------------

Casts accept constructor arguments via the colon syntax. Here's an encrypted cast that accepts a cipher name:

```php
class EncryptedJson implements CastsAttributes
{
    public function __construct(private string $cipher = 'AES-256-CBC') {}

    public function get(Model $model, string $key, mixed $value, array $attributes): array
    {
        return json_decode(decrypt($value), true);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        return encrypt(json_encode($value));
    }
}

```

Register it with an argument:

```php
protected $casts = [
    'metadata' => EncryptedJson::class . ':AES-128-CBC',
];

```

Laravel resolves the constructor automatically, passing the string after the colon as the first argument.

---

Testing Casts in Isolation
--------------------------

Because a cast is a plain PHP class, you can unit-test it without a database:

```php
it('round-trips a Money value object', function () {
    $cast  = new MoneyCast();
    $model = new class extends \Illuminate\Database\Eloquent\Model {};

    $json = $cast->set($model, 'price', new Money(1999, 'USD'), []);
    $back = $cast->get($model, 'price', $json, []);

    expect($back->amount)->toBe(1999)
        ->and($back->currency)->toBe('USD');
});

```

No factories, no migrations, no HTTP overhead.

---

Takeaways
---------

- `CastsAttributes` replaces accessor/mutator pairs with a reusable, testable class.
- Return an `array` from `set()` to hydrate multiple physical columns from one logical cast.
- Pass constructor arguments with the `ClassName:arg` colon syntax.
- Casts are plain PHP — unit-test them without touching the database.
- Combine with `readonly` value objects for immutable domain primitives that are impossible to corrupt.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [eloquent](https://www.msaied.com/public/articles?search=eloquent)
- [ddd](https://www.msaied.com/public/articles?search=ddd)
- [php](https://www.msaied.com/public/articles?search=php)

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

  Can a custom cast hydrate columns that are not the key registered in $casts?Yes. When set() returns an associative array, Eloquent merges every key in that array into the model's attributes, regardless of which key was registered in $casts. This is how composite casts spanning multiple columns work.

   How do I pass runtime arguments to a custom cast?Use the colon syntax in $casts: 'column' =&gt; MyCast::class . ':arg1,arg2'. Laravel splits on the colon and passes the comma-separated values as positional constructor arguments to the cast class.

   Should I implement CastsInboundAttributes instead of CastsAttributes?Use CastsInboundAttributes when you only need to transform data on the way into the database (e.g., hashing a password) and want the raw stored value returned on get. CastsAttributes is the right choice when both directions need transformation.

   ![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 articleLaravel Macro-Free Extensibility: Custom Query Builder Classes and Fluent Scopes](https://www.msaied.com/public/articles/laravel-macro-free-extensibility-custom-query-builder-classes-and-fluent-scopes) [Next articleLaravel Horizon: Queue Metrics, Supervisor Tuning, and Safe Deployments](https://www.msaied.com/public/articles/laravel-horizon-queue-metrics-supervisor-tuning-and-safe-deployments)  

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

1. [Why Custom Casts Beat Accessors and Mutators](#why-custom-casts-beat-accessors-and-mutators)
2. [1. Value-Object Cast](#1-value-object-cast)
3. [2. Composite-Column Cast](#2-composite-column-cast)
4. [3. Encrypted JSON Cast with Constructor Arguments](#3-encrypted-json-cast-with-constructor-arguments)
5. [Testing Casts in Isolation](#testing-casts-in-isolation)
6. [Takeaways](#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/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  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)
