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

Inventory & stock

Stock is an auditable ledger: every change is a StockAdjustment row, and the variant caches stock (on hand) and reserved (held for placed orders). Available stock is stock minus reserved. Shops::inventory($variant) is the way in; every write goes through AdjustStockAction, which row-locks the variant inside a transaction and decides a sale or a reservation against that locked row — never a stale copy — so two checkouts can never both take the last unit:

use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Inventory\Enums\StockReason;

Shops::inventory($variant)->receive(100, ref: $purchaseOrder, note: 'PO-17'); // +100 on hand
Shops::inventory($variant)->returned(1, $order);                             // +1, booked against the order
Shops::inventory($variant)->adjust(-2, note: 'Stock count');                  // Manual correction, either sign
Shops::inventory($variant)->adjust(-1, StockReason::Sold, $posSale);          // sell one outside an order
Shops::inventory($variant)->available();                                     // stock - reserved
Shops::inventory($variant)->inStock(3);                                      // bool

Each write returns the StockAdjustment row. ref is any model that explains the change — a purchase order, an RMA, the order. A return booked against an Order the variant was never on throws ForeignItemException.

The action form

use RoundlyConsulting\Shops\Actions\Inventory\AdjustStockAction;

// What every inventory write runs — call it directly in jobs or your own actions:
app(AdjustStockAction::class)->execute(
    ProductVariant $variant,
    int $delta,                  // signed, in the reason's direction, never 0
    StockReason $reason,
    ?Model $reference = null,    // morph, e.g. the Order or a purchase order
    ?string $note = null,
): StockAdjustment;

Reasons and direction

The sign of the delta must match the reason. A zero delta or a wrong sign throws InvalidQuantityException before anything is written:

ReasonSignColumnMeaning
Received+stockGoods received into stock.
Sold−stockA sale — refused below available stock on a tracked variant.
Reserved+reservedHeld for a placed order; never throws.
Released−reservedA hold released (clamped at zero); never throws.
Returned+stockA customer return.
Manual±stockA manual correction in either direction.

Selling or reserving more than the available stock of a tracked variant throws InsufficientStockException. Variants with track_stock = false (digital or unlimited goods) never throw, and inStock() is always true for them. The variant you pass in gets the new stock and reserved as clean attributes, so a later save() of it never rewrites stock another request has moved.

Reservations

Orders manage reservations automatically — you never touch them for a normal checkout:

StepActionEffect per order line
Place orderReserveStockActionChecks every line against the variant’s row-locked availability (throws InsufficientStockException), then adds it to reserved. One oversell rolls back every reservation.
CancelReleaseStockAction (sell: false)Releases the hold — the quantity is available again.
Refund a Paid orderReleaseStockAction (sell: false)Releases the hold of an order that was paid but never fulfilled. Refunding a Fulfilled order leaves stock alone.
FulfilReleaseStockAction (sell: true)Releases the hold, then books a Sold adjustment — the reservation becomes a sale.

Both actions are @internal: checkout reserves, and Shops::order($order)->cancel(), fulfil() and refund() of an unfulfilled order release. Under Shops::fake() they count as part of the placed order or the transition, not as separate stock adjustments. Lines whose variant was deleted are skipped, and every ledger row references the order.

The ledger

use RoundlyConsulting\Shops\Inventory\StockAdjustment;

StockAdjustment::query()
    ->where('product_variant_id', $variant->id)
    ->latest()
    ->get();                  // the variant's full ledger

$adjustment->quantity;        // the signed delta
$adjustment->reason;          // StockReason
$adjustment->note;            // ?string
$adjustment->variant;         // ProductVariant
$adjustment->reference;       // e.g. the Order (morph)

StockReason::Reserved->affectsReserved();   // true for Reserved and Released
StockReason::Sold->direction();             // -1

Stock events

Every adjustment fires StockAdjusted. StockRanLow fires once when an adjustment takes a tracked variant’s available stock from above shops.inventory.low_stock_threshold to at or below it — not again while it stays low. Both fire after the surrounding transaction commits, so a rolled-back checkout fires nothing:

use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use RoundlyConsulting\Shops\Inventory\Events\StockAdjusted;
use RoundlyConsulting\Shops\Inventory\Events\StockRanLow;

Event::listen(function (StockRanLow $event): void {
    // $event->variant, $event->threshold
    Notification::route('mail', '[email protected]')
        ->notify(new ReorderNotice($event->variant));
});

Event::listen(function (StockAdjusted $event): void {
    // $event->variant, $event->adjustment
});

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.