Skip to main content
Development

Laravel Expressive: The Typed Object Boundary Your Laravel App Has Been Missing.

12 minutes

Laravel Expressive: The Typed Object Boundary Your Laravel App Has Been Missing

I've been a developer now for a long time and over the years I've had the pleasure of being able to use different frameworks to build different applications. From CodeIgniter, Zend through to CakePHP and of course Laravel. When I first encountered the way CakePHP handled data, I thought it a bit odd. Why on earth do we have a PostsTable and a Post entity? They both represent posts. They're both doing post-related things. Why split them? Why and entity and a table class it just didnt make sense.

Then I started actually using it. And slowly it clicked.

The table object is responsible for database behaviour - querying, associations, saving. The entity is responsible for your data - its shape, its logic, its rules as an object. They're not the same thing, even though they both touch the same table. Conflating them just because it's convenient is the cause of a lot of pain in the long run.

Fast forward to Laravel, and for the most part we throw everything into the Eloquent model and call it a day. It handles querying, relationships, casting, visibility, mass assignment, business logic via accessors and mutators - the lot. It's brilliant for getting things done quickly. But as applications grow, that convenience starts to cost you, you could end up with very large models with lots of methods.

I saw a new package land recently by Wendell Adriel who is part of the core Laravel team. The package is called Laravel Expressive, and it's brought that CakePHP lightbulb moment rushing back.

The Problem With Passing Eloquent Models Everywhere

Here at Jump24, we already lean heavily on DTOs for moving data through our applications. When a form is submitted, we're not passing the raw request or a model around - we're mapping to a typed object and passing that to our actions and services. Spatie's Laravel Data package is a popular choice for this, and we've used custom DTO classes too.

The problem Expressive solves is slightly different though. It's not about form input. It's about read models - what you pass around after you've fetched data from the database.

When you fetch an Eloquent model and pass it through several layers of your application, a few things happen that aren't great:

  • Any layer of your application can call save() on it. Accidentally.

  • Properties are resolved via magic __get() - there's no type safety, no IDE autocomplete beyond what plugins can infer.

  • Your service classes and actions become coupled to Eloquent. Unit testing gets messy because you're either hitting a database or partially mocking an Eloquent model - neither of which is fun.

  • The full database representation bleeds into your domain logic. Need to change how a column is named? Good luck finding everywhere that touches $user->email_verified_at vs $user->emailVerifiedAt.

We've solved parts of this with DTOs and resources, but the flow of "convert model to typed object for the domain, convert back to model for persistence" has always been a bit of a manual affair.

That's exactly what Expressive makes first-class.

What Laravel Expressive Actually Is

Expressive converts Eloquent models into typed PHP objects with public, typed properties - and can convert them back for persistence. Eloquent keeps doing what Eloquent does: querying, relationships, casts, mass assignment, database writes. Your services, actions, and tests get a clean typed object that has nothing to do with the database layer.

It was released as v1.0.0 literally days ago (28th May 2026), so the timing couldn't be better to dig into it.

Install it via Composer:

shell
1composer require wendelladriel/laravel-expressive

Then publish the config if you need to customise the defaults:

php
1php artisan vendor:publish --tag="expressive"

The package requires PHP 8.3 or higher and supports Laravel 12 and 13.

Opting Your Model In

First, add the IsExpressive trait to the models you want to use with Expressive. Let's use an Order model as our example - a realistic scenario we see in many of the applications we build:

php
1<?php
2 
3namespace App\Models;
4 
5use App\Enums\OrderStatus;
6use Illuminate\Database\Eloquent\Attributes\Fillable;
7use Illuminate\Database\Eloquent\Model;
8use Illuminate\Database\Eloquent\Relations\HasMany;
9use WendellAdriel\Expressive\Concerns\IsExpressive;
10 
11#[Fillable(['customer_name', 'customer_email', 'status', 'total_pence'])]
12class Order extends Model
13{
14 use IsExpressive;
15 
16 public function lines(): HasMany
17 {
18 return $this->hasMany(OrderLine::class);
19 }
20 
21 protected function casts(): array
22 {
23 return [
24 'status' => OrderStatus::class,
25 'placed_at' => 'datetime',
26 ];
27 }
28}

Nothing dramatic there. The IsExpressive trait adds a single expressive() method - it doesn't change how your model behaves otherwise.

Generating Your Expressive Class

Now scaffold the typed object from your model using the Artisan command:

shell
1php artisan make:expressive Order --model="App\Models\Order"

By default this creates App\Expressive\Order. Wendell has been smart about the namespace resolution - as long as your Expressive class lives in the configured namespace and has the same base name as its model, everything is resolved automatically. No extra config needed.

There's also a sync command worth knowing about:

shell
1php artisan expressive:sync Order --model="App\Models\Order"

This checks whether your Expressive class has drifted from the underlying model's shape. If a column gets added in a migration and the Expressive class isn't updated to match, the sync command flags the gap. It won't overwrite intentional customisations - it just reports the differences so you can decide what's expected and what's been forgotten. That makes it a useful addition to your CI pipeline once you start relying on Expressive more heavily.

Here's what the generated class looks like, once we tidy it up to reflect our domain:

php
1<?php
2 
3namespace App\Expressive;
4 
5use App\Enums\OrderStatus;
6use App\Models\Order as OrderModel;
7use Carbon\CarbonInterface;
8use Illuminate\Support\Collection;
9use WendellAdriel\Expressive\Attributes\Relationship;
10use WendellAdriel\Expressive\Attributes\Virtual;
11use WendellAdriel\Expressive\Expressive;
12 
13/**
14 * @extends Expressive<OrderModel>
15 */
16final class Order extends Expressive
17{
18 public ?int $id = null;
19 
20 public string $customerName;
21 
22 public string $customerEmail;
23 
24 public OrderStatus $status;
25 
26 public int $totalPence;
27 
28 public ?CarbonInterface $placedAt = null;
29 
30 public ?CarbonInterface $createdAt = null;
31 
32 public ?CarbonInterface $updatedAt = null;
33 
34 /** @var Collection<int, OrderLine>|null */
35 #[Relationship]
36 public ?Collection $lines = null;
37 
38 #[Virtual]
39 public ?string $formattedTotal = null;
40}

This is a proper PHP object. $order->status is an OrderStatus enum, not a magic string. $order->totalPence is an int. Your IDE knows exactly what you're working with.

The #[Virtual] attribute maps to an Eloquent accessor on the model - here a formattedTotal() accessor that renders the pence value as a display string. Virtual properties aren't appended automatically; you request them explicitly when converting (more on that below), which means the object always reflects precisely what was loaded, rather than silently triggering extra work behind your back.

There's a related safety mechanism worth knowing about: if a non-nullable property like customerName receives null during conversion - say a database column turns out to be unexpectedly null - Expressive throws a NonNullablePropertyException immediately rather than letting a null propagate silently into your business logic. It fails right at the boundary, which is exactly where you want it.

Using It In Practice

In a controller, converting is a one-liner:

php
1<?php
2 
3// Single model
4$order = Order::findOrFail($id)->expressive();
5 
6// With relationships and virtual accessor properties loaded explicitly
7$order = Order::findOrFail($id)->expressive(
8 attributes: ['formattedTotal'],
9 relationships: ['lines'],
10);
11 
12// From a query builder - returns a Collection of Expressive objects directly
13$orders = Order::query()
14 ->where('status', OrderStatus::Pending)
15 ->expressive();
16 
17// For large result sets - process typed objects in chunks without loading everything into memory
18Order::query()
19 ->where('status', OrderStatus::Pending)
20 ->expressiveChunk(100, function (Collection $orders): void {
21 // $orders is a typed Collection of Order Expressive objects
22 });

Now in your service or action, instead of receiving a fat Eloquent model, you receive your typed domain object:

php
1<?php
2 
3namespace App\Actions;
4 
5use App\Enums\OrderStatus;
6use App\Expressive\Order;
7 
8final class ConfirmOrderAction
9{
10 public function execute(Order $order): Order
11 {
12 // $order->status is typed - your IDE and static analysis know this is OrderStatus
13 if ($order->status !== OrderStatus::Pending) {
14 throw new InvalidOrderStateException(
15 "Cannot confirm an order with status: {$order->status->value}"
16 );
17 }
18 
19 // Return a modified Expressive object
20 return new Order([
21 ...$order->toArray(),
22 'status' => OrderStatus::Confirmed,
23 ]);
24 }
25}

When you're ready to write back to the database:

php
1<?php
2 
3$confirmed = $confirmOrderAction->execute($order);
4 
5// Build an Eloquent model in memory (no DB write)
6$model = $confirmed->model();
7 
8// Or persist it directly
9$saved = $confirmed->save();

That save() call persists through Eloquent's standard mechanisms, respecting mass assignment rules and supported relationships (BelongsTo, HasOne, HasMany, MorphOne, MorphMany). It's not bypassing anything - it's Eloquent all the way down.

The Testing Story

This is where things get genuinely interesting, and it's the angle I was most excited to dig into.

There's no Expressive::fake() in the way you might fake events or mail in Laravel. That's not what this package is. But you don't need it - the beauty of Expressive objects is that they're plain PHP objects you can construct directly, without any Eloquent or database involvement.

Let's look at what this means practically.

Unit testing your actions

Before Expressive, unit testing an action that received an Eloquent model you'd either hit the database (now it's a feature test), or partially mock an Eloquent model (awkward at best). With Expressive objects, you just... make one:

php
1<?php
2 
3it('confirms a pending order', function () {
4 $order = new \App\Expressive\Order([
5 'id' => 1,
6 'customerName' => 'Jane Smith',
7 'customerEmail' => 'jane@example.com',
8 'status' => OrderStatus::Pending,
9 'totalPence' => 4999,
10 ]);
11 
12 $action = new ConfirmOrderAction();
13 $result = $action->execute($order);
14 
15 expect($result->status)->toBe(OrderStatus::Confirmed);
16});
17 
18it('throws when confirming a non-pending order', function () {
19 $order = new \App\Expressive\Order([
20 'id' => 1,
21 'status' => OrderStatus::Confirmed,
22 'totalPence' => 4999,
23 ]);
24 
25 expect(fn () => (new ConfirmOrderAction())->execute($order))
26 ->toThrow(InvalidOrderStateException::class);
27});

No database. No factories. No mocking. You build the typed object with exactly the state you need, pass it in, and assert on typed properties. expect($result->status)->toBe(OrderStatus::Confirmed) is miles more readable than $this->assertEquals('confirmed', $result['status']) - and your IDE will shout at you if OrderStatus::Confirmed doesn't exist.

Feature testing with factories

For feature tests where you do want a real persisted model, your factory workflow stays exactly the same. You create the model via factory, then convert:

php
1<?php
2 
3it('returns typed expressive orders for the index', function () {
4 $user = User::factory()->create();
5 Order::factory()->count(3)->for($user)->create([
6 'status' => OrderStatus::Pending,
7 ]);
8 
9 $orders = Order::query()
10 ->where('user_id', $user->id)
11 ->expressive();
12 
13 expect($orders)->toHaveCount(3)
14 ->and($orders->first())->toBeInstanceOf(\App\Expressive\Order::class)
15 ->and($orders->first()->status)->toBe(OrderStatus::Pending);
16});

Testing persistence round-trips

You can also verify that building a model from an Expressive object round-trips correctly - useful when you're adopting Expressive on an existing codebase and want confidence you haven't broken anything:

php
1<?php
2 
3it('builds a valid eloquent model from expressive', function () {
4 $expressive = new \App\Expressive\Order([
5 'customerName' => 'Tom Hughes',
6 'customerEmail' => 'tom@example.com',
7 'status' => OrderStatus::Pending,
8 'totalPence' => 2500,
9 ]);
10 
11 $model = $expressive->model();
12 
13 expect($model)->toBeInstanceOf(\App\Models\Order::class)
14 ->and($model->customer_name)->toBe('Tom Hughes')
15 ->and($model->status)->toBe(OrderStatus::Pending);
16});

The key shift here is that your domain logic becomes unit-testable without the database. Once your services and actions accept Expressive objects, you control the test state entirely through typed PHP objects. That's a meaningful improvement over what most Laravel applications have today.

The Serialisation Bonus

Because Expressive objects implement Arrayable and JsonSerializable, they work naturally with Laravel's response layer:

php
1<?php
2 
3// In a controller
4return response()->json($order->expressive(relationships: ['lines']));

The serialisation follows the Expressive properties, not the full Eloquent model output. Hidden model attributes (password, remember_token and the like) are still respected - the package reads the model's visibility rules and applies them. You can also configure whether keys come out as camelCase or snake_case via the published config.

Our Honest Take

There's a lot to like here, and it scratches an itch that DTOs alone don't quite reach. The automatic scaffolding from make:expressive, the bidirectional conversion, and the recursive relationship handling are all genuinely well thought through.

A few things to keep in mind before you go all-in:

It's a v1.0.0. Released literally this week. The core API feels solid, but don't be surprised if things shift. Keep an eye on the changelog.

Persistence support has deliberate gaps. The save() method handles the relationships you'd expect for straightforward parent/child writes - BelongsTo, HasOne, HasMany, MorphOne, and MorphMany - but several types are intentionally left out:

  • BelongsToMany and MorphToMany - pivot tables require explicit attach, sync, or detach semantics that Expressive can't assume on your behalf. If you're dealing with many-to-many relationships, you'll need to fall back to Eloquent directly for the write side.

  • HasOneThrough and HasManyThrough - writes go through an intermediate model, which is beyond what Expressive tries to orchestrate.

  • Custom relation classes - Expressive has no way to infer the correct write semantics for anything it doesn't recognise natively.

The bit that's worth understanding clearly: unsupported persistence only means you can't write those relationships back through save(). You can absolutely still convert a model with any of those relationships loaded into an Expressive object - the read side works fine. It's purely the round-trip back to the database that's constrained. Wendell covers this in detail in the persistence section of the docs, and the Laravel News writeup is also a decent place to start if you want a quick overview of the full picture before diving into the full documentation.

No deletion API. You can't delete through an Expressive object. Convert back to the model for that. Not a dealbreaker, just worth knowing up front.

Coexists with Spatie Laravel Data rather than replacing it. If you're already using Laravel Data heavily for form inputs and API responses, Expressive sits in a different spot in the stack - it's specifically about the model-to-typed-object boundary. They're complementary, not competing.

Reflection under the hood. Expressive reads public properties via reflection, which carries a small performance cost. For most applications this is irrelevant, but if you're converting thousands of models in a tight loop it's worth benchmarking rather than assuming.

Should You Use It?

Not everywhere - and that's worth saying clearly. For straightforward CRUD screens where a controller loads a model, tweaks a field or two, and redirects back, the extra layer adds overhead without meaningful gain. Simple flows don't need a typed boundary.

Expressive earns its place when you're working with code that actually benefits from the separation:

  • business actions with meaningful rules that shouldn't depend on persistence behaviour

  • services or jobs where typed state is passed through multiple layers

  • read models consumed by several entry points, where consistency and type safety genuinely matter

  • anywhere that untestable Eloquent coupling has already been causing you pain

It also sits alongside your existing tools rather than replacing them. If you're using Laravel API Resources for response contracts - especially anything that needs versioning, conditional fields, or response-specific formatting - keep using them. Expressive objects serialise cleanly to arrays and JSON, but Resources are built for that shape-shifting work.

The mental model is sound, the API is clean, and the testing improvements alone make it worth serious consideration for teams that care about unit testability (which, if you've read our posts on static analysis and coding standards, you know we do).

The pattern isn't new - CakePHP developers have lived it for years. It's just good to see it arriving in Laravel in such a well-packaged form, and with Wendell's track record of maintaining his packages properly, it's one worth watching closely as it matures.

Have you got an existing codebase where Eloquent models are leaking everywhere? Or are you already doing something similar with DTOs or read models? We'd love to hear how your team handles this boundary - drop us a comment or reach out.

Looking to bring better architecture patterns to your Laravel application?

We're a passionate team of Laravel specialists, and we've been helping businesses build clean, maintainable applications since 2014. If you're dealing with architectural debt or want to think through how patterns like this fit into your existing stack, get in touch - we'd love to have that conversation.

Links: