Checkout API
Checkout API
Checkout creates an order from a cart, snapshots its customer, address, product, price, and applied discount data, reserves finite stock, and asks the configured payment provider to initiate payment for the total.
The cart's currency becomes the order currency and is passed to the payment provider alongside the currency-independent price amount.
Call the checkout action from your application's controller after validating the request:
use Illuminate\Http\Request;
use Larasell\Larasell\Address;
use Larasell\Larasell\Checkout\Checkout;
use Larasell\Larasell\Models\Cart;
class CheckoutController
{
public function __construct(private Checkout $checkout) {}
public function __invoke(Request $request, Cart $cart)
{
$result = $this->checkout->create($cart, [
'customer_email' => $request->string('email')->toString(),
'customer_name' => $request->string('name')->toString(),
'billing_address' => new Address(
country: $request->string('billing_address.country')->toString(),
firstName: $request->string('billing_address.first_name')->toString(),
lastName: $request->string('billing_address.last_name')->toString(),
street: $request->array('billing_address.street'),
city: $request->string('billing_address.city')->toString(),
postcode: $request->string('billing_address.postcode')->toString(),
),
'shipping_address' => $request->array('shipping_address'),
'customer_id' => $request->user()?->customer?->getKey(),
], paymentMethod: 'bank_transfer');
if ($result->requiresRedirect()) {
return $result->redirect();
}
return redirect()->route('orders.show', $result->order);
}
}
Idempotent checkout
Pass a unique key generated for the customer's checkout submission to make retries return the original order and payment instead of creating duplicates:
$result = $checkout->create(
cart: $cart,
data: $data,
paymentMethod: 'stripe',
paymentOptions: $paymentOptions,
idempotencyKey: $request->header('Idempotency-Key'),
);
Keys must be non-empty strings of at most 255 characters. Generate a new key for each intentional checkout submission and retain it when retrying a request after a timeout or lost response. Do not derive it from the cart ID, because a cart may be used for more than one intentional checkout over its lifetime.
Larasell stores a fingerprint of the cart, customer input, payment method, and
payment options. Reusing a key with different input throws an
InvalidArgumentException. Concurrent requests using the same key and input
resolve to the same order and payment.
Payment providers may be called again with the same persisted payment when an idempotent checkout is retried. Providers must therefore use the payment ID as their provider-side idempotency identity. If provider communication throws, Larasell keeps the local payment pending because its remote outcome is unknown and rethrows the exception. Retry checkout with the same key to recover the provider operation.
customer_id is optional. Leave it null for guest checkout. When the buyer
is signed in, pass the related Customer id ($user->customer), not
the Laravel user id. The email,
name, addresses, product name, slug, SKU, barcode, and prices are always copied
to the order, so later changes to customer or product records do not rewrite
order history.
Applicable promotions are recalculated during checkout. Their identifiers, names, totals, and product or shipping allocations are stored on the order. See the Promotions API for the snapshot structure.
Taxes are recalculated authoritatively during checkout. Provisional or unavailable tax stops checkout, while successful calculations are stored on the order, its items, and shipping. See Taxes for configuration, estimate handling, and tax snapshot fields.
Both addresses accept an Address value object or an associative array. An
address requires country, first_name, last_name, street, city, and
postcode. street may be a string or an ordered array of lines. title,
state, email, phone, company, and tax_id are optional. All values are
stored in the order snapshot.
Order addresses always return Address value objects, regardless of which input form
was used:
$result->order->shipping_address->street;
$result->order->shipping_address->country;
Order status
New orders start as pending_payment. The built-in cash and bank transfer
methods remain pending until payment is recorded manually. Supported order
transitions are:
pending_paymenttopaid,payment_failed, orcancelledpayment_failedtopending_paymentorcancelledpaidtofulfilledorcancelled
Use the model method to enforce these transitions:
use Larasell\Larasell\Enums\OrderStatus;
$order->transitionTo(OrderStatus::Fulfilled);
Payment methods
Cash is the default method. Pass cash or bank_transfer as the third checkout
argument to select a method explicitly:
$result = $checkout->create($cart, $data, paymentMethod: 'cash');
Both built-in methods use the offline driver and create a pending payment. They
do not collect money or expose customer-facing payment instructions. The result
contains the created $result->order, $result->payment, and an optional
$result->action.
The available methods and default can be changed in the published configuration:
use Larasell\Larasell\Payments\OfflinePaymentProvider;
'payments' => [
'default' => 'cash',
'methods' => [
'cash' => [
'driver' => 'offline',
'provider' => OfflinePaymentProvider::class,
],
'bank_transfer' => [
'driver' => 'offline',
'provider' => OfflinePaymentProvider::class,
],
],
],
Recording payment
Use the payment model when an offline payment is received:
$payment = $order->payments()->where('status', 'pending')->firstOrFail();
$payment->markAsPaid();
This atomically marks the payment as succeeded, records paid_at, and moves
the order to paid. Repeating the action is safe and does not replace the
original payment timestamp. The Larasell admin order page exposes the same
operation for pending payments.
A pending attempt can instead be cancelled with $payment->cancel(). Cancellation
only changes the payment to cancelled; it does not cancel the order or restore
stock.
Refunds
Successful payments can be refunded in full or in part. Omitting the amount refunds the remaining amount that is not already refunded or reserved by a pending refund:
use Larasell\Larasell\Price;
$refund = $payment->refund();
$partialRefund = $payment->refund(Price::of(2500));
Cash and bank transfer refunds remain pending until their real-world transfer
is confirmed manually:
$refund->markAsSucceeded();
// or: $refund->markAsFailed($message);
// or: $refund->cancel();
$payment->refundedAmount(), $payment->pendingRefundAmount(), and
$payment->refundableAmount() expose the financial totals. Pending refunds
reserve their amount so concurrent attempts cannot exceed the successful
payment. The original payment remains succeeded; refund records preserve the
separate money movement and its history.
Cancelling an order
Unpaid orders can be cancelled directly. Pending payments are cancelled and inventory deducted during checkout is restored by default.
$order->cancel();
Pass restock: false when the inventory should remain deducted:
$order->cancel(restock: false);
Paid orders can only be cancelled after every successful payment has been fully refunded. A refund never cancels an order automatically, and fulfilled orders cannot be cancelled even after a full refund. Order items store the quantity actually deducted from finite inventory, so cancellation does not infer restocking from the product's current settings.
Lifecycle events
Larasell dispatches dedicated events after their database transaction commits:
Larasell\Larasell\Events\OrderPlacedLarasell\Larasell\Events\OrderPaidLarasell\Larasell\Events\OrderFulfilledLarasell\Larasell\Events\OrderCancelledLarasell\Larasell\Events\PaymentPendingLarasell\Larasell\Events\PaymentSucceededLarasell\Larasell\Events\PaymentFailedLarasell\Larasell\Events\PaymentCancelledLarasell\Larasell\Events\RefundPendingLarasell\Larasell\Events\RefundSucceededLarasell\Larasell\Events\RefundFailedLarasell\Larasell\Events\RefundCancelled
Each order event exposes an $order property and each payment event exposes a
$payment property. Refund events expose a $refund property. Idempotent
operations do not dispatch duplicate events.
use Illuminate\Support\Facades\Event;
use Larasell\Larasell\Events\OrderPaid;
Event::listen(OrderPaid::class, function (OrderPaid $event) {
// Handle the paid order through $event->order.
});
Custom providers must implement Larasell\Larasell\Contracts\PaymentProvider
and return a Larasell\Larasell\Payments\PaymentResult from initiate().
Custom payment providers
Checkout creates the local order and pending payment before invoking a provider.
The provider therefore receives stable models that can be included in provider
metadata and idempotency keys. The request's $breakdown contains final product
and shipping amounts after discounts, tax, and rounding:
$request->breakdown->lines; // list of PaymentLine
$request->breakdown->shipping; // PaymentLine|null
$request->breakdown->total; // equals $request->payment->amount
PaymentLine::$amount is the final amount for the complete order line, not a
unit price. Its quantity preserves the purchased quantity for display and
metadata. A provider that multiplies unit prices by quantity should submit the
line with provider quantity 1, using Larasell's line amount directly, and
include the purchased quantity in its label or description. This avoids
fractional-cent errors when a discount or tax allocation cannot be divided
evenly across the purchased quantity.
The breakdown constructor validates that all product and shipping amounts add up exactly to the persisted payment amount. Providers must not recalculate discounts or taxes from catalog prices.
The complete request can then be used to create a provider payment:
use Larasell\Larasell\Contracts\PaymentProvider;
use Larasell\Larasell\Models\Payment;
use Larasell\Larasell\Payments\PaymentRequest;
use Larasell\Larasell\Payments\PaymentResult;
use Larasell\Larasell\Payments\RedirectPaymentAction;
final class HostedPaymentProvider implements PaymentProvider
{
public function initiate(PaymentRequest $request): PaymentResult
{
$session = $this->client->createSession([
'amount' => $request->payment->amount->amount(),
'currency' => $request->order->currency->value,
'success_url' => $request->option('success_url'),
'metadata' => [
'order_id' => $request->order->getKey(),
'payment_id' => $request->payment->getKey(),
],
'idempotency_key' => 'payment-'.$request->payment->getKey(),
]);
return PaymentResult::pending(
reference: $session->id,
action: new RedirectPaymentAction($session->url),
);
}
}
Provider-specific checkout values can be passed as the fourth argument:
$result = $checkout->create(
$cart,
$data,
paymentMethod: 'hosted',
paymentOptions: [
'success_url' => route('checkout.success'),
'cancel_url' => route('checkout.cancel'),
],
);
Providers return PaymentResult::pending(), PaymentResult::succeeded(), or
PaymentResult::failed(). Provider exceptions are recorded as failed payments.
For asynchronous providers, use the provider reference from a verified webhook:
$payment = Payment::findByProviderReference('hosted', $providerReference);
$payment->markAsPaid();
// or: $payment->markAsFailed($message);
Return and cancellation URLs are storefront navigation only. They must not mark a payment as paid; asynchronous provider webhooks are authoritative.
Providers that support refunds additionally implement
Larasell\Larasell\Contracts\RefundProvider. Its refund() method receives a
persisted payment and refund through RefundRequest and returns
RefundResult::pending(), succeeded(), failed(), or cancelled(). Providers
that only implement PaymentProvider continue to work, but their payments
cannot be refunded through Larasell.
Variant order history
Cart lines are checked out against their concrete variant. Checkout locks and revalidates variant availability and stock before creating the order.
Each order item stores a nullable current variant() relation and immutable
transaction data in product_sku, product_barcode, unit_price,
variant_name, and variant_options. The option snapshot contains stable IDs
and slugs plus the customer-facing labels used at checkout. Deleting or
renaming catalog variants therefore does not rewrite historical orders.