---
title: "Order status & lifecycle — Shops for Laravel | Roundly"
description: "A guarded state machine — New, InProgress, Paid, Fulfilled, Canceled, Refunded — with timestamps, stock settlement, events and order numbers."
url: https://roundly-consulting.com/open-source/docs/shops-for-laravel/order-lifecycle
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Shops for Laravel](https://roundly-consulting.com/open-source/docs/shops-for-laravel.md)

# Order status & lifecycle

Orders move through a guarded state machine with these allowed transitions:

```
New        → InProgress | Canceled
InProgress → Paid | Canceled
Paid       → Fulfilled | Refunded
Fulfilled  → Refunded
Canceled, Refunded   (terminal)
```

Transition through Shops::order($order) or the helper methods on the order, which call the same manager. Each move is validated under the order’s row lock, stamps the matching timestamp (in\_progress\_at, paid\_at, fulfilled\_at, canceled\_at, refunded\_at), persists and fires events:

```php
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\Enums\Status;

Shops::order($order)->transition(Status::InProgress);   // New → InProgress
Shops::order($order)->transition(Status::Paid);         // InProgress → Paid, stamps paid_at, fires OrderPaid
Shops::order($order)->fulfil();    // Paid → Fulfilled, converts reserved stock into a sale, fires OrderFulfilled
Shops::order($order)->cancel();    // New|InProgress → Canceled, releases reserved stock, returns store credit, fires OrderCanceled
Shops::order($order)->refund();    // Paid|Fulfilled → Refunded (a Paid order's reservation is released), fires OrderRefunded

// The helpers on the order call the same manager:
$order->markInProgress();          // → InProgress
$order->markPaid();                // → Paid
$order->markFulfilled();           // → Fulfilled
$order->cancel();                  // → Canceled
$order->refund();                  // → Refunded
$order->transitionTo(Status::Paid);
```

## Reading status

Status is a string-backed enum with the enums-for-laravel helpers:

```php
$order->status->is(Status::Paid);                        // bool
$order->status->isIn([Status::Paid, Status::Fulfilled]); // bool
$order->status->canTransitionTo(Status::Refunded);       // bool
$order->status->allowedTransitions();                    // list<Status>
$order->status->isTerminal();                            // Canceled or Refunded
$order->status->timestampColumn();                       // e.g. 'paid_at' (null for New)

Status::options();          // select-ready options (enums-for-laravel helpers)
Status::validationRule();   // a validation rule over the values
```

## Illegal transitions

```php
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\Exceptions\IllegalStatusTransitionException;

try {
    Shops::order($order)->refund();   // the order is still New
} catch (IllegalStatusTransitionException $e) {
    // "Cannot transition an order from [New] to [Refunded]." — nothing changed
}
```

## Stock settlement

Canceling releases the order’s reservation and returns any store credit applied to it; refunding a Paid (not yet fulfilled) order releases the reservation too; fulfilling converts it into a sale. Refunding a Fulfilled order leaves stock alone — goods coming back are booked with Shops::inventory($variant)->returned($qty, $order).

## Events

Every transition fires OrderStatusChanged (with from and to), plus OrderPaid, OrderFulfilled, OrderCanceled or OrderRefunded for those targets. Checkout fires OrderPlaced. Order events fire after the surrounding transaction commits, so a rolled-back transition or checkout fires nothing. A transition that fails part-way — a fulfilment whose stock cannot be sold — changes nothing either, not the row and not the order instance you hold, so retrying it on the same instance works:

```php
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use RoundlyConsulting\Shops\Orders\Events\OrderPaid;
use RoundlyConsulting\Shops\Orders\Events\OrderStatusChanged;

Event::listen(function (OrderPaid $event): void {
    Mail::to($event->order->customer)->send(new OrderConfirmation($event->order));
});

Event::listen(function (OrderStatusChanged $event): void {
    logger()->info('Order status changed', [
        'order' => $event->order->number,
        'from' => $event->from->value,
        'to' => $event->to->value,
    ]);
});
```

## Order numbers

By default a number is the two-digit year plus a six-digit sequence, counted per year including soft-deleted orders. It is assigned once, when the order is first inserted (unless you set one), never when orders are loaded:

```php
$order = Order::create([]);
$order->number;   // e.g. "26000042" — two-digit year + six-digit sequence

Route::get('/orders/{order}', ShowOrder::class);   // orders route-bind by number
```

orders.number is unique across all orders. The default generator skips numbers already taken; when two checkouts race to the same number, the index refuses the second insert and the generator is asked again — up to five times, in a savepoint. An explicitly set number is never replaced — a duplicate throws. Provide your own strategy with NumberGenerator:

```php
use Illuminate\Support\Str;
use RoundlyConsulting\Shops\Orders\NumberGenerators\NumberGenerator;
use RoundlyConsulting\Shops\Orders\Order;

final class PrefixedNumberGenerator implements NumberGenerator
{
    public function generate(Order $order): string
    {
        return 'ORD-'.Str::upper(Str::random(8));
    }
}

// config/shops.php → 'orders' => ['number_generator' => PrefixedNumberGenerator::class]
```

```php
use RoundlyConsulting\Shops\Orders\NumberGenerators\DefaultNumberGenerator;

// The default generator is not final — override getNextNumber(), format() or isTaken():
final class BranchNumberGenerator extends DefaultNumberGenerator
{
    protected function format(int $sequence): string
    {
        return 'A'.parent::format($sequence);   // "A26000042"
    }
}
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/shops-for-laravel.md)
