# Improving Laravel Architecture With Expressive

> Learn how Expressive can improve a Laravel application by keeping Eloquent as the database layer while moving business logic to fully typed objects.

- URL: https://wendelladriel.com/blog/improving-laravel-architecture-with-expressive
- Author: Wendell Adriel
- Published: Jun 1, 2026
- Reading time: 11 minutes
- Tags: backend, architecture, php, laravel

## Introduction

**Eloquent** is one of the best parts of **Laravel**. It makes querying, relationships, casts,
events, scopes, and persistence really pleasant to work with.

But as an application grows, a common problem starts to appear: the **Eloquent Model** becomes the
object that is passed everywhere.

Controllers receive models. Actions receive models. Services receive models. Jobs receive models.
Policies receive models. Eventually, a lot of business logic starts depending directly on an object
that also knows how to talk to the database.

That is not always a problem. For many CRUD flows, passing an Eloquent model around is completely
fine. But for more important business flows, it can make the application harder to reason about.

This is where **Expressive** can help.

**Expressive** is a package that provides **typed objects for Eloquent**. It lets you keep Eloquent
as the persistence layer, while giving your application and business logic a cleaner typed object to
work with.

In this article, let's see how **Expressive** can improve a **Laravel** application by decoupling the
database layer from business logic and making the code safer through fully typed objects.

## The Problem With Passing Models Everywhere

An **Eloquent Model** is not only a data object.

It is also:

* a query entry point
* a persistence object
* a relationship manager
* a casting layer
* an event source
* a serialization source
* a place where scopes, accessors, and mutators can live

That is powerful, but it also means that when your business logic receives a model, it receives a lot
more than just the data it needs.

For example, imagine a payment use case like this:

```php
final class CaptureOrderPayment
{
    public function handle(Order $order, PaymentGateway $gateway): void
    {
        if ($order->status !== OrderStatus::Pending) {
            throw OrderCannotBePaid::becauseItIsNotPending($order);
        }

        $gateway->charge(
            customerId: $order->customer_id,
            amountInCents: $order->total_cents,
        );

        $order->update([
            'status' => OrderStatus::Paid,
            'paid_at' => now(),
        ]);
    }
}
```

This code is common, and it works. But the use case is doing a few things at the same time.

It is reading business data, making a business decision, and persisting the result through the same
object.

The action also depends on database-shaped names like `customer_id` and `total_cents`. If a column is
nullable but the business flow expects it to be present, you will only find out at runtime. If a field
is mistyped, your editor and static analysis have less information to help you.

Again, this is not a disaster. This is just the natural trade-off of using **Active Record**. It is
very productive, but the persistence object can slowly leak into places where a more explicit
application object would be easier to understand.

## What Expressive Gives You

**Expressive** does not replace **Eloquent**.

That is an important point.

Eloquent still owns querying, relationships, casts, visibility rules, mass assignment, model events,
and database writes. **Expressive** gives you a typed boundary on top of that.

The idea is simple:

* use **Eloquent Models** to talk to the database
* convert them into **Expressive objects** when application logic needs typed data
* let business logic depend on those typed objects instead of the full database model
* convert back to Eloquent when you need to persist changes

The result is not a completely different architecture. It is a small boundary that makes the rest of
your code more explicit.

An Expressive object is just a class with public typed properties:

```php
namespace App\Expressive;

use App\Enums\OrderStatus;
use App\Models\Order as OrderModel;
use Carbon\CarbonInterface;
use WendellAdriel\Expressive\Attributes\Model;
use WendellAdriel\Expressive\Attributes\Virtual;
use WendellAdriel\Expressive\Expressive;

/**
 * @extends Expressive<OrderModel>
 */
#[Model(OrderModel::class)]
final class Order extends Expressive
{
    public ?int $id = null;

    public int $customerId;

    public OrderStatus $status;

    public int $totalCents;

    public ?CarbonInterface $paidAt = null;

    public ?CarbonInterface $createdAt = null;

    public ?CarbonInterface $updatedAt = null;

    #[Virtual]
    public ?bool $payable = null;
}
```

Now the shape of the object is visible from the class itself.

`customerId` is an integer. `status` is an `OrderStatus` enum. `paidAt` is nullable. `payable` is a
virtual property that can be backed by an Eloquent accessor.

That may look like a small change, but it has a big impact on how easy the code is to read.

## Converting Models Into Expressive Objects

To start using **Expressive**, the model opts in with the `IsExpressive` trait:

```php
namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use WendellAdriel\Expressive\Concerns\IsExpressive;

final class Order extends Model
{
    use IsExpressive;

    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
            'paid_at' => 'datetime',
        ];
    }

    protected function payable(): Attribute
    {
        return Attribute::make(
            get: fn (): bool => $this->status === OrderStatus::Pending
                && $this->total_cents > 0,
        );
    }
}
```

After that, you can convert a model by calling `expressive()`:

```php
use App\Models\Order as OrderModel;

$order = OrderModel::query()
    ->findOrFail($orderId)
    ->expressive(attributes: ['payable']);
```

The returned object is an instance of `App\Expressive\Order`.

Notice that we explicitly requested the `payable` accessor. **Expressive** does not automatically
append every virtual property. If you do not request it, the nullable virtual property stays `null`.

That is a nice default because the object tells you what was actually loaded instead of silently
triggering more work behind your back.

You can also convert collections and builders:

```php
$orders = OrderModel::query()
    ->where('status', OrderStatus::Pending)
    ->expressive(attributes: ['payable']);
```

Calling `expressive()` on a builder executes the query and returns a Laravel collection containing
Expressive objects.

For larger result sets, the package also provides `expressiveChunk()` so you can process typed objects
without loading everything into memory at once.

## Moving Business Logic Away From the Model

Now let's rewrite the payment example using an Expressive object.

```php
use App\Enums\OrderStatus;
use App\Expressive\Order;

final class CaptureOrderPayment
{
    public function handle(Order $order, PaymentGateway $gateway): PaymentResult
    {
        if ($order->status !== OrderStatus::Pending) {
            return PaymentResult::rejected('The order is not pending.');
        }

        if ($order->payable !== true) {
            return PaymentResult::rejected('The order cannot be paid.');
        }

        $gateway->charge(
            customerId: $order->customerId,
            amountInCents: $order->totalCents,
        );

        $order->status = OrderStatus::Paid;
        $order->paidAt = now();

        return PaymentResult::paid($order);
    }
}
```

This action is now working with the data it needs, not with the full database object.

It cannot accidentally call `$order->delete()`. It cannot start building queries from the object. It
does not know about `customer_id` or `total_cents`. It only knows the application-level shape:
`customerId` and `totalCents`.

That is the decoupling benefit.

The database still exists, of course. We are not pretending persistence disappeared. We are just
moving the boundary so the business flow depends on a typed object instead of depending directly on
Eloquent.

Then the caller can decide when to persist the result:

```php
use App\Models\Order as OrderModel;
use Illuminate\Http\RedirectResponse;

final class PayOrderController
{
    public function __invoke(
        string $orderId,
        CaptureOrderPayment $capture,
        PaymentGateway $gateway,
    ): RedirectResponse {
        $order = OrderModel::query()
            ->findOrFail($orderId)
            ->expressive(attributes: ['payable']);

        $result = $capture->handle($order, $gateway);

        if (! $result->successful) {
            return back()->withErrors(['order' => $result->message]);
        }

        $model = $result->order->save();

        return redirect()->route('orders.show', $model);
    }
}
```

The controller still uses Eloquent to load and save data. The business action does not need to know
how that persistence happens.

That separation is useful because each part now changes for a different reason:

* change the database mapping: update the model or Expressive class
* change the payment rule: update the business action
* change the HTTP response: update the controller or responder

This is the same kind of separation I like in other application patterns: small boundaries that make
each class easier to reason about.

## The Safety of Fully Typed Objects

One of the biggest benefits of **Expressive** is the extra safety you get from normal PHP types.

With an Eloquent model, a lot of values are accessed through magic properties. Laravel does a great
job with casts, but the model class itself usually does not expose a concrete typed property for every
attribute.

With Expressive, the object shape is explicit:

```php
$order->totalCents = '1000';
```

That assignment is invalid because `totalCents` is an `int`. PHP can reject it immediately.

The same applies to enums:

```php
$order->status = 'paid';
```

That is not an `OrderStatus` enum, so it is not accepted by the object.

This helps your editor, your static analysis tools, your tests, and future developers reading the
code. The type information is not hidden in a `casts()` method or inferred from a database column. It
is visible where the object is used.

Expressive also protects you during conversion. If a non-nullable Expressive property receives `null`,
the package throws a `NonNullablePropertyException` instead of silently creating an invalid object.

That is exactly the kind of failure I prefer: fail close to the boundary, not three methods later
when the business logic assumes a value is present.

## Relationships Without Lazy Surprises

Expressive can also map loaded relationships into nested Expressive objects.

For example:

```php
use Illuminate\Support\Collection;
use WendellAdriel\Expressive\Attributes\Relationship;

final class Order extends Expressive
{
    // ...

    /** @var Collection<int, OrderItem>|null */
    #[Relationship]
    public ?Collection $items = null;
}
```

Then you can request the relationship during conversion:

```php
$order = OrderModel::query()
    ->findOrFail($orderId)
    ->expressive(relationships: ['items']);
```

Loaded `HasMany` relationships become collections of Expressive objects. Loaded single-model
relationships become a nested Expressive object.

The important detail is that unloaded relationships stay `null`. Expressive does not trigger lazy
loading just because a property exists.

That makes the boundary more honest. If your business logic needs `items`, request `items` when you
convert the model. If you did not request it, the object shows that the relationship is unavailable.

When persisting, Expressive supports direct relationship writes like `BelongsTo`, `HasOne`, `MorphOne`,
`HasMany`, and `MorphMany`. It intentionally does not hide complex semantics like `attach`, `sync`, or
`detach` for many-to-many relationships.

For those cases, I would keep the write explicit in application code. That is usually clearer anyway.

## Generating and Keeping Objects in Sync

You do not have to write every Expressive class by hand.

The package provides a generator:

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

The generator inspects the model table, casts, relationships, and accessors, then creates a typed
class that you can use as a starting point.

This is important: the generated class is a starting point, not a prison.

Sometimes your Expressive object should match the model closely. Sometimes it should represent a more
specific boundary for a workflow. You can edit it when the application needs a different shape.

There is also a sync command:

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

This command checks if the Expressive class drifted from the model shape. That can be useful in CI or
during refactors when you want generated objects to stay aligned with the database layer.

If you intentionally customized the object, the report is still useful. It tells you what changed so
you can decide whether the difference is expected.

## A Few Practical Rules

I would not use Expressive everywhere just because it exists.

For simple CRUD screens, returning an Eloquent model from a controller, updating a few fields, and
redirecting back is usually enough.

Expressive shines more when you have application code that deserves a clearer boundary:

* business actions with important rules
* workflows that should not depend directly on persistence behavior
* read models used by multiple entry points
* jobs or services where typed state makes the code easier to test
* places where static analysis and explicit properties would prevent mistakes

I would also keep API response contracts separate when they need a different shape. Expressive objects
can serialize to arrays and JSON, but if your public API needs versioning, conditional fields, links,
or response-specific formatting, **Laravel API Resources** are still a better fit.

The goal is not to replace every Laravel convention. The goal is to add a useful boundary where the
default model-centric approach starts to make the code harder to evolve.

## Conclusion

**Expressive** is a small architectural tool with a very practical purpose: keep **Eloquent** as the
database layer while giving the rest of your application a typed object to work with.

That boundary helps you avoid pushing Eloquent models through every part of the system. It makes
business logic depend on explicit data instead of persistence behavior, and it gives PHP, your editor,
and your static analysis tools more information to catch mistakes earlier.

The best way to start is simple: pick one important workflow where a model is carrying too much
responsibility, generate an Expressive object for it, and move the business action to depend on that
typed object.

If the code becomes easier to read, easier to test, and safer to change, the boundary is doing its
job.

I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!

