Custom Eloquent Casts for Domain Logic in Laravel | 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: Encapsulating Domain Logic Inside Model Attributes

 Custom Eloquent Casts: Encapsulating Domain Logic Inside Model Attributes
==========================================================================

 Custom Eloquent casts let you push value-object logic directly into model attributes, keeping controllers and services thin. This article shows how to build production-grade casts with validation, serialization, and testability.

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

ShareCopy linkCopied

 ![Custom Eloquent Casts: Encapsulating Domain Logic Inside Model Attributes](https://cdn.msaied.com/238/8e843e57a34f81f853eedefae629c09b.png) 

  On this page +1. [Why Custom Casts Beat Accessor/Mutator Pairs](#why-custom-casts-beat-accessormutator-pairs)
2. [The Interface Contract](#the-interface-contract)
3. [Registering the Cast](#registering-the-cast)
4. [Parameterised Casts](#parameterised-casts)
5. [Inbound-Only Casts](#inbound-only-casts)
6. [Testing Your Cast in Isolation](#testing-your-cast-in-isolation)
7. [Handling Null Gracefully](#handling-null-gracefully)
8. [When Not to Use a Cast](#when-not-to-use-a-cast)
9. [Takeaways](#takeaways)

 Why Custom Casts Beat Accessor/Mutator Pairs
--------------------------------------------

Before Laravel 8 introduced `CastsAttributes`, the standard approach was a `getXAttribute` / `setXAttribute` pair. That works, but it scatters transformation logic across two methods, makes the intent implicit, and is impossible to reuse across models. A custom cast is a self-contained class: one place to read, one place to write, and trivially injectable into any model.

### The Interface Contract

```php
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

/**
 * @implements CastsAttributes
 */
class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        if (is_null($value)) {
            return Money::zero();
        }

        $decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);

        return new Money(
            amount: $decoded['amount'],
            currency: Currency::from($decoded['currency'])
        );
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        if ($value instanceof Money) {
            return json_encode([
                'amount'   => $value->amount,
                'currency' => $value->currency->value,
            ], JSON_THROW_ON_ERROR);
        }

        throw new \InvalidArgumentException('Value must be a Money instance.');
    }
}

```

The generic annotation on the `@implements` docblock is picked up by PHPStan and Psalm, giving you typed attribute access without any extra stubs.

### Registering the Cast

```php
class Order extends Model
{
    protected $casts = [
        'total' => MoneyCast::class,
    ];
}

```

Now `$order->total` is always a `Money` object. No defensive `instanceof` checks in your service layer.

### Parameterised Casts

Sometimes you need the cast to behave differently per attribute — for example, rounding precision. Pass arguments via the colon syntax:

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

```

Receive them in the constructor:

```php
class MoneyCast implements CastsAttributes
{
    public function __construct(protected int $precision = 2) {}

    // get / set use $this->precision
}

```

### Inbound-Only Casts

If you only need to transform on write (e.g., hashing a PIN), implement `CastsInboundAttributes` instead. It has only a `set` method, which makes the intent explicit and prevents accidental reads of the raw value.

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

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

```

### Testing Your Cast in Isolation

Because the 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 Order();

    $json = $cast->set($model, 'total', new Money(1999, Currency::GBP), []);
    $back = $cast->get($model, 'total', $json, []);

    expect($back->amount)->toBe(1999)
        ->and($back->currency)->toBe(Currency::GBP);
});

```

No factories, no migrations, no HTTP — just fast, focused assertions.

### Handling Null Gracefully

Eloquent passes `null` to `get` when the column is `NULL`. Always decide explicitly: return a null object, throw, or return `null` and mark the property nullable in your type hint. Returning a null object (like `Money::zero()`) is usually the safest choice for arithmetic-heavy domains.

### When Not to Use a Cast

Casts are evaluated on every attribute access. If your value object is expensive to construct (e.g., it calls a service or parses a large blob), consider lazy construction or caching the result in a model property instead.

Takeaways
---------

- `CastsAttributes` gives you a single, reusable class for bidirectional transformation — no more scattered accessor/mutator pairs.
- Parameterised casts via the colon syntax keep one class flexible across multiple attributes.
- Use `CastsInboundAttributes` for write-only transformations like hashing.
- Casts are plain PHP classes: unit-test them without a database for fast feedback loops.
- Always handle `null` explicitly to avoid silent type errors downstream.
- Avoid expensive operations inside `get`; casts run on every property read.

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

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

  Can a custom cast return null instead of a value object?Yes. If the column is nullable and a null object pattern does not fit your domain, simply return null from `get` and annotate the property type as nullable. Just be consistent so callers always know what to expect.

   Do custom casts work with Eloquent's `isDirty` and `getChanges` methods?Yes, but Eloquent compares the raw stored value, not the cast object. If you need dirty-checking on the value object level, override `castForComparison` or compare objects yourself in a model observer.

   Is there a performance cost to using custom casts on frequently accessed attributes?The cast's `get` method runs on every attribute access unless you cache the result. For lightweight value objects the overhead is negligible, but for expensive construction consider storing the result in a model property after the first access.

   ![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 article'The Story of PHP' Documentary Teaser Is Out — Watch It Now](https://www.msaied.com/public/articles/the-story-of-php-documentary-teaser-is-out-watch-it-now) [Next articleMonitor Laravel Queues, Commands, and Schedulers on Any Driver with Vigilance](https://www.msaied.com/public/articles/monitor-laravel-queues-commands-and-schedulers-on-any-driver-with-vigilance)  

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

1. [Why Custom Casts Beat Accessor/Mutator Pairs](#why-custom-casts-beat-accessormutator-pairs)
2. [The Interface Contract](#the-interface-contract)
3. [Registering the Cast](#registering-the-cast)
4. [Parameterised Casts](#parameterised-casts)
5. [Inbound-Only Casts](#inbound-only-casts)
6. [Testing Your Cast in Isolation](#testing-your-cast-in-isolation)
7. [Handling Null Gracefully](#handling-null-gracefully)
8. [When Not to Use a Cast](#when-not-to-use-a-cast)
9. [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/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)
