NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Shops for Laravel

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:

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:

$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

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:

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:

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

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]
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

By donating, you agree to our donation terms.

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.