Martin Joo

« back published by @mmartin_joo on February 26, 2022

Domain-Driven Design with Laravel - States and Transitions

What Is Domain-Driven Design or DDD?

Domain-Driven Design is a software development approach that tries to bring the business language and the source code as close as possible.

I think that's the most simple yet correct definition of DDD. To be a little bit more specific it achieves this by making every important "stuff" a first-class citizen. If "Change invoice status" or "Increase order item quantity" are two sentences that your business people say a lot then you probably should have

  • ChangeInvoiceStatus
  • IncreaseOrderItemQuantity

classes somewhere in your application. That's one example of being a first-class citizen.

Now let's say you're working on some server management / monitoring application. Maybe a commercial one, or some internal tool for your own projects. What is the single most important "stuff" in this application? In my opinion, it's the server status. If it says "Healthy" everyone is happy, but if it says "Down" you know you're about to have a hard day. We know it's important but still, it's just a string in our database table. It's the 15th attribute on our Server model. And we're changing it from "Healthy" to "Deploying" in the 873rd line of some random Command or Job, or in a Model somewhere, or even worse in a Controller. Instead of having these string values and changing them, we can have classes like:

  • ServerStatus/Healthy
  • ServerStatus/Down
  • ServerStatus/Deploying

We can also have transitions like:

  • HealthyToDeploying

So this is what I mean by making things, first-class citizens. This is what Domain-Driven Design is all about (and a little bit more, but now this is the important thing).

What Is a State in the Domain-Driven Design World?

A State is a simple class that represent the state of something. That something in most cases is an Eloquent model. So instead of a string we have a dedicated class which gives us a bunch of advantages:

  • Encapsulation: Everything that associated with a state is in one place.
  • Separation of concern: Each state has its own class so you have a nice separation.
  • More simple logic: There is no need for nasty if-else or switch statements around a string attribute anymore.

How a State class looks like? Let's say we are working on an e-commerce application so we have an Order class. An Order has a status like:

  • Draft
  • Pending
  • Paid
  • PaymentFailed

For the sake of simplicity let's say the most important business logic around the Order's state is that it can be changed or not. A customer can modify a draft Order but cannot modify a Paid order.

First, let's create an abstract class:

abstract class OrderStatus
{
    public function __construct(protected Order $order)
    {
    }

    abstract public function canBeChanged(): bool;
}

Each state gonna extend the OrderStatus parent class. Now we can create these concrete classes:

class DraftOrderStatus extends OrderStatus
{
    public function canBeChanged(): bool
    {
        return true;
    }
}

As I said earlier a draft order can be changed but a paid cannot be changed:

class PaidOrderStatus extends OrderStatus
{
    public function canBeChanged(): bool
    {
        return false;
    }
}

That's easy, now let's see how you can use them. First, we define an enum with the possible statuses:

enum OrderStatuses: string
{
    case Draft = 'draft';
    case Pending = 'pending';
    case Paid = 'paid';
    case PaymentFailed = 'payment-failed';

    public function createOrderStatus(Order $order): OrderStatus
    {
        return match($this) {
            OrderStatuses::Draft => new DraftOrderStatus($order),
            OrderStatuses::Pending => new PendingOrderStatus($order),
            OrderStatuses::Paid => new PaidOrderStatus($order),
            OrderStatuses::PaymentFailed => new PaymentFailedOrderStatus($order),
        };
    }
}

As you can see this enum acts like a factory function. This is one of the hidden features of PHP8.1 enums. In the Order model we can leverage this with an attribute accessor:

class Order extends Model
{
    public function status(): Attribute
    {
        return new Attribute(
            get: fn (string $value) => OrderStatuses::from($value)->createOrderStatus($this),
        );
    }    
}

This is the new Laravel 8 accessor syntax, it's equivalent to this one:

public function getStatusAttribute(string $value): OrderStatus
{
    return OrderStatuses::from($value)->createOrderStatus($this)
}

First I create an enum from the string value stored in the database, after that I call the factory on the Enum. So anytime you acces the status attribute of an order you get an OrderStatus instance.

Now let's see how we can use these state classes:

class OrderController extends Controller
{
    public function update(UpdateOrderRequest $request, Order $order)
    {
        abort_if(!$order->status->canBeChanged(), 400);
    }
}

Since the status will return an OrderStatus instance, we can call the canBeChanged method on it. Or we can simply add a delegate method to the Order class:

class Order extends Model
{
    public function canBeChanged(): bool
    {
        return $this->status->canBeChanged();
    }
}

// Now we can use it like:
$order->canBeChanged();

// Instead of:
$order->status->canBeChanged();

So what we can do in a State class? I would say, basically anything that relates to the state of the order. And of course, you can call any other class to re-use some existing logic, just to name a few:

  • Any Action
  • OrderService
  • OrderRepository
  • Any method from the Order model or from Order query builder
  • And so on

What About Transitions Between States?

That's our next topic. At some point we need to change the state from Pending to Paid. I think we all wrote code similar to this in the past:

class OrderController extends Controller
{
    public function pay(PayOrderRequest $request, Order $order)
    {
        // Some logic here...
        $order->status = OrderStatuses::Paid;
        $order->save();
    }
}

What's wrong with this?

  • I would say it's a little bit random. I mean we have an important status update in some random place in a Controller. Of course we can write a setStatus() method in the Order model.
  • Who guarantees that this transition can happen? What if this Order cannot be mark as paid for some reason? Of course we can write some logic in the Controller or (better) in the Order model that checks some rules and makes sure that only legit transitions can happen.
  • If you write these method in the Order class it starts to get bigger and bigger and bigger. And after a few months or year you just cannot reason about what's happening.
  • To sum it up: it's random. A little bit of logic in the Controller a few methods in the Model, maybe there's an attribute accessor here, maybe there's a model event there. It's hard to reason about.

This is what Domain-Driven Design teach us: we have to treat these transitions as first-class citizens. So let's put them in dedicated classes!

First, we can create some kind of abstraction. In this case we don't need a class only an interface:

interface Transition
{
    /**
     * @throws Exception
     */
    public function execute(Order $order): Order;
}

A Transition can be executed. It takes an Order and returns an Order. If something goes wrong it throws an Exception. That's the contract.

This is a concrete Transition:

class DraftToPendingTransition implements Transition
{
    public function execute(Order $order): Order
    {
        if ($order->status !== OrderStatuses::Draft) {
            throw new Exception('Transition not allowed');
        }

        $order->status = OrderStatuses::Pending;
        $order->save();

        return $order;
    }
}

First it makes sure that the current Order is draft, then it updates it to Pending. Really easy.

The last task is to somehow get these Transition instances and executes. You can create a simple Factory class, but in this example I go with an AllowedTransitions class:

abstract class AllowedTransitions
{
    public const ALL = [
        [
            'current' => OrderStatuses::Draft,
            'next' => OrderStatuses::Pending,
            'transition' => DraftToPendingTransition::class,
        ],
        [
            'current' => OrderStatuses::Pending,
            'next' => OrderStatuses::Paid,
            'transition' => PendingToPaidTransition::class,
        ],
        [
            'current' => OrderStatuses::Pending,
            'next' => OrderStatuses::PaymentFailed,
            'transition' => PendingToPaymentFailedTransition::class,
        ],
        [
            'current' => OrderStatuses::PaymentFailed,
            'next' => OrderStatuses::Pending,
            'transition' => PaymentFailedToPendingTransition::class,
        ],
    ];

    public static function getTransition(OrderStatuses $current, OrderStatuses $next): Transition
    {
        $item = collect(AllowedTransitions::ALL)
            ->first(fn (array $t) =>
                $t['current'] === $current &&
                $t['next'] === $next
            );

        if (!$item) {
            throw new InvalidArgumentException("No transition for $current->value -> $next->value");
        }

        return new $item['transition'];
    }

So this class class contains EVERY possible state transitions. In my opinion that's huge gain! Now imagine a 8-10 years old system with 200-300 tables. Try to get all the possible state transitions and the rules around them. It will take a day or even more in some cases... With this simple class we get everything in one place.

After we have this class we can use it very easily:

class ChangeOrderStatusAction
{
    public function execute(Order $order, OrderStatuses $nextStatus): Order
    {
        return DB::transaction(function () use ($order, $nextStatus) {
            $transition = AllowedTransitions::getTransition($order->status, $nextStatus);
            $order = $transition->execute($order);

            return $order;
        });
    }
}

Now we have only one problem left to solve. How we create an OrderStatus class from a string? Let's say we have an API for changing the status:

PATCH /api/v1/orders/1/status/paid

1 is the order ID and 'paid' is the new status. So we need to create a PaidOrderStatus instance from the string 'paid'. This is easy with an enum. In fact, Laravel already has a feature called enum route binding. So, Laravel will create an enum from the 'paid' string for us:

class ChangeOrderStatusController extends Controller
{
    public function __construct(private ChangeOrderStatusAction $changeOrderStatus)
    {
    }

    public function index(Order $order, OrderStatuses $status)
    {
        $nextOrderStatus = $status->createOrderStatus($order);
        return [
            'data' => new OrderResource($this->changeOrderStatus->execute($order, $nextOrderStatus))
        ];
    }
}

You can only do this with a backed enum. By the way, it comes with validation out-of-the-box. You if you use the following URL:

PATCH /api/v1/orders/1/status/INVALID_STATUS

You will get a 404 response, because no case found for this particular value.

Conclusion

In my opinion, using state classes and enums is a really great way to structure your state-related logic. It has the following advantages:

  • Encapsulation: Everything that associated with a state is in one place.
  • Separation of concern: Each state has its own class so you have a nice separation.
  • These two benefits also applies to transition classes. You have separate classes for each transition that encapsulates a particular logic.
  • Single-Responsibility Principle: Each state and transition does only one thing, and they do it very well.
  • More simple logic: There is no need for nasty if-else or switch statements around a string value anymore.

Of course, nothing is free. The main disadvantage of using states and transition is that you will end up with a lot of classes. I mean, it's not the end of the world, but it can be annoying after a while.

Please keep in mind that states and transitions are useful only when your logic is complicated. If you have only a few statuses and a few methods that depend on these statuses using states is really unnecessary.

Domain-Driven Design with Laravel