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); // boolEach 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:
| Reason | Sign | Column | Meaning |
|---|---|---|---|
Received | + | stock | Goods received into stock. |
Sold | − | stock | A sale — refused below available stock on a tracked variant. |
Reserved | + | reserved | Held for a placed order; never throws. |
Released | − | reserved | A hold released (clamped at zero); never throws. |
Returned | + | stock | A customer return. |
Manual | ± | stock | A 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:
| Step | Action | Effect per order line |
|---|---|---|
| Place order | ReserveStockAction | Checks every line against the variant’s row-locked availability (throws InsufficientStockException), then adds it to reserved. One oversell rolls back every reservation. |
| Cancel | ReleaseStockAction (sell: false) | Releases the hold — the quantity is available again. |
| Refund a Paid order | ReleaseStockAction (sell: false) | Releases the hold of an order that was paid but never fulfilled. Refunding a Fulfilled order leaves stock alone. |
| Fulfil | ReleaseStockAction (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(); // -1Stock 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 cryptoBy 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.