Placing orders
Shops::cart($cart)->checkout() turns a cart into an order in one transaction (PlaceOrderAction). An oversell rolls everything back and leaves the cart untouched:
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\DataTransferObjects\Address;
use RoundlyConsulting\Shops\Orders\DataTransferObjects\PlaceOrderData;
$address = new Address(
name: 'Jane Doe',
street: 'Main Street 1',
city: 'Berlin',
postalCode: '10115',
countryIso: 'DE',
email: '[email protected]',
);
$order = Shops::cart($cart)->checkout(new PlaceOrderData(
billing: $address,
shipping: $address, // the shipping country drives per-country tax
couponCode: 'WELCOME10', // optional — defaults to the cart's coupon_code
note: 'Leave at the door', // optional
customer: $user, // optional — defaults to the cart owner
));
$order = Shops::cart($cart)->checkout(); // no addresses, the cart's own coupon_code
// The same as an action:
$order = app(\RoundlyConsulting\Shops\Actions\Orders\PlaceOrderAction::class)->execute($cart, $data);What happens
- 1. The cart row is locked for the whole checkout. An empty cart, or a line whose variant was deleted or soft-deleted since it was added, is refused with CheckoutRefusedException before anything is written.
- 2. An Order is created with status New, the cart’s currency, the addresses and the note.
- 3. The buyer is linked — the given customer, else the cart owner (guest orders have none) — and the cart’s shop_id is copied; order items inherit the order’s shop.
- 4. Every cart line is copied through the internal AddOrderItemAction at the name, sku, price and tax class the cart snapshotted — what the customer saw, even if the catalog was repriced since — plus the tax rate.
- 5. Stock is reserved — an oversell throws InsufficientStockException and rolls everything back.
- 6. The coupon (the given code, else the cart’s coupon_code) is redeemed and its discount snapshotted. A missing or non-redeemable code is silently skipped and never blocks checkout.
- 7. Shipping is quoted and snapshotted onto the order (see Shipping below).
- 8. The cart is cleared, the order refreshed and returned; OrderPlaced fires after the transaction commits.
Refused checkouts
Because the cart stays locked, a double-submitted “Place order” places one order: the second request waits, finds the cart emptied and is refused with Orders\Exceptions\CheckoutRefusedException — catch it and show the order the first request placed. The same exception refuses an empty cart and a line whose variant is gone, naming the line in $cartItem:
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\Exceptions\CheckoutRefusedException;
try {
$order = Shops::cart($cart)->checkout($data);
} catch (CheckoutRefusedException $e) {
$e->cartItem; // ?CartItem — the line whose variant was removed; null for an empty cart
// A double-submitted "Place order" lands here too: show the order the first request placed.
}Shipping
With a shipping address, checkout asks the bound ShippingMethod (shops.shipping.method) to quote it — after the lines are added, so the method can price them — and snapshots the result onto the order as shipping_cost, in the order currency. Pass the option the customer chose with shippingCost instead. The order’s final price and the charge include it, and a free-shipping coupon takes it off. No address and no chosen cost means no shipping charge; a cart has no destination, so Shops::cart($cart)->price() never includes shipping:
use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\DataTransferObjects\PlaceOrderData;
// Quoted through shops.shipping.method and snapshotted onto the order:
$order = Shops::cart($cart)->checkout(new PlaceOrderData(shipping: $address));
// Or the option the customer chose:
$order = Shops::cart($cart)->checkout(new PlaceOrderData(
shipping: $address,
shippingCost: Money::ofMinor(490, 'EUR'),
));
$order->shipping_cost; // ?Money, in the order currency
$order->price->shippingCost(); // the shipping charged — zero with a free-shipping couponPlaceOrderData
Every field is optional:
new PlaceOrderData(
?Address $billing = null,
?Address $shipping = null,
?string $couponCode = null,
?string $note = null,
?Model $customer = null,
?Money $shippingCost = null, // the shipping option the customer chose — else quoted
);Orders built by hand
For an admin or phone order, go through a cart too — the line snapshot, the stock reservation and the coupon redemption then run together:
use RoundlyConsulting\Shops\Cart\Cart;
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\DataTransferObjects\PlaceOrderData;
// An admin or phone order: go through a cart so the snapshot,
// the reservation and the coupon redemption run together.
$cart = Cart::create(['currency' => 'EUR', 'shop_id' => $shop->id]);
Shops::cart($cart)->add($variant, 2);
$order = Shops::cart($cart)->checkout(new PlaceOrderData(customer: $customer));Addresses
Billing and shipping addresses are stored as JSON and cast to an immutable Address DTO with name, street, city, postalCode, countryIso and optional company, phone and email:
use RoundlyConsulting\Shops\Orders\DataTransferObjects\Address;
$order->shipping_address; // ?Address — an immutable DTO (JSON column)
$order->shipping_address?->countryIso; // "DE"
$address = Address::fromArray([
'name' => 'Jane Doe', 'street' => 'Main Street 1', 'city' => 'Berlin',
'postal_code' => '10115', 'country_iso' => 'DE', 'phone' => '+49 30 000000',
]);
$address->toArray(); // snake_case keys
// Reuse the cast on your own models:
protected function casts(): array
{
return ['delivery_address' => \RoundlyConsulting\Shops\Support\Casts\AddressCast::class];
}From the customer’s address book
With addresses-for-laravel, build the order’s addresses from the customer’s saved ones. Shipping comes from the primary shipping address, billing from the primary billing address; when there is no billing address, shipping is reused (toggle with shops.addresses.billing_same_as_shipping or the billingSameAsShipping argument). Shops::addresses() returns the AddressBook behind it. The customer model must implement Addressable:
use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Addresses\Contracts\Addressable;
use RoundlyConsulting\Addresses\Traits\HasAddresses;
class User extends Authenticatable implements Addressable
{
use HasAddresses;
}
$order = Shops::cart($cart)->checkout(PlaceOrderData::fromAddressBook($user, couponCode: 'WELCOME10'));
// Or read the defaults yourself:
$defaults = Shops::addresses()->defaults($user); // OrderAddresses
$defaults->shipping; // ?Address
$defaults->billing; // ?AddressAddresses are snapshotted onto the order — there is no foreign key into the address book, so editing a saved address never alters a past order.
The order customer
An order carries an optional polymorphic customer. It powers the address book, store credit and verified-purchase reviews; it is nullable, so guest orders are fully supported:
$order->customer; // ?Model — the buyer, copied from the cart owner at place-order
$order->hasCustomer(); // false for guest ordersShow 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.