Overview
The checkout flow takes a customer from their cart through to a placed order. This typically involves collecting billing and shipping addresses, selecting a shipping method, reviewing the order, and processing payment. This guide walks through building a checkout using Lunar’s Cart model, CartSession facade, and Payments facade.
The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API.
Checkout Flow
A typical checkout follows these steps:
- Collect addresses — billing address (always required) and shipping address (required for shippable carts)
- Select shipping — choose a shipping method from the available options
- Review order — show the customer a summary before they commit
- Process payment — authorize the payment and place the order
Lunar does not enforce a specific page structure. These steps can be spread across multiple pages, combined into a single page, or presented as an accordion. The underlying API calls are the same regardless of layout.
Collecting Addresses
Before an order can be created, the cart needs at least a billing address. If the cart contains shippable items, a shipping address is also required.
Setting the Billing Address
use Lunar\Facades\CartSession;
$cart = CartSession::current();
$cart->setBillingAddress([
'country_id' => $request->country_id,
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'company_name' => $request->company_name,
'line_one' => $request->line_one,
'line_two' => $request->line_two,
'line_three' => $request->line_three,
'city' => $request->city,
'state' => $request->state,
'postcode' => $request->postcode,
'contact_email' => $request->contact_email,
'contact_phone' => $request->contact_phone,
]);
Setting the Shipping Address
$cart->setShippingAddress([
'country_id' => $request->country_id,
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'company_name' => $request->company_name,
'line_one' => $request->line_one,
'line_two' => $request->line_two,
'line_three' => $request->line_three,
'city' => $request->city,
'state' => $request->state,
'postcode' => $request->postcode,
'delivery_instructions' => $request->delivery_instructions,
'contact_email' => $request->contact_email,
'contact_phone' => $request->contact_phone,
]);
Both methods accept either an associative array or an object implementing the Lunar\Base\Addressable interface. When called, any existing address of that type on the cart is replaced.
Required Address Fields
When creating an order, Lunar validates that the following fields are present on the billing address (and shipping address, if applicable):
| Field | Description |
|---|
country_id | Foreign key to the lunar_countries table |
first_name | Customer’s first name |
line_one | First line of the address |
city | City or town |
postcode | Postal or ZIP code |
Using the Same Address for Both
A common pattern is to let the customer check a box to copy their billing address to the shipping address:
$cart->setBillingAddress($request->billing);
if ($request->boolean('ship_to_billing')) {
$cart->setShippingAddress($request->billing);
} else {
$cart->setShippingAddress($request->shipping);
}
Populating the Country Select
Lunar ships with a Country model that can be used to populate a country dropdown:
use Lunar\Models\Country;
$countries = Country::orderBy('name')->get();
<select name="country_id">
@foreach($countries as $country)
<option value="{{ $country->id }}">{{ $country->name }}</option>
@endforeach
</select>
Shipping Options
Once a shipping address is set, the available shipping methods can be retrieved using the ShippingManifest facade. Shipping options are configured by extending Lunar’s shipping system (see Extending Shipping).
Fetching Available Options
use Lunar\Facades\ShippingManifest;
$cart = CartSession::current();
$shippingOptions = ShippingManifest::getOptions($cart);
This returns a collection of Lunar\DataTypes\ShippingOption objects. Each option has the following properties:
| Property | Type | Description |
|---|
name | string | Display name for the option (e.g., “Standard Delivery”) |
description | string | A longer description of the option |
identifier | string | Unique identifier used to select this option |
price | Lunar\DataTypes\Price | The cost of this shipping method |
collect | bool | Whether this is a collection/pickup option rather than a delivery |
@foreach($shippingOptions as $option)
<label>
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
@checked($cart->getShippingOption()?->identifier === $option->identifier)
>
{{ $option->name }} — {{ $option->price->formatted() }}
@if($option->description)
<span>{{ $option->description }}</span>
@endif
</label>
@endforeach
Setting the Shipping Option
After the customer selects a shipping method, apply it to the cart. Use the ShippingManifest facade to retrieve the full ShippingOption object by its identifier:
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest;
$cart = CartSession::current();
$shippingOption = ShippingManifest::getOption($cart, $request->shipping_option);
$cart->setShippingOption($shippingOption);
setShippingOption() automatically recalculates the cart. After it completes, the shippingSubTotal, shippingTaxTotal, and shippingTotal properties on the cart reflect the selected shipping option.
Order Review
Before placing the order, display a summary so the customer can verify their selections. At this point the cart has addresses, a shipping option (if applicable), and calculated totals.
$cart = CartSession::current();
<h2>Order Summary</h2>
<h3>Items</h3>
@foreach($cart->lines as $line)
<div>
<p>{{ $line->purchasable->product->attr('name') }}</p>
<p>Qty: {{ $line->quantity }}</p>
<p>{{ $line->total->formatted() }}</p>
</div>
@endforeach
<h3>Shipping Address</h3>
@if($cart->shippingAddress)
<p>{{ $cart->shippingAddress->first_name }} {{ $cart->shippingAddress->last_name }}</p>
<p>{{ $cart->shippingAddress->line_one }}</p>
<p>{{ $cart->shippingAddress->city }}, {{ $cart->shippingAddress->postcode }}</p>
@endif
<h3>Billing Address</h3>
@if($cart->billingAddress)
<p>{{ $cart->billingAddress->first_name }} {{ $cart->billingAddress->last_name }}</p>
<p>{{ $cart->billingAddress->line_one }}</p>
<p>{{ $cart->billingAddress->city }}, {{ $cart->billingAddress->postcode }}</p>
@endif
<h3>Totals</h3>
<dl>
<dt>Subtotal</dt>
<dd>{{ $cart->subTotal->formatted() }}</dd>
@if($cart->discountTotal?->value > 0)
<dt>Discount</dt>
<dd>-{{ $cart->discountTotal->formatted() }}</dd>
@endif
@if($cart->shippingTotal?->value > 0)
<dt>Shipping</dt>
<dd>{{ $cart->shippingTotal->formatted() }}</dd>
@endif
<dt>Tax</dt>
<dd>{{ $cart->taxTotal->formatted() }}</dd>
<dt>Total</dt>
<dd>{{ $cart->total->formatted() }}</dd>
</dl>
Creating the Order
Once the customer is ready to proceed, create the order from the cart. This validates the cart, copies all data (lines, addresses, totals) to a new Lunar\Models\Order, and returns it as a draft (with placed_at set to null).
use Lunar\Exceptions\Carts\CartException;
use Lunar\Facades\CartSession;
try {
$order = CartSession::createOrder();
} catch (CartException $e) {
return redirect()->back()->withErrors(['checkout' => $e->getMessage()]);
}
CartSession::createOrder() removes the cart from the session by default. To keep the cart in the session (for example, to allow the customer to return to their cart if payment fails), pass false: CartSession::createOrder(forget: false).
What Happens During Order Creation
When createOrder() is called, Lunar runs through the following steps:
- Recalculates the cart to ensure totals are up to date
- Validates the cart (billing address, shipping address, and shipping option)
- Creates the order with financial data, currency, and channel from the cart
- Copies cart lines to order lines
- Copies cart addresses to order addresses
- Creates a shipping line on the order (if a shipping option is set)
- Maps any discount breakdowns to the order
Handling Validation Errors
If validation fails, a Lunar\Exceptions\Carts\CartException is thrown. The exception contains a MessageBag accessible via the errors() method, and its getMessage() returns a human-readable summary.
use Lunar\Exceptions\Carts\CartException;
try {
$order = CartSession::createOrder();
} catch (CartException $e) {
return redirect()->route('checkout.show')
->withErrors(['checkout' => $e->getMessage()]);
}
For more detail, access the underlying MessageBag:
try {
$order = CartSession::createOrder();
} catch (CartException $e) {
// $e->errors() returns a MessageBag with the validation failures
return redirect()->route('checkout.show')
->withErrors($e->errors());
}
Checking if the Cart Can Create an Order
To check whether the cart is ready without throwing exceptions, use canCreateOrder():
$cart = CartSession::current();
if (! $cart->canCreateOrder()) {
// Show a message or redirect back
}
Processing Payment
After the order is created, process the payment using the Payments facade. Lunar uses a driver-based approach, so the same API works regardless of the payment provider.
Basic Payment Flow
use Lunar\Facades\Payments;
$payment = Payments::driver('card')
->cart($cart)
->withData([
'payment_token' => $request->payment_token,
])
->authorize();
if ($payment->success) {
return redirect()->route('checkout.complete', [
'order' => $payment->orderId,
]);
}
return redirect()->back()->withErrors([
'payment' => $payment->message ?? 'Payment could not be processed.',
]);
The authorize() method returns a Lunar\Base\DataTransferObjects\PaymentAuthorize object with the following properties:
| Property | Type | Description |
|---|
success | bool | Whether the payment was authorized |
message | ?string | An error or status message |
orderId | ?int | The ID of the placed order |
paymentType | ?string | The type/driver of the payment |
An order is only considered “placed” when its placed_at column has a datetime value. The payment driver is responsible for setting this. A draft order (where placed_at is null) has not been placed, even if an order record exists.
Offline Payments
For manual or offline payments (cash on delivery, bank transfer, etc.), use the built-in offline driver:
$payment = Payments::driver('cash-in-hand')
->cart($cart)
->authorize();
The offline driver automatically creates a draft order (if one does not exist), sets placed_at, and updates the order status to the configured “authorized” status.
Configuring Payment Types
Payment types are configured in config/lunar/payments.php:
return [
'default' => env('PAYMENTS_TYPE', 'offline'),
'types' => [
'cash-in-hand' => [
'driver' => 'offline',
'authorized' => 'payment-offline',
],
'card' => [
'driver' => 'stripe',
'released' => 'payment-received',
],
],
];
See the Payments reference for details on configuring drivers, and the Extending Payments guide for building custom payment drivers.
Order Confirmation
After a successful payment, redirect the customer to a confirmation page. The order is now placed and contains all the data needed for a receipt.
use Lunar\Models\Order;
class CheckoutController extends Controller
{
public function complete(Order $order)
{
if ($order->isDraft()) {
abort(404);
}
$order->load([
'lines.purchasable.product',
'addresses',
'transactions',
]);
return view('checkout.complete', compact('order'));
}
}
<h1>Order Confirmed</h1>
<p>Thank you for your order!</p>
<p>Order Reference: {{ $order->reference }}</p>
<h2>Items</h2>
@foreach($order->productLines as $line)
<div>
<p>{{ $line->description }}</p>
<p>Qty: {{ $line->quantity }}</p>
<p>{{ $line->total->formatted() }}</p>
</div>
@endforeach
@if($order->shippingLines->isNotEmpty())
<h2>Shipping</h2>
@foreach($order->shippingLines as $line)
<p>{{ $line->description }}: {{ $line->total->formatted() }}</p>
@endforeach
@endif
<h2>Totals</h2>
<dl>
<dt>Subtotal</dt>
<dd>{{ $order->sub_total->formatted() }}</dd>
@if($order->discount_total->value > 0)
<dt>Discount</dt>
<dd>-{{ $order->discount_total->formatted() }}</dd>
@endif
@if($order->shipping_total->value > 0)
<dt>Shipping</dt>
<dd>{{ $order->shipping_total->formatted() }}</dd>
@endif
<dt>Tax</dt>
<dd>{{ $order->tax_total->formatted() }}</dd>
<dt>Total</dt>
<dd>{{ $order->total->formatted() }}</dd>
</dl>
Cart Fingerprinting
Lunar provides a fingerprint mechanism to detect when cart contents change between checkout steps. This is useful for verifying that the cart has not been modified (in another tab, for example) between the time the customer reviewed their order and the time they submitted payment.
// Generate a fingerprint when showing the review page
$fingerprint = $cart->fingerprint();
<input type="hidden" name="fingerprint" value="{{ $fingerprint }}">
// Verify the fingerprint before processing payment
use Lunar\Exceptions\FingerprintMismatchException;
try {
$cart->checkFingerprint($request->fingerprint);
} catch (FingerprintMismatchException $e) {
return redirect()->route('checkout.review')
->withErrors(['cart' => 'Your cart has changed. Please review your order again.']);
}
Routes
use App\Http\Controllers\CheckoutController;
Route::get('/checkout', [CheckoutController::class, 'show'])->name('checkout.show');
Route::post('/checkout/addresses', [CheckoutController::class, 'saveAddresses'])->name('checkout.addresses');
Route::post('/checkout/shipping', [CheckoutController::class, 'saveShipping'])->name('checkout.shipping');
Route::post('/checkout/payment', [CheckoutController::class, 'payment'])->name('checkout.payment');
Route::get('/checkout/complete/{order}', [CheckoutController::class, 'complete'])->name('checkout.complete');
Putting It All Together
Here is a complete controller covering the checkout flow:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Facades\CartSession;
use Lunar\Facades\Payments;
use Lunar\Facades\ShippingManifest;
use Lunar\Models\Country;
use Lunar\Models\Order;
class CheckoutController extends Controller
{
public function show()
{
$cart = CartSession::current();
if (! $cart || $cart->lines->isEmpty()) {
return redirect()->route('cart.show');
}
$countries = Country::orderBy('name')->get();
$shippingOptions = $cart->shippingAddress
? ShippingManifest::getOptions($cart)
: collect();
return view('checkout.show', [
'cart' => $cart,
'countries' => $countries,
'shippingOptions' => $shippingOptions,
]);
}
public function saveAddresses(Request $request)
{
$request->validate([
'billing.country_id' => 'required|exists:lunar_countries,id',
'billing.first_name' => 'required|string',
'billing.last_name' => 'required|string',
'billing.line_one' => 'required|string',
'billing.city' => 'required|string',
'billing.postcode' => 'required|string',
'billing.contact_email' => 'required|email',
'shipping.country_id' => 'required_unless:ship_to_billing,true|exists:lunar_countries,id',
'shipping.first_name' => 'required_unless:ship_to_billing,true|string',
'shipping.last_name' => 'required_unless:ship_to_billing,true|string',
'shipping.line_one' => 'required_unless:ship_to_billing,true|string',
'shipping.city' => 'required_unless:ship_to_billing,true|string',
'shipping.postcode' => 'required_unless:ship_to_billing,true|string',
]);
$cart = CartSession::current();
$cart->setBillingAddress($request->billing);
$cart->setShippingAddress(
$request->boolean('ship_to_billing')
? $request->billing
: $request->shipping
);
return redirect()->route('checkout.show');
}
public function saveShipping(Request $request)
{
$request->validate([
'shipping_option' => 'required|string',
]);
$cart = CartSession::current();
$shippingOption = ShippingManifest::getOption($cart, $request->shipping_option);
if (! $shippingOption) {
return redirect()->back()
->withErrors(['shipping_option' => 'The selected shipping option is not available.']);
}
$cart->setShippingOption($shippingOption);
return redirect()->route('checkout.show');
}
public function payment(Request $request)
{
$cart = CartSession::current();
try {
$cart->checkFingerprint($request->fingerprint);
} catch (FingerprintMismatchException $e) {
return redirect()->route('checkout.show')
->withErrors(['cart' => 'Your cart has changed. Please review your order again.']);
}
try {
$order = CartSession::createOrder(forget: false);
} catch (CartException $e) {
return redirect()->route('checkout.show')
->withErrors(['checkout' => $e->getMessage()]);
}
$payment = Payments::driver($request->input('payment_type', 'cash-in-hand'))
->cart($cart)
->withData($request->only('payment_token'))
->authorize();
if (! $payment->success) {
return redirect()->route('checkout.show')
->withErrors(['payment' => $payment->message ?? 'Payment could not be processed.']);
}
CartSession::forget();
return redirect()->route('checkout.complete', [
'order' => $payment->orderId ?? $order->id,
]);
}
public function complete(Order $order)
{
if ($order->isDraft()) {
abort(404);
}
$order->load([
'lines.purchasable.product',
'addresses',
]);
return view('checkout.complete', compact('order'));
}
}
Next Steps
- Review the Payments reference for payment driver configuration, transactions, and refunds.
- Review the Extending Shipping guide for adding custom shipping options.
- Review the Extending Payments guide for building a custom payment driver.
- Review the Extending Orders guide for customizing order creation and adding post-creation behavior.
- Review the Cart guide for building the cart page that precedes checkout.