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:

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:

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:

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():

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:

$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.

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:

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:

$order->totalCents = '1000';

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

The same applies to enums:

$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:

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:

$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:

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:

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!