DEV Community

Cover image for Typed Eloquent boundaries without building a second ORM
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Typed Eloquent boundaries without building a second ORM

Most Laravel teams do not need to "fix" Eloquent. They need to stop letting raw model state leak too far into code that makes real business decisions.

That is the practical version of this debate.

Typed objects around Eloquent can be a big improvement, but only when they are used as boundaries. If you push the pattern too far, you end up with a second object model shadowing the first one. At that point you are not improving Laravel. You are building a parallel ORM that adds mapping code, cognitive load, and friction on every change.

So the right question is not, "Should we replace Eloquent with typed objects?" The right question is, where does untyped Eloquent stop being cheap?

Once you frame it that way, the migration path becomes much clearer. Keep Eloquent where it is good at persistence, hydration, scopes, relationships, and query composition. Introduce typed objects where the shape is messy, the values carry business meaning, or invalid combinations are too easy to represent.

That is the version that pays off.

The Core Recommendation

If you only remember one thing from this article, make it this: add typed boundaries around unstable or meaningful data, not around every model.

That usually means one of four cases:

  • a JSON column that multiple parts of the app interpret differently
  • domain values like money, status, addresses, or billing configuration
  • data crossing from Eloquent into services, jobs, or integrations
  • code paths where stringly typed state has already caused confusion or bugs

Everything else should be guilty until proven useful.

This is where a lot of teams go wrong. They see a good example of typed objects and immediately generalize it into an architecture rule. Then every model gets a FooData, FooView, FooState, FooRecord, and FooMapper. The app becomes more "designed" and less understandable.

A Laravel codebase does not get better because it has more classes. It gets better because responsibility becomes clearer.

Why Raw Eloquent Starts Hurting

Eloquent is deliberately permissive. That is one reason Laravel teams ship quickly with it. Attributes can be strings, arrays, JSON blobs, cast values, nullable timestamps, or whatever the database currently allows. Early on, that flexibility feels productive.

The problems show up later, usually in boring places.

A field that started as a simple JSON blob becomes important to billing or permissions. A string column that once held two status values now holds six, and one of them is only valid after a webhook arrives. A settings array is read by a controller, a queue job, an action class, and an API transformer, and each one assumes slightly different defaults.

At that point, Eloquent is not the problem by itself. The problem is that storage shape and domain meaning are now fused together.

The hidden cost of array-shaped logic

This is the code smell you want to notice early:

if (($user->settings['plan'] ?? 'free') === 'pro' &&
    ($user->settings['trial_ends_at'] ?? null) !== null &&
    ($user->settings['cancel_at_period_end'] ?? false) === false) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

That line is doing at least three jobs:

  • reading a storage format
  • applying defaults
  • expressing business intent

It is also impossible to scan quickly. The logic is not hard, but the shape is noisy. If five different parts of the system do their own version of that, you now have a maintenance problem.

The deeper issue is not style. It is semantic drift. One place defaults plan to free. Another assumes missing plan is invalid. One path reads trial_ends_at as nullable string. Another parses a Carbon instance. Eventually two code paths disagree silently.

Typed boundaries help because they centralize interpretation.

Strings are cheap until they are not

Laravel developers are used to raw strings for status fields, provider names, feature flags, event types, and mode switches. That is fine while the meaning is obvious and local.

It stops being fine when the value crosses process boundaries or starts controlling workflow.

A raw status column with values like draft, published, archived, and scheduled does not look dangerous. But once those values drive API responses, jobs, admin actions, and permissions, the weakness becomes obvious:

  • typos are legal until runtime
  • invalid transitions are hard to guard consistently
  • IDE refactors cannot protect you
  • business rules stay smeared across call sites

A typed object, enum, or small value object is not about code aesthetics here. It is about making the set of legal states more explicit.

What a Good Typed Boundary Looks Like

A good typed boundary does one or more of these things:

  • normalizes messy incoming storage shape
  • enforces a small invariant
  • exposes behavior that belongs with the value
  • gives the rest of the app a predictable interface

It does not exist just to mirror columns one-to-one.

That distinction matters more than people admit.

Example 1: Typed settings around a JSON column

A JSON column is one of the clearest places to introduce a typed object because the raw database shape tends to spread quickly.

Imagine a users.subscription_settings column. The naive version often starts as this:

$user->subscription_settings = [
    'plan' => 'pro',
    'cancel_at_period_end' => false,
    'trial_ends_at' => '2026-06-30T00:00:00Z',
];
Enter fullscreen mode Exit fullscreen mode

That looks harmless. The trouble starts when those keys are read in ten places with ten slightly different assumptions.

A better boundary is a typed object returned from a cast.

<?php

declare(strict_types=1);

namespace App\Domain\Billing;

final readonly class SubscriptionSettings
{
    public function __construct(
        public string $plan,
        public bool $cancelAtPeriodEnd,
        public ?\DateTimeImmutable $trialEndsAt,
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            plan: (string) ($data['plan'] ?? 'free'),
            cancelAtPeriodEnd: (bool) ($data['cancel_at_period_end'] ?? false),
            trialEndsAt: isset($data['trial_ends_at'])
                ? new \DateTimeImmutable((string) $data['trial_ends_at'])
                : null,
        );
    }

    public function toArray(): array
    {
        return [
            'plan' => $this->plan,
            'cancel_at_period_end' => $this->cancelAtPeriodEnd,
            'trial_ends_at' => $this->trialEndsAt?->format(DATE_ATOM),
        ];
    }

    public function isOnTrial(): bool
    {
        return $this->trialEndsAt !== null && $this->trialEndsAt > new \DateTimeImmutable();
    }

    public function isEnterprise(): bool
    {
        return $this->plan === 'enterprise';
    }
}
Enter fullscreen mode Exit fullscreen mode

Then wire it into Eloquent with a cast:

<?php

declare(strict_types=1);

namespace App\Casts;

use App\Domain\Billing\SubscriptionSettings;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

final class SubscriptionSettingsCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): SubscriptionSettings
    {
        $decoded = json_decode($value ?: '{}', true, flags: JSON_THROW_ON_ERROR);

        return SubscriptionSettings::fromArray($decoded);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        if (! $value instanceof SubscriptionSettings) {
            throw new \InvalidArgumentException('Expected SubscriptionSettings instance.');
        }

        return json_encode($value->toArray(), JSON_THROW_ON_ERROR);
    }
}
Enter fullscreen mode Exit fullscreen mode

And in the model:

protected function casts(): array
{
    return [
        'subscription_settings' => \App\Casts\SubscriptionSettingsCast::class,
    ];
}
Enter fullscreen mode Exit fullscreen mode

This is a strong pattern because it improves the code around the model without pretending Eloquent is gone. The storage remains JSON. The domain-facing interface becomes stable.

Why this boundary is worth it

This boundary is useful because it removes repeated interpretation from the rest of the app.

Before:

  • every caller knows the raw keys
  • every caller applies its own defaults
  • every caller decides how to parse dates
  • changing the payload shape is risky

After:

  • one place owns the mapping
  • the type documents the meaning
  • behavior lives with the data
  • the rest of the app deals with a real object

That is a real gain, not architecture theater.

Where Teams Accidentally Build a Parallel ORM

The failure mode is predictable: a good local pattern gets promoted into a universal rule.

Someone introduces typed objects for a messy JSON field. It works well. Then the team starts wrapping every model attribute in custom classes, every query result in DTOs, and every relationship traversal in mirrored object graphs.

Now you have two representations of the same thing:

  • the Eloquent model Laravel uses for persistence and relationships
  • the typed object system your application uses for everything else

That sounds disciplined. In practice, it often means every change hits five layers.

The one-to-one wrapper trap

This is the pattern to be suspicious of:

final readonly class PostData
{
    public function __construct(
        public int $id,
        public string $title,
        public string $body,
        public ?string $publishedAt,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

If that class has no invariant, no normalization, no behavior, and no boundary value, it is probably dead weight.

A one-to-one wrapper around database columns does not automatically create better design. It usually just moves the same ambiguity into another file.

Mapper explosion is a tax, not a virtue

Once every model gets a wrapper, mappers multiply fast:

  • model to data object
  • data object to API resource
  • request payload to domain object
  • domain object back to model

Some of that is necessary in large systems. Most of it is not necessary in a typical Laravel app.

The discipline you want is selective conversion, not universal conversion.

Relationship mirroring is usually the breaking point

The fastest way to overcomplicate this approach is trying to re-model Eloquent relationships as a separate typed graph.

Laravel already gives you a lot here: eager loading, lazy loading, constraints, aggregate helpers, polymorphic relations, scopes, pivot behavior, and query composition. Rebuilding all of that behind a second object model is rarely worth it.

If you end up with TypedUser -> TypedTeam -> TypedSubscription -> TypedPlan, ask yourself whether the code got more explicit or just more indirect.

Most of the time, typed boundaries should sit at meaningful seams, not replace the entire navigation model of the app.

A Better Way to Think About Boundaries in Laravel

Instead of asking "which models should be typed?", ask which movements of data deserve a stronger contract.

That usually leads to better decisions.

Boundary 1: Storage to domain meaning

This is the cast example. A raw database representation becomes a typed value with a stable API.

Good fit:

  • JSON columns n- compound values stored across loose fields
  • normalized status or settings logic

Boundary 2: Domain to external integration

When Eloquent data is sent to Stripe, OpenAI, Slack, or an internal service, raw model state often leaks too much incidental shape.

A typed object or dedicated payload object is useful here because it decouples your internal persistence model from the integration contract.

For example:

final readonly class InvoicePayload
{
    public function __construct(
        public string $customerEmail,
        public int $amountInCents,
        public string $currency,
        public string $description,
    ) {}

    public static function fromOrder(Order $order): self
    {
        return new self(
            customerEmail: $order->customer_email,
            amountInCents: $order->total->amount(),
            currency: $order->total->currency(),
            description: "Order #{$order->id}",
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

That boundary matters because integrations are expensive places to be sloppy.

Boundary 3: Eloquent to async work

Jobs, events, and queued actions are another strong seam.

Passing full models through queues can be fine, but it can also create subtle coupling to later database state. Sometimes the job should see whatever the model looks like when it runs. Sometimes it should operate on a precise snapshot.

A typed payload object is often safer when:

  • the job must preserve exact values from dispatch time
  • only a subset of model data is relevant
  • you want the job contract to stay stable as the model evolves

This is not a rule against queueing models. It is a reminder that async boundaries magnify ambiguity.

Incremental Adoption That Does Not Blow Up the Codebase

The right migration path is boring on purpose. That is a good sign.

Do not start with a grand refactor. Start with a recurring pain point and tighten just that boundary.

Step 1: Find repeated interpretation

Look for code patterns like these:

  • repeated ['some_key'] ?? default access
  • status strings checked in multiple services
  • date parsing scattered across call sites
  • the same normalization logic repeated in requests, jobs, and resources

That is your signal that the raw shape has escaped too far.

Step 2: Introduce one typed object

Pick one value with clear meaning. Do not start with the most central model in the app. Start with something painful but contained.

Good first targets:

  • shipping or billing address
  • subscription settings
  • money totals
  • workflow state object
  • external API payloads

The first win should be easy to explain: we used to interpret this shape everywhere; now we interpret it once.

Step 3: Put behavior with the value

A typed boundary that only carries properties is better than raw arrays, but the larger payoff comes when it exposes behavior.

Bad:

if ($order->shipping_address->country === 'IN' && $order->shipping_address->postalCode !== '') {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Better:

if ($order->shipping_address->isDomesticFor('IN') && $order->shipping_address->hasPostalCode()) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

That is not about hiding all detail. It is about letting the type speak in domain language.

Step 4: Stop when the boundary is doing its job

This is where restraint matters.

Once a typed object solves the ambiguity, do not automatically expand the pattern outward. You do not need a typed object for every neighboring value just because one boundary worked well.

Architecture gets bloated when people confuse a good pattern with a universal pattern.

Example 2: A Shipping Address Object That Actually Earns Its Keep

A shipping address is a good example because it often starts simple and becomes annoying over time.

You begin with a JSON blob or a handful of nullable fields. Then checkout logic, tax calculations, delivery rules, admin review, and label generation all want slightly different things from it.

A typed object helps because address data has both shape and behavior.

<?php

declare(strict_types=1);

namespace App\Domain\Orders;

final readonly class ShippingAddress
{
    public function __construct(
        public string $line1,
        public ?string $line2,
        public string $city,
        public string $state,
        public string $postalCode,
        public string $country,
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            line1: trim((string) ($data['line_1'] ?? '')),
            line2: isset($data['line_2']) ? trim((string) $data['line_2']) : null,
            city: trim((string) ($data['city'] ?? '')),
            state: trim((string) ($data['state'] ?? '')),
            postalCode: trim((string) ($data['postal_code'] ?? $data['postcode'] ?? '')),
            country: strtoupper(trim((string) ($data['country'] ?? ''))),
        );
    }

    public function toArray(): array
    {
        return [
            'line_1' => $this->line1,
            'line_2' => $this->line2,
            'city' => $this->city,
            'state' => $this->state,
            'postal_code' => $this->postalCode,
            'country' => $this->country,
        ];
    }

    public function isDomesticFor(string $countryCode): bool
    {
        return $this->country === strtoupper($countryCode);
    }

    public function hasPostalCode(): bool
    {
        return $this->postalCode !== '';
    }

    public function formattedSingleLine(): string
    {
        return collect([
            $this->line1,
            $this->line2,
            $this->city,
            $this->state,
            $this->postalCode,
            $this->country,
        ])->filter()->implode(', ');
    }
}
Enter fullscreen mode Exit fullscreen mode

This object is doing real work:

  • normalizing postcode vs postal_code
  • normalizing country casing
  • centralizing formatting
  • exposing behavior useful to rules and integrations

That is what "typed object around Eloquent" should usually mean in a Laravel app. Not total abstraction. Just a sharper seam.

Failure Modes to Watch For

Most architectural patterns do not fail because the first example is bad. They fail because teams stop being selective.

Failure mode 1: treating all data as domain data

Some data is just persistence detail. Audit columns, import metadata, view counters, internal sort positions, and similar fields often do not need domain objects.

If there is no meaningful behavior or invariant, raw Eloquent may be the correct level of abstraction.

Failure mode 2: mixing internal types with API shape

Your internal object and your API resource are not the same thing by default.

A domain object should express meaning. An API response should express what clients need. Sometimes those line up. Often they do not.

When you merge them too early, the domain object ends up carrying serialization quirks, presentation formatting, and backwards compatibility baggage.

Keep that split clean unless you have a strong reason not to.

Failure mode 3: assuming stronger types remove validation needs

Typed objects do not replace validation. They complement it.

Requests still need input validation. Database constraints still matter. Integration boundaries still need defensive checks.

A typed object improves how your application represents a value after it enters the system. It does not magically guarantee the outside world behaved.

Failure mode 4: hiding too much behind tiny methods

There is also an opposite failure mode: turning every property read into a method call just to sound domain-driven.

If a type becomes a wall of trivial wrappers, readability suffers again.

The rule is simple: extract behavior when the behavior is meaningful, repeated, or protection-worthy. Do not hide obvious data behind noise.

Testing Strategy That Matches This Pattern

One benefit of typed boundaries is that they make testing narrower and more honest.

You do not need to boot a full Laravel feature test to verify every bit of interpretation logic.

Test the object directly for:

  • normalization rules
  • derived behavior
  • invalid state rejection if applicable
  • serialization back to storage shape

Then add a smaller number of model-level tests to verify the cast integration actually works.

That split is useful because it keeps your business semantics testable without pushing everything through Eloquent every time.

The trap to avoid is writing fragile tests that just reassert the implementation line by line. Test the boundary contract, not the existence of getters.

The Practical Decision Rule

Use typed Eloquent objects when they remove ambiguity, centralize interpretation, or protect meaningful business rules.

Do not use them as a blanket ideology.

If a raw model attribute is read in one place, has obvious meaning, and carries no important invariant, leave it alone. If a value is messy, reused, or expensive to misunderstand, give it a proper type.

That is the sweet spot.

Laravel does not need to be purified away from Eloquent. It needs cleaner seams between storage concerns and application meaning. Typed boundaries are excellent at that when used carefully. They are expensive when used everywhere.

So if you are introducing this pattern into an existing app, start small. Pick one ugly boundary. Add one typed object. Move one cluster of logic into it. Watch whether the surrounding code gets simpler.

If it does, keep going where the same pain exists.

If it does not, stop before your migration story turns into an accidental rewrite.

The goal is not to make the codebase feel more abstract. The goal is to make invalid states harder, business logic clearer, and Eloquent less noisy where it actually matters.


Read the full post on QCode: https://qcode.in/typed-eloquent-boundaries-without-rewriting-your-laravel-app/

Top comments (0)