A self-contained, Lunar-owned Inertia + Vue checkout application. It ships
prebuilt, owns its own build, Inertia root and route, and works the moment
you install it. Your storefront — React, Next, Astro, Blade, plain HTML, or no
JS at all — simply links or redirects a customer to /checkout. Inertia,
Vue and Vite are an internal implementation detail; your storefront needs none
of them.
This is the Cashier / Telescope / Horizon pattern: working prebuilt defaults out of the box, publish-to-own when you need full control — and, in between, a runtime seam to add your own elements and payment gateways without forking or rebuilding anything.
Design lives in
spec/— start withspec/overview.md. This README is the how-to for the scaffolding those specs describe (0008 + 0009).
composer require lunarphp/checkoutThat's it. The package registers GET /checkout, serves the prebuilt app, and
streams its assets same-origin from the package — no npm, no Vite, no
vendor:publish, no build. Point your storefront's "Checkout" button at
/checkout.
The single most important thing to understand: adding your own checkout UI and owning the checkout app are independent. Do not conflate them.
| You want to… | Tier | Publish the app? | Rebuild the app? |
|---|---|---|---|
| Re-brand (colours, radii, fonts) | 1 — Theme | No | No |
| Add / replace an element or payment gateway | 2 — Contribute | No | No |
| Rewrite the checkout's own markup/behaviour | 3 — Publish & own | Yes | Yes |
Tiers 1 and 2 leave the prebuilt app untouched. You only reach tier 3 to change Lunar's own components — never just to add one of yours.
The theme is an immutable DTO bound in the container. Rebind it in a service provider; the override applies client-side via CSS custom properties (CSP-safe, no rebuild).
use Lunar\Checkout\DataObjects\CheckoutTheme;
// AppServiceProvider::register()
$this->app->bind(CheckoutTheme::class, fn () =>
CheckoutTheme::tender()->with(accent: '#DB2777', radiusMd: '4px')
);Per-channel theming: bind a closure that resolves the theme from the current channel. Same seam.
A checkout element has two halves: a server descriptor (what data it captures, where it sits) and a Vue component (how it renders). The prebuilt app can't contain a component it has never heard of — so a contributor ships a tiny self-registering chunk that drops its component into the running app.
// your package — or your own app's AppServiceProvider::boot()
Checkout::add(GiftMessageElement::class); // a CheckoutElement
// or, for a gateway:
Payment::registerMethod(StripeCardMethod::class); // a PaymentMethod (spec 0002)use Lunar\Checkout\Facades\CheckoutAssets;
CheckoutAssets::register(
package: 'acme-gift-message',
source: __DIR__.'/../resources/dist', // your OWN prebuilt chunk dir
entry: 'checkout.js', // the self-registering ES module
compat: '^1.0', // SDK range it was built against
);That's the whole job. The chunk is served same-origin from
/checkout/assets/{package}/{file} — no vendor:publish — and its
<script type="module"> is emitted into the checkout after the app boots.
// resources/js/checkout.js (built into resources/dist/checkout.js)
import { registerCheckoutElement } from '@lunarphp/checkout'
import GiftMessage from './GiftMessage.vue'
registerCheckoutElement('gift-message', GiftMessage) // key === server component()Inside GiftMessage.vue you may call useCheckout() exactly like a built-in
element — because every chunk shares the app's one Vue runtime and SDK (see
§6). An unknown key renders a dev fallback until its chunk loads; the registry is
reactive, so it appears in place the moment it registers.
Build with the Lunar preset so vue and @lunarphp/checkout are externalised
(never bundled — that's what keeps a single runtime):
// your package's vite.config.js
import { defineCheckoutElement } from '@lunarphp/checkout-element'
export default defineCheckoutElement({ entry: 'resources/js/checkout.js', name: 'acme-gift-message' })The host app is a first-class contributor. Writing a bespoke element just for your own store (not a distributable package)? Do exactly the same from your
AppServiceProvider— build a chunk,CheckoutAssets::register(...). Still no app publish, no app rebuild.
First-party gateways (Stripe/PayPal/Opayo) are prebuilt into the shipped app, so the common case is true zero-build install-and-go; the runtime chunk carries everything else (spec 0009 §F).
Only when you need to change the checkout's own markup or behaviour:
php artisan vendor:publish --tag=lunar.checkout.source # resources/js, resources/css, package.json, vite.config.js
php artisan vendor:publish --tag=lunar.checkout.views # the Inertia root Blade (optional)// config/lunar/checkout.php — turn OFF the package routes
'routes' => false,Then register your own route + controller pointing at the published app, edit the Vue components, and build the app's own Vite (it is standalone — not wired into your storefront's bundler; you need only Node):
cd resources/vendor/lunar-checkout
npm install
npm run buildYou now own those files; package upgrades no longer touch them.
- Install-and-go needs no publish. The app's own prebuilt bundle streams from
GET /checkout/build/{file}and contributed chunks fromGET /checkout/assets/{package}/{file}— both same-origin, both straight from packagedist/. A strictscript-src 'self'is satisfied; only registered files are servable (no path traversal). - One Vue runtime, one SDK. The app bundle exposes
window.Vueandwindow.Lunar(the SDK). Contributed chunks externalisevue/@lunarphp/checkoutto those globals (the build preset does this), so a gateway component shares the app's reactivity andinjects the sameCheckoutProvider. Two Vue runtimes would silently breakuseCheckout()— the preset exists to make that impossible.
A checkout session is the addressable record of an in-progress checkout. It is
created from a cart by a swappable checkout driver, carries a public UUID, and
pins a snapshot of the cart's currency, channel and totals at the moment
checkout begins — so the amount a customer pays can't drift if the cart changes
underneath them. The default lunar driver targets Lunar's cart + order.
You don't build any of this yourself — your storefront just creates a session and sends the customer to it.
Resolve the driver and hand it the current cart. You get back a session with a
url — redirect the customer there and the hosted checkout takes over.
use Lunar\Checkout\Contracts\CheckoutDriver;
use Lunar\Core\Facades\CartSession;
class CheckoutController
{
public function __invoke(CheckoutDriver $checkout)
{
$session = $checkout->createSession(CartSession::current());
return redirect($session->url); // → /checkout/{uuid}
}
}createSession() snapshots the cart once. From here the session owns its
amounts (amount_subtotal, amount_total, in minor units), currency_code,
channel_id and locale; later edits to the shopper's cart never move them. A
re-checkout makes a fresh session (a cart can have many).
When you take payment, verify the gateway intent against the session's pinned total — never the live cart:
$session = CheckoutSession::where('uuid', $uuid)->firstOrFail();
abort_unless(
$intent->amount === $session->amount_total, // pinned minor-unit total
422,
'Payment amount no longer matches the checkout.'
);The uuid is the route key — a bearer capability token, unguessable and never the
database id. Bind it straight into a route to resume or render a checkout:
use Lunar\Checkout\Models\CheckoutSession;
Route::get('/checkout/{checkoutSession}', function (CheckoutSession $session) {
abort_if($session->isExpired(), 410); // terminal sessions aren't operable
return view('checkout', [
'total' => $session->amount_total,
'currency' => $session->currency_code,
'status' => $session->status->label(), // Open / Completed / Expired …
]);
});Each session gets an expires_at (default 24h — config('lunar.checkout.session.expires_after'))
and a lifecycle state. Check it on read with isExpired(); a scheduled command
flips stale Open sessions to Expired (it deliberately leaves
PaymentProcessing alone — payment may still be confirming):
$session->isExpired(); // bool — past expires_at, or a terminal Expired state
// runs hourly out of the box; or invoke directly:
php artisan lunar:checkout:expire-sessionsFor hosted flows that bounce back to your storefront, or to stamp your own correlation data, drive the create-action directly — it accepts caller extras and echoes them back on the session:
use Lunar\Checkout\Contracts\Actions\CreatesCheckoutSession;
$session = app(CreatesCheckoutSession::class)->execute(CartSession::current(), [
'success_url' => route('orders.thanks'),
'cancel_url' => route('cart.index'),
'client_reference_id' => $yourOrderRef,
'metadata' => ['gift' => true, 'source' => 'mobile-app'],
]);The session, UUID and state machine are backend-neutral; only the driver knows
your cart and order shapes. Register your own and select it by name — no
config(...class) swap, the standard Manager extend() seam:
// YourServiceProvider::register()
use Lunar\Checkout\Managers\CheckoutSessionManager;
$this->app->make(CheckoutSessionManager::class)
->extend('statamic', fn ($app) => new StatamicCheckoutDriver(/* … */));// config/lunar/checkout.php
'driver' => 'statamic',Your driver's createSession() ingests whatever you call a cart and complete()
finalises it into your own order model (linked via the session's order morph) —
everything in between behaves identically.
npm install
npm run build # emits the prebuilt dist/ (committed/released artefact)
npm run dev # Vite dev server, for working on the app's own componentsdist/ is what install-and-go consumers run; rebuild it on every UI change.
composer install
composer test # pest + phpstan
vendor/bin/pint # code styleWhile Lunar v2 is in alpha, lunarphp/core on Packagist can lag behind the
contracts this package builds against. To test against a local monorepo
checkout, point Composer at it (not committed, both paths are local to you):
composer config repositories.lunar '{"type":"path","url":"../lunar/packages/core","options":{"symlink":true}}'
composer require "lunarphp/core:2.x-dev" -WRevert with git checkout -- composer.json && composer update lunarphp/core.
- The suite runs serially in CI. Two files reach for helpers and fake gateway
classes declared inside a sibling test file (
routeTestCart()inCheckoutRouteTest,registerFakeHoldGateway()/FakeHoldGatewayinPaymentHoldTest), which only resolve when both land in one process. Move them intotests/Pest.phpandtests/Utils/to restore--parallel. - The Stripe element components (
resources/js/components/payments/) ship in this package. Deliberate: it supports Stripe first, and theregisterCheckoutElementseam lets a consumer swap them.
| Path | Role |
|---|---|
resources/js/app.js |
Inertia client boot; exposes window.Vue + window.Lunar (spec 0008/0009) |
resources/js/index.js |
the @lunarphp/checkout SDK surface chunks build against |
resources/js/composables/elements.js |
the reactive component registry (registerCheckoutElement) |
resources/views/app.blade.php |
the Inertia root view — bundle tags + contributed-chunk loop |
vite.config.js / package.json |
the app's own build toolchain |
src/Support/CheckoutAssets.php |
server registry of contributed chunks |
src/Support/CheckoutBundle.php |
resolves the app's own prebuilt bundle from dist/ |
src/Http/Controllers/CheckoutController.php |
renders the app; streams bundle + chunks same-origin |
config/checkout.php |
route path/middleware, routes toggle, driver |