> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lunarphp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Checkout

> Build a checkout flow with Lunar, covering address collection, shipping options, order creation, and payment processing.

## 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 `Lunar\Core\Models\Cart` model, `Lunar\Core\Facades\CartSession` facade, and `Lunar\Core\Facades\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:

1. **Collect addresses** — billing address (always required) and shipping address (required for shippable carts)
2. **Select shipping** — choose a shipping method from the available options
3. **Review order** — show the customer a summary before they commit
4. **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

```php theme={null}
use Lunar\Core\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

```php theme={null}
$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\Core\Contracts\Addressable` interface. When called, any existing address of that type on the cart is replaced.

<Info>
  Setting a new shipping address clears any tax zone override on the cart by default, so tax recalculates against the new address. Pass `setShippingAddress($address, clearTaxZone: false)` to keep an existing override.
</Info>

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

```php theme={null}
$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:

```php theme={null}
use Lunar\Core\Models\Country;

$countries = Country::orderBy('name')->get();
```

```blade theme={null}
<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](/2.x/extending/shipping)).

### Fetching Available Options

```php theme={null}
use Lunar\Core\Facades\ShippingManifest;

$cart = CartSession::current();

$shippingOptions = ShippingManifest::getOptions($cart);
```

This returns a collection of `Lunar\Core\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\Core\DataObjects\PriceValue` | The cost of this shipping method                                  |
| `collect`     | `bool`                              | Whether this is a collection/pickup option rather than a delivery |

```blade theme={null}
@foreach($shippingOptions as $option)
    <label>
        <input
            type="radio"
            name="shipping_option"
            value="{{ $option->identifier }}"
            @checked($cart->getShippingOption()?->identifier === $option->identifier)
        >
        {{ $option->name }} — {{ $option->price->format() }}
        @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:

```php theme={null}
use Lunar\Core\DataTypes\ShippingOption;
use Lunar\Core\Facades\ShippingManifest;

$cart = CartSession::current();

$shippingOption = ShippingManifest::getOption($cart, $request->shipping_option);

$cart->setShippingOption($shippingOption);
```

<Info>
  `setShippingOption()` recalculates the cart by default. After it completes, the `shippingSubTotal`, `shippingTaxTotal`, and `shippingTotal` properties on the cart reflect the selected shipping option.
</Info>

## 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.

```php theme={null}
$cart = CartSession::current();
```

```blade theme={null}
<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->format() }}</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->format() }}</dd>

    @if($cart->discountTotal?->value > 0)
        <dt>Discount</dt>
        <dd>-{{ $cart->discountTotal->format() }}</dd>
    @endif

    @if($cart->shippingTotal?->value > 0)
        <dt>Shipping</dt>
        <dd>{{ $cart->shippingTotal->format() }}</dd>
    @endif

    <dt>Tax</dt>
    <dd>{{ $cart->taxTotal->format() }}</dd>

    <dt>Total</dt>
    <dd>{{ $cart->total->format() }}</dd>
</dl>
```

See the [Cart guide](/2.x/guides/cart) for the full list of computed properties available on the cart and its lines.

## Creating the Order

Once the customer is ready to proceed, create the order from the cart. This validates the cart and runs it through the configured creation pipeline, which copies the cart's data (lines, addresses, totals) onto a new `Lunar\Core\Models\Order` and returns it as a draft (with `placed_at` set to `null`).

```php theme={null}
use Lunar\Core\Exceptions\Carts\CartException;
use Lunar\Core\Facades\CartSession;

try {
    $order = CartSession::createOrder();
} catch (CartException $e) {
    return redirect()->back()->withErrors(['checkout' => $e->getMessage()]);
}
```

<Info>
  `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)`.
</Info>

### What Happens During Order Creation

When `createOrder()` is called, Lunar runs through the following steps:

1. Recalculates the cart to ensure totals are up to date
2. Validates the cart (billing address, shipping address, and shipping option, among other checks)
3. Runs the order through the `lunar.orders.pipelines.creation` pipeline, which by default: fills the order with the cart's financial data, currency, and channel; creates the order lines; creates the order addresses; creates a shipping line (if a shipping option is set); and maps the cart's discount breakdown onto the order

The pipeline is configurable, so a store can insert or replace steps in `config/lunar/orders.php`.

### Handling Validation Errors

If validation fails, a `Lunar\Core\Exceptions\Carts\CartException` is thrown. The exception contains a `MessageBag` accessible via the `errors()` method, and its `getMessage()` returns a human-readable summary.

```php theme={null}
use Lunar\Core\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`:

```php theme={null}
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()`:

```php theme={null}
$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 built on the `Lunar\Core\Contracts\PaymentType` contract, so the same API works regardless of the payment provider.

```php theme={null}
use Lunar\Core\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\Core\DataObjects\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     |

<Warning>
  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.
</Warning>

For a full walkthrough of driver selection, the offline driver, and the first-party Stripe and PayPal drivers, see the [Payment Integration guide](/2.x/guides/payment-integration).

## 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.

Order money fields (`sub_total`, `discount_total`, `shipping_total`, `tax_total`, `total`) are stored as plain integers in the currency's minor unit. Format them with the model's `format()` method rather than treating them as objects.

```php theme={null}
use Lunar\Core\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'));
    }
}
```

```blade theme={null}
<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->format('total') }}</p>
    </div>
@endforeach

@if($order->shippingLines->isNotEmpty())
    <h2>Shipping</h2>
    @foreach($order->shippingLines as $line)
        <p>{{ $line->description }}: {{ $line->format('total') }}</p>
    @endforeach
@endif

<h2>Totals</h2>
<dl>
    <dt>Subtotal</dt>
    <dd>{{ $order->format('sub_total') }}</dd>

    @if($order->discount_total > 0)
        <dt>Discount</dt>
        <dd>-{{ $order->format('discount_total') }}</dd>
    @endif

    @if($order->shipping_total > 0)
        <dt>Shipping</dt>
        <dd>{{ $order->format('shipping_total') }}</dd>
    @endif

    <dt>Tax</dt>
    <dd>{{ $order->format('tax_total') }}</dd>

    <dt>Total</dt>
    <dd>{{ $order->format('total') }}</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.

```php theme={null}
// Generate a fingerprint when showing the review page
$fingerprint = $cart->fingerprint();
```

```blade theme={null}
<input type="hidden" name="fingerprint" value="{{ $fingerprint }}">
```

```php theme={null}
// Verify the fingerprint before processing payment
use Lunar\Core\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

```php theme={null}
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 theme={null}
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Lunar\Core\Exceptions\Carts\CartException;
use Lunar\Core\Exceptions\FingerprintMismatchException;
use Lunar\Core\Facades\CartSession;
use Lunar\Core\Facades\Payments;
use Lunar\Core\Facades\ShippingManifest;
use Lunar\Core\Models\Country;
use Lunar\Core\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 [Payment Integration guide](/2.x/guides/payment-integration) for driver selection, the Stripe and PayPal drivers, and post-payment order state.
* Review the [Extending Shipping](/2.x/extending/shipping) guide for adding custom shipping options.
* Review the [Extending Payments](/2.x/extending/payments) guide for building a custom payment driver.
* Review the [Extending Orders](/2.x/extending/orders) guide for customizing the order creation pipeline.
* Review the [Cart guide](/2.x/guides/cart) for building the cart page that precedes checkout.
