Skip to content

Return pages and the pay link

Configure your final pages once — app routes, or a frontend/SPA on another origin:

config/billing.php
'return_urls' => [
'success' => 'https://app.example.com/checkout/success',
'failed' => 'https://app.example.com/checkout/failed',
],

The gateway is not sent there directly. It gets the package’s return route, billing/return/{payment}/{outcome} (billing.return, GET and POST, no CSRF), which:

  1. fires CheckoutReturned($payment, $outcome, $data);
  2. 303-redirects to return_urls.{outcome} with ?payment={id} appended (and any incoming query params).

The hop exists because WayForPay and Hutko return the customer with an auto-submitted POST, which the route accepts without CSRF exceptions on your side, and the 303 turns into a plain GET — something a SPA page could never receive directly.

The return page is UX only. The browser may come back before the webhook, after it, or never; CheckoutReturned and its $data (whatever the gateway put in the redirect) are unverified. Never fulfil an order from them.

Route::get('/checkout/success', function (Request $request) {
$payment = Payment::findOrFail($request->query('payment'));
return view('checkout.result', [
'paid' => $payment->isPaid(),
'failed' => $payment->isFailed() || $payment->status === PaymentStatus::Canceled,
]);
});

If return_urls.{outcome} is not configured, the return route answers 404.

Need more than the payment id on your page? ChargeOptions::$returnParams rides through the hop as query parameters:

Billing::charge($payment, new ChargeOptions(returnParams: ['order' => $order->number]));
// → https://app.example.com/checkout/success?order=1042&payment={id}

payment always wins over a same-named param. These are display hints — not proof of anything.

ChargeOptions(successUrl: ..., failUrl: ...) sends a URL to the gateway as is — no CheckoutReturned, no ?payment=. With WayForPay or Hutko the POST-style return is then yours to accept. Monobank has one redirectUrl for both outcomes, so failUrl is ignored there.

route('billing.pay', $payment) (GET billing/pay/{payment}) is the URL to put in an email or on an invoice — unlike payment_url, it never goes stale:

Payment stateThe link
pending with a live checkout (hasActivePaymentUrl())Redirects to the gateway
Expired, failed or canceledIssues a fresh checkout via charge(), then redirects. The old gateway-side checkout is left to expire (Paddle cancels it)
paidRedirects to return_urls.success with ?payment={id}
pending with external_id and no payment_url — an off-session charge (chargeWithMethod(), a renewal) waiting for its webhookRedirects to return_urls.success with ?payment={id}; no checkout is issued (since 0.12.11)
A refund row404

Every visit fires PaymentLinkOpened($payment) — an analytics signal (“opened twice, never paid”), nothing more.

The link is public and unauthenticated by design: it opens a checkout, not a document, and the id is a UUID. Re-issues are serialized per payment with a cache lock (billing:reissue:{id}, waits up to 15 s), so a double click or a mail client prefetching links doesn’t create two live checkouts — the second request reuses the link the first one stored. The lock is only as wide as your cache store (see Refunds).

A gateway may refuse a re-issue for a reference it considers final; that surfaces as the driver’s exception. Hutko, LiqPay and WayForPay use the payment id as their order id, so they refuse a re-issue of a payment they have already seen (Hutko: Duplicate order) — for a failed or expired payment there, create a new Payment.

A re-issue builds its ChargeOptions through Contracts\ReissueChargeOptionsContract — empty by default. The original call’s options were never stored, so saveCard, description, raw and the like are not repeated. Receipt items still auto-fill from a HasReceiptItems payable, but the package’s own Subscription isn’t one.

Two omissions matter: saveCard (without a token a package-managed subscription can never renew) and the fiscal basket. Bind your own resolver to carry the intent across:

use Fomvasss\Billing\Contracts\ReissueChargeOptionsContract;
use Fomvasss\Billing\DTO\ChargeOptions;
use Fomvasss\Billing\Models\Payment;
use Fomvasss\Billing\Models\Subscription;
class ReissueOptions implements ReissueChargeOptionsContract
{
public function resolve(Payment $payment): ChargeOptions
{
return new ChargeOptions(
saveCard: $payment->payable instanceof Subscription,
description: "Payment {$payment->number}",
);
}
}
// AppServiceProvider::register()
$this->app->bind(ReissueChargeOptionsContract::class, ReissueOptions::class);

LiqPay accepts only a browser-submitted POST with signed fields. charge() caches that form (until payment_url_expires_at) and points payment_url at billing/checkout/{payment} (billing.checkout-form), a page that auto-submits it. Once the cache entry expires the page answers 404 — open the pay link to get a new one.

Paddle has no hosted web checkout: its payment link opens a page on your domain that loads Paddle.js. The package ships it as billing/paddle/checkout (billing.paddle.checkout), see Paddle.