Overview
Lunar’s payment system has three layers:- Payment Manager (
Lunar\Core\Facades\Payments) — routes payment calls to the correct driver based on configuration - Payment Type (
Lunar\Core\Contracts\PaymentType) — handles authorization, capture, and refund logic for a specific provider - Transactions (
Lunar\Core\Models\Transaction) — records of every payment event stored against the order
PaymentType contract, so storefront code calls the same methods regardless of which provider is behind Payments::driver().
Configuring Payment Types
Payment types are configured inconfig/lunar/payments.php:
types maps a name (used with Payments::driver()) to a driver. Any other keys added to a type’s array are passed to the driver instance via setConfig(), so a driver can read type-specific settings if it needs them; the built-in offline and Stripe/PayPal drivers do not require any.
Registering a Driver
A driver is made available to the payment manager by callingPayments::extend(), typically from a service provider’s boot() method:
Payments::extend() from their own service providers, which are auto-discovered by Composer — installing lunarphp/stripe or lunarphp/paypal is enough to register the driver. This step only needs to be done manually when building a custom or third-party driver.
The Generic Payment Flow
Every driver is resolved throughPayments::driver() and implements Lunar\Core\Contracts\PaymentType:
cart(), withData(), and authorize() to take a payment:
withData() accepts whatever the driver needs to authorize the payment — a Stripe PaymentIntent ID, a PayPal order ID, or nothing at all for an offline driver. authorize() returns a Lunar\Core\DataObjects\PaymentAuthorize object:
If a draft order does not already exist for the cart, most drivers create one as part of
authorize() — see the Checkout guide for the alternative of calling CartSession::createOrder() explicitly before authorizing.
Working Against an Existing Order
Once an order exists (for example, after a failed first attempt), callorder() instead of cart():
Amount and Currency Verification
A first-party driver verifies the payment amount and currency against the cart or order total before placing the order, so a client-manipulated amount cannot silently under-charge a customer. Both first-party drivers expose anallow_partial_payment config option for stores that intentionally take deposits or part payments.
Offline Payments
For manual or offline payments (cash on delivery, bank transfer, purchase orders), use the built-inoffline driver:
placed_at immediately. No external API calls are made, and no withData() payload is required.
Stripe and PayPal
Lunar ships two first-party payment drivers,lunarphp/stripe and lunarphp/paypal. Both implement the full PaymentType contract, including getPaymentChecks(), verify the authorized amount and currency against the order total, and ship a publishable config file under their own lunar.stripe / lunar.paypal namespace.
- Stripe — Payment Intents API, supports automatic and manual capture, webhooks, address sync, and payment checks (AVS, postal code, CVC)
- PayPal — Orders v2 / Payments v2 REST API, server-side only (the storefront renders PayPal’s own JS buttons)
PaymentType contract shown above, and how the order settles afterward.
Every other payment gateway is third-party: a package built against the PaymentType contract and registered with Payments::extend(), maintained outside the Lunar monorepo.
Post-Payment Order State
placed_at
An order becomes “placed” the moment its driver sets placed_at. Check this with isPlaced() / isDraft():
payment_status
Unlike placed_at, payment_status is not set directly by a driver. It is a derived rollup, recomputed automatically from the order’s transaction ledger whenever a transaction is created or updated, and cast to a Lunar\Core\States\Order\Payment\PaymentStatus state:
Transactions
Every payment event is recorded as aLunar\Core\Models\Transaction on the order. Transactions provide a complete audit trail.
Transaction uses the same format() / decimal() methods as Order, since amount is stored as a plain integer:
Capture and Refund
For manual-capture flows, or refunding an existing order, prefer the verb methods onLunar\Core\Models\Order over calling the payment driver directly — they validate the request (matching transaction type, remaining refundable amount, line quantities) before dispatching to the underlying driver.
Manual Capture
capture() takes the transaction ID and a major-unit amount (a decimal, not minor units) — it converts the amount using the order’s currency before handing it to the driver.
Refunds
RefundRequest also accepts shipping and adjustment (both major-unit amounts) for refunding shipping or an amount not tied to specific lines, and notify to control whether the customer receives a refund notification.
Both verb methods ultimately call the payment driver’s own capture() / refund() methods from the PaymentType contract, so a custom driver only needs to implement those two methods to support this flow.
Payment Checks
Drivers can attach provider-specific checks (AVS, CVC, and similar) to a transaction. Read them through the transaction rather than the driver directly:Lunar\Core\DataObjects\PaymentChecks collection — getPaymentChecks() is part of the PaymentType contract, and a driver author should implement it (or explicitly document it as a no-op) rather than omit it.
Routes
Putting It All Together
Here is a checkout controller that authorizes a payment against whichever driver the customer selected, then shows the confirmation page:Next Steps
- Review the Stripe add-on documentation for PaymentIntent creation, the storefront Elements integration, and webhooks.
- Review the PayPal add-on documentation for the Orders v2 checkout flow and webhooks.
- Review the Payments reference for the full payment API and transaction model.
- Review the Extending Payments guide for building a custom payment driver.
- Review the Checkout guide for the complete checkout flow including address collection and shipping.