Writing a custom payment gateway
To use a provider other than Stripe or the built-in offline gateway, write a class that implements Reach\StatamicResrv\Http\Payment\PaymentInterface and register it in the payment_gateways config array.
TIP
Surcharges and amount limits need no gateway code, because PaymentGatewayManager handles them from config. See Payment gateways.
Updating a gateway for v6.5
Resrv v6.5.0 changes PaymentInterface to support manual reservations, which customers pay from the payment page:
paymentIntent()gained a 4th parameter,?string $returnUrl = null. See The return URL.retrievePaymentIntent()is a new required method. See Retrieving a payment intent.verifyPayment()must also confirm reservations in the newawaiting_paymentstatus. See Verifying webhooks.
A gateway missing either of the first two fails to load with a PHP error ("Declaration of …::paymentIntent() must be compatible with …"). Resrv loads every configured gateway, so this breaks checkout, webhooks and the Control Panel reservation pages for all of them. Update your gateway before the Resrv upgrade or in the same deploy.
The two gateway types
A gateway is either inline or redirect:
- Inline gateways, like Stripe, render a payment form on the checkout page with a Blade view.
redirectsForPayment()returnsfalse. - Redirect gateways, like PayPal, send the customer to the provider's hosted page and back.
redirectsForPayment()returnstrue.
The PaymentInterface
interface PaymentInterface
{
public function name(): string; // NEW in v6
public function label(): string; // NEW in v6
public function paymentView(): string; // NEW in v6
public function paymentIntent($amount, Reservation $reservation, $data, ?string $returnUrl = null); // $returnUrl NEW in v6.5
public function retrievePaymentIntent(string $paymentId, Reservation $reservation): ?object; // NEW in v6.5
public function cancelPaymentIntent(string $paymentId, Reservation $reservation): void; // NEW in v6
public function refund(Reservation $reservation);
public function getPublicKey(Reservation $reservation);
public function getSecretKey(Reservation $reservation);
public function getWebhookSecret(Reservation $reservation);
public function supportsWebhooks(): bool;
public function supportsManualConfirmation(): bool; // NEW in v6
public function supportsAutomaticRefunds(): bool; // NEW in v6.2
public function redirectsForPayment(): bool;
public function handleRedirectBack(): array;
public function handlePaymentPending(): bool|array;
public function verifyPayment($request);
public function verifyWebhook();
}If you're porting a v5 gateway, start with the five methods added in v6.0:
| Method | Returns | Role |
|---|---|---|
name() | string | Lowercase slug, like paypal. Used as a fallback identifier. |
label() | string | Default picker label. The config label key overrides it. |
paymentView() | string | Blade view for the inline payment form. Redirect gateways return the default, which never renders. |
supportsManualConfirmation() | bool | true only for offline gateways. |
cancelPaymentIntent() | void | Voids an abandoned intent. See Cancelling abandoned intents. |
Later releases added these, which both v5 ports and older v6 gateways need:
| Change | Since | Role |
|---|---|---|
supportsAutomaticRefunds() | v6.2 | true when refund() returns money through the provider's API. See Refunds. |
retrievePaymentIntent() | v6.5 | Reads the current state of an intent. See Retrieving a payment intent. |
paymentIntent() 4th parameter, ?string $returnUrl = null | v6.5 | The base URL a redirect gateway sends the customer back to. See The return URL. |
Implement every method
Every method is required, and paymentIntent() must declare the 4th parameter even if you ignore it. A missing method is a fatal error as soon as Resrv loads the gateway. Methods your provider doesn't need can be no-ops, like cancelPaymentIntent() in an offline gateway or retrievePaymentIntent() returning null.
Identity methods
public function name(): string
{
return 'paypal'; // lowercase slug, no spaces
}
public function label(): string
{
return 'PayPal'; // default picker label; overridable via config 'label'
}
public function paymentView(): string
{
// Inline gateway: your custom view:
return 'your-package::livewire.paypal-payment';
// Redirect gateway: default fallback (never actually rendered):
// return 'statamic-resrv::livewire.checkout-payment';
}
public function supportsManualConfirmation(): bool
{
return false; // true ONLY for offline / non-provider-verified gateways
}Return true from supportsManualConfirmation() only when no provider verifies the payment, as with bank transfer. Checkout then shows a "Confirm Reservation" button that confirms the booking straight away. Gateways that confirm through a webhook or redirect callback return false.
The bundled Stripe gateway returns:
public function name(): string { return 'stripe'; }
public function label(): string { return 'Credit Card'; }
public function paymentView(): string { return 'statamic-resrv::livewire.checkout-payment'; }
public function supportsManualConfirmation(): bool { return false; }Creating the payment intent
paymentIntent() creates the session at the provider and returns an object Resrv uses for the rest of checkout or the payment page. $amount is a Price object that includes any surcharge. $amount->raw() gives it in minor units (cents) as a numeric string. Redirect gateways use $returnUrl (see The return URL).
Inline gateways
Return an object with ->id and ->client_secret:
// Inline gateways can ignore $returnUrl, but must still declare it.
public function paymentIntent($amount, Reservation $reservation, $data, ?string $returnUrl = null)
{
$session = YourProvider::createSession([
'amount' => $amount->raw(), // minor units (cents) as a numeric string
'currency' => Str::lower(config('resrv-config.currency_isoCode')),
'metadata' => ['reservation_id' => $reservation->id],
]);
$result = new \stdClass;
$result->id = $session->id; // stored as reservation.payment_id
$result->client_secret = $session->client_secret; // passed to the frontend JS SDK
return $result;
}Redirect gateways
Return an object with ->id and ->redirectTo:
public function paymentIntent($amount, Reservation $reservation, $data, ?string $returnUrl = null)
{
$session = YourProvider::createCheckout([
'amount' => $amount->raw(),
'currency' => Str::lower(config('resrv-config.currency_isoCode')),
'metadata' => ['reservation_id' => $reservation->id],
'return_url' => $this->buildReturnUrl($reservation, $returnUrl), // see "The return URL" below
]);
$result = new \stdClass;
$result->id = $session->id; // stored as reservation.payment_id
$result->redirectTo = $session->checkout_url; // customer is sent here
return $result;
}Always store reservation_id in the intent metadata. The stale-intent fallback in verifyPayment() needs it.
The return URL
Redirect gateways build the provider's return URL from $returnUrl:
- In checkout, it's the absolute URL of your checkout-complete entry (the page with the
{{ resrv_checkout_redirect }}tag). - On the payment page, it's the customer's payment link, which already carries
?ref=…&hash=…. - When it's
null, fall back to the checkout-complete entry.
Then append your own parameters, with & as the separator when the base already has a query string:
use Statamic\Facades\Entry;
protected function buildReturnUrl(Reservation $reservation, ?string $returnUrl = null): string
{
$base = $returnUrl ?? Entry::find(config('resrv-config.checkout_completed_entry'))->absoluteUrl();
// The payment page's URL already ends in ?ref=…&hash=…, so never hard-code '?'.
$separator = str_contains($base, '?') ? '&' : '?';
return $base.$separator.http_build_query([
'id' => $reservation->id,
'resrv_gateway' => 'paypal', // the config key you register the gateway under
]);
}INFO
In checkout, Resrv adds resrv_gateway=<config_key> to your redirectTo URL. On the payment page it adds it to $returnUrl instead. Append it yourself anyway, since a duplicate does no harm. The {{ resrv_checkout_redirect }} tag and the payment page use it to identify your gateway.
The inline payment view
The view from paymentView() renders inside the CheckoutPayment Livewire component at checkout and the reservation-payment component on the payment page. Both pass these to it:
| Property | Type | Description |
|---|---|---|
$wire.clientSecret | string | From paymentIntent()->client_secret |
$wire.publicKey | string | From getPublicKey() |
$wire.checkoutCompletedUrl | string | Where to send the customer after paying (the payment link on the payment page) |
$amount | float | Formatted payment amount, as a Blade variable |
Load your provider's JavaScript in your payment page layout too.
After payment, send the customer to checkout_completed_url with redirect_status appended, using & when the URL already has a query string (the payment link always does). The payment page shows Payment processing only for redirect_status=succeeded or redirect_status=processing (or Stripe's payment_intent / payment_intent_client_secret parameters). redirect_status=failed shows the Pay button again. Without them, a customer who just paid sees Complete your payment until the webhook confirms the booking.
A minimal view at resources/views/livewire/paypal-payment.blade.php:
<div>
<div x-data="paypalPayment">
<div class="my-6 xl:my-8">
<div class="text-lg xl:text-xl font-medium mb-2">
{{ trans('statamic-resrv::frontend.payment') }}
</div>
</div>
<div id="paypal-button-container" x-ref="paypalButtons"></div>
<p x-show="errors" x-cloak x-transition class="mt-6 text-red-600">
<span x-html="errors"></span>
</p>
</div>
</div>
@script
<script>
Alpine.data('paypalPayment', () => ({
client_id: $wire.publicKey,
checkout_completed_url: $wire.checkoutCompletedUrl,
errors: false,
init() {
// Load the provider's JS SDK and render the payment UI.
// On approval, redirect to this.checkout_completed_url with redirect_status appended:
// const sep = this.checkout_completed_url.includes('?') ? '&' : '?';
// window.location.href = this.checkout_completed_url + sep + 'redirect_status=succeeded';
},
}));
</script>
@endscriptMake the view publishable and return its namespaced path from paymentView(), like 'your-package::livewire.paypal-payment', so a published copy overrides it.
Handling the redirect back
When a customer returns from a redirect gateway, the {{ resrv_checkout_redirect }} tag finds your gateway from the resrv_gateway query parameter and calls handleRedirectBack(). It must return an array with a status of true, false or 'pending':
public function handleRedirectBack(): array
{
// Always check for pending payments first
if ($pending = $this->handlePaymentPending()) {
return $pending;
}
$sessionId = request()->input('session_id');
$reservation = Reservation::findByPaymentId($sessionId)->first();
// No match: payment_id was cleared because the customer abandoned, switched
// gateway, etc. Short-circuit to the failure path, since falling back to the session
// reservation would show success for a booking no webhook will confirm.
if (! $reservation) {
return ['status' => false, 'reservation' => []];
}
$session = YourProvider::getSession($sessionId);
return [
'status' => $session->status === 'paid',
'reservation' => $reservation->toArray(),
];
}handlePaymentPending() returns a pending state when ?payment_pending=<id> is present, for async methods like bank transfer, and false otherwise. Return Resrv's PendingPayment helper, available from v6.5.1:
use Reach\StatamicResrv\Http\Payment\PendingPayment;
public function handlePaymentPending(): bool|array
{
return PendingPayment::fromRequest();
}The helper includes the reservation data only when the id belongs to the visitor's own checkout session. For any other id the customer still sees the pending message, with an empty reservation.
Don't look the reservation up yourself
Earlier versions of this page used Reservation::find(request()->input('payment_pending')) here. The id comes from the URL and reservation ids are sequential, so that lookup returned any booking's data to anyone who asked for it. If your gateway has that code, update Resrv to v6.5.1 and replace the method body with the one above. From v6.5.1 the {{ resrv_checkout_redirect }} tag also removes the data for an id the session doesn't own, but update your gateway anyway.
The same goes for handleRedirectBack(): find the reservation from a value your provider issued, like the payment id in findByPaymentId(), never from a reservation id in the query string.
The checkout-completed page shows a success, failure or pending message based on status.
The payment page also calls handleRedirectBack(), but only to pick a message: true or 'pending' shows "Payment processing" and false lets the customer try again. The webhook still confirms the booking, so handleRedirectBack() needs no changes for the payment page.
Verifying webhooks
Gateways that confirm through a webhook return true from supportsWebhooks() and implement verifyPayment(). Register the webhook at your gateway's own route, where the last segment matches the config key:
POST https://yoursite.com/resrv/api/webhook/paypal
POST https://yoursite.com/resrv/api/webhook/stripeThe bare /resrv/api/webhook route still goes to the default gateway on single-gateway sites, but returns a 404 when you configure more than one. See Payment gateways.
verifyPayment() should:
- Verify the signature against the webhook secret and reject the request if it doesn't match.
- Find the reservation by
payment_id, with a metadata fallback for stale intents. - On success, call
transitionTo(CONFIRMED, tolerant: true)and dispatchReservationConfirmedonly if it returnstrue. Don't check forpendingfirst. The transition confirms bothpendingcheckouts andawaiting_paymentmanual reservations, and returnsfalsefor anything else. - Leave a failed attempt alone. A declined card can be retried, so don't cancel or release the hold.
- Return a 200 response so the provider stops retrying.
public function verifyPayment($request)
{
$payload = json_decode($request->getContent(), true);
$data = $payload['data']['object'];
// Verify the webhook signature against getWebhookSecret($reservation) here.
$reservation = Reservation::findByPaymentId($data['id'])->first();
// Stale-intent fallback: payment_id was cleared by Resrv before the provider's
// cancellation took effect. Recover the reservation from intent metadata so the
// charge isn't lost.
$isStaleIntent = false;
if (! $reservation && isset($data['metadata']['reservation_id'])) {
$reservation = Reservation::find($data['metadata']['reservation_id']);
$isStaleIntent = (bool) $reservation;
}
if (! $reservation) {
return response()->json([], 200);
}
if ($eventType === 'succeeded') {
if ($isStaleIntent) {
// The customer walked away (refresh, back, gateway switch, coupon change)
// before this webhook arrived. Confirming now would email and decrease
// inventory against an amount/gateway the reservation no longer matches.
Log::warning('Payment intent succeeded after being abandoned; manual reconciliation may be required.', [
'reservation_id' => $reservation->id,
'payment_intent_id' => $data['id'],
]);
return response()->json([], 200);
}
// Confirms PENDING and AWAITING_PAYMENT rows alike: no pending-only check in front of it.
if ($reservation->transitionTo(ReservationStatus::CONFIRMED, tolerant: true)) {
ReservationConfirmed::dispatch($reservation, ReservationConfirmed::VIA_WEBHOOK, [
'gateway' => $reservation->payment_gateway,
'payment_id' => $data['id'],
]);
return response()->json([], 200);
}
// The transition was skipped because the row changed under the lock: see
// the orphan-payment race below.
$reservation->refresh();
\Reach\StatamicResrv\Mail\OrphanedPaymentNotification::notifyIfOrphaned($reservation, $data['id']);
}
// A failed ATTEMPT is retryable: do nothing, leaving the reservation PENDING
// (or AWAITING_PAYMENT, so the customer can pay again from the same link).
if ($eventType === 'canceled' && ! $isStaleIntent) {
// A genuinely-canceled intent is dead. expire() no-ops if not PENDING,
// so an AWAITING_PAYMENT reservation stays open.
$reservation->expire();
}
return response()->json([], 200);
}Pass ReservationConfirmed::VIA_WEBHOOK, the gateway and the payment id, as above, so the activity log shows "Confirmed by payment webhook".
A failed or cancelled intent must never end an Awaiting payment reservation, since the customer can still pay from the same link. expire() only touches pending reservations, so don't call any other transition for these events.
Log and ignore stale successes
Logging a stale success instead of confirming it means Resrv never confirms a reservation against an amount or gateway the customer abandoned. You then reconcile the orphan charge at the provider by hand. The Stripe gateway does this, and we suggest the same for any provider that confirms asynchronously.
The orphan-payment race
A live intent can still lose a race. ExpireReservations runs on every availability search and can move a PENDING reservation to EXPIRED between your webhook's checks and its confirmation. From v6.5.0, the lapsed-hold sweep (resrv:cancel-lapsed-holds) can also cancel an awaiting_payment manual reservation while its payment is going through.
Reservation::transitionTo() re-reads the row under a lockForUpdate. With tolerant: true it returns false instead of throwing when the row has moved to an incompatible state. On false, refresh the reservation and report the orphaned charge:
$reservation->refresh();
// Logs and notifies admins when the row is EXPIRED, REFUNDED, CANCELLED or PARTNER
// (a cancelled booking's own retained charge excepted). Dedupes on (reservation, intent)
// so provider retries don't spam admins.
\Reach\StatamicResrv\Mail\OrphanedPaymentNotification::notifyIfOrphaned($reservation, $data['id']);
return response()->json([], 200);If your gateway checks the terminal statuses by hand and calls dispatchFor(), switch to notifyIfOrphaned(). A hand-written list of EXPIRED, REFUNDED and PARTNER misses a late payment on a cancelled hold.
Return 200 either way and reconcile from the logged orphan. The bundled StripePaymentGateway does the same.
Manual-confirmation gateways
Offline gateways (supportsManualConfirmation() returns true) confirm through Resrv's CheckoutPayment::confirmPayment(), which already does this re-read and shows the customer an "expired" error. You don't need to add anything.
verifyWebhook() is a health check for the GET webhook route. Built-in gateways return true.
Cancelling abandoned intents
Resrv calls cancelPaymentIntent() to void an intent at the provider when the customer:
- Navigates back from step 3 to step 1 or 2.
- Switches gateway at the picker on step 3.
- Re-enters step 2 with a stale intent from an earlier pass.
- Reopens a PENDING checkout whose time ran out.
For manual reservations, Resrv also calls it when:
- An admin or the lapsed-hold sweep cancels an awaiting-payment reservation.
- An admin marks a reservation as paid while the customer's online payment is still open.
- The payment page has to replace an intent it can't resume.
Resrv then checks the void with retrievePaymentIntent().
public function cancelPaymentIntent(string $paymentId, Reservation $reservation): void
{
try {
// Best-effort void/cancel at the provider:
// Stripe: PaymentIntent::retrieve($paymentId)->cancel() (only in a cancellable status)
// PayPal: POST /v2/checkout/orders/{id}/void
// Adyen: POST /cancels with originalReference=$paymentId
YourProvider::cancelOrder($paymentId);
} catch (\Throwable $e) {
Log::warning('Failed to cancel provider intent: '.$e->getMessage(), [
'payment_id' => $paymentId,
'reservation_id' => $reservation->id,
]);
}
}- Only cancel intents that can be cancelled. Cancelling a
succeededor alreadycanceledintent usually throws. - Log provider errors and don't re-throw them (Resrv would log and swallow them anyway). In checkout, Resrv has already cleared
reservation.payment_id, so a failed cancellation only leaves an orphan at the provider. For manual reservations, Resrv keeps the reference untilretrievePaymentIntent()shows the intent is dead. - Don't try to rescue a succeeded intent here. The stale-intent fallback in
verifyPayment()handles that.
Offline and test gateways have nothing to clean up, so OfflinePaymentGateway and FakePaymentGateway make it a no-op:
public function cancelPaymentIntent(string $paymentId, Reservation $reservation): void
{
//
}Retrieving a payment intent NEW
retrievePaymentIntent(), required from v6.5.0, reads the current state of an intent your gateway created. Checkout never calls it. Manual reservations use it to:
- Resume an interrupted payment on the payment page instead of creating a second intent.
- Check a void after
cancelPaymentIntent(). Resrv drops the reference only when the intent readscanceledornull. - Decide whether to void or keep the customer's intent when an admin marks the reservation as paid. An intent that took money is kept.
- Refund a reservation marked as paid only when its intent reads
succeededorprocessing.
public function retrievePaymentIntent(string $paymentId, Reservation $reservation): ?objectReturn an object with a ->status property, using Stripe's status names. Map your provider's statuses to these:
succeeded,processingorrequires_capture: the money is captured, settling or held. Resrv keeps the reference and won't create another intent. Marking the reservation as paid voids an uncapturedrequires_capturehold instead.canceled: the intent is dead, and Resrv may replace or forget it.- Anything else, such as
requires_payment_methodorrequires_action: still payable, so Resrv resumes it.
While the intent is payable, include ->redirectTo (redirect gateways) or ->client_secret (inline gateways) so Resrv can resume the same payment. Otherwise Resrv voids it and creates a new one through paymentIntent() on every resume.
Return null only when the intent definitely doesn't exist, such as an HTTP 404 or Stripe's resource_missing. Resrv treats null as gone.
Throw on transient failures: timeouts, rate limits (429), 5xx responses, authentication and connection errors. Never catch everything and return null, or Resrv may create a second chargeable intent, lose a charge that captures later, or skip a real refund.
After cancelPaymentIntent() voids an intent, report it as canceled. Resrv checks for canceled or null before it creates a replacement. If the old intent still looks payable, the payment attempt stops with a retryable error.
The bundled Stripe gateway returns the Stripe intent itself, which already has ->status and ->client_secret:
public function retrievePaymentIntent(string $paymentId, Reservation $reservation): ?object
{
try {
return $this->getClient($reservation)->paymentIntents->retrieve($paymentId);
} catch (InvalidRequestException $e) {
// Only a definitely-gone intent may be replaced with a fresh one. Every transient
// failure must propagate so a brownout on this read can't orphan a live intent.
if ($e->getError()?->code === 'resource_missing' || $e->getHttpStatus() === 404) {
return null;
}
throw $e;
}
}Offline gateways have no provider intent and return null. That's safe, because they return true from supportsManualConfirmation() and Resrv never relies on a retrieved status for them:
public function retrievePaymentIntent(string $paymentId, Reservation $reservation): ?object
{
// Offline payments have no remote intent to resume.
return null;
}Refunds
Resrv calls refund() when an admin refunds a reservation in the Control Panel, using the gateway stored in the reservation's payment_gateway column. Throw RefundFailedException on failure and the Control Panel shows an error.
public function refund($reservation)
{
try {
$result = YourProvider::refund($reservation->payment_id);
} catch (\Exception $e) {
throw new \Reach\StatamicResrv\Exceptions\RefundFailedException($e->getMessage());
}
return $result;
}refund() also runs when a customer cancels their own reservation from the reservation status page, but only if your gateway returns money automatically:
public function supportsAutomaticRefunds(): bool
{
return true; // false only when refund() moves no money (bank transfer, pay at premises)
}With false, customers can't cancel those bookings themselves. An admin returns the money and then refunds the reservation in the Control Panel.
Resrv skips refund() for a reservation an admin marked as paid, since the admin returns that money by hand. If it kept an online intent reference, Resrv calls refund() only when retrievePaymentIntent() reads succeeded or processing.
Provider keys
These three methods return the public, secret and webhook signing keys. Each receives the $reservation, so you can return different keys per collection, as the Stripe gateway does.
public function getPublicKey($reservation)
{
return config('resrv-config.paypal_client_id');
}
public function getSecretKey($reservation)
{
return config('resrv-config.paypal_secret');
}
public function getWebhookSecret($reservation)
{
return config('resrv-config.paypal_webhook_id');
}Registering the gateway
Add your gateway to config/resrv-config.php under payment_gateways:
'payment_gateways' => [
'stripe' => [
'class' => \Reach\StatamicResrv\Http\Payment\StripePaymentGateway::class,
'label' => 'Credit Card',
],
'paypal' => [
'class' => \App\Payment\PayPalPaymentGateway::class,
'label' => 'PayPal',
],
],- The config key (
'paypal') is stored in the reservation'spayment_gatewaycolumn and used for refunds, webhooks and redirect callbacks. Don't change it once reservations use it. labelis optional and defaults to the gateway'slabel().- The first entry is the default gateway, used for old reservations with a null
payment_gateway. - The old singular
payment_gatewaykey still works as a fallback whenpayment_gatewaysis empty.
Complete redirect-gateway skeleton
A starting point for a redirect gateway:
<?php
namespace App\Payment;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Reach\StatamicResrv\Enums\ReservationStatus;
use Reach\StatamicResrv\Events\ReservationConfirmed;
use Reach\StatamicResrv\Exceptions\RefundFailedException;
use Reach\StatamicResrv\Http\Payment\PaymentInterface;
use Reach\StatamicResrv\Http\Payment\PendingPayment;
use Reach\StatamicResrv\Models\Reservation;
use Statamic\Facades\Entry;
class PayPalPaymentGateway implements PaymentInterface
{
// --- Identity ---
public function name(): string
{
return 'paypal';
}
public function label(): string
{
return 'PayPal';
}
public function paymentView(): string
{
// Redirect gateway: never rendered, return the default as a fallback.
return 'statamic-resrv::livewire.checkout-payment';
}
public function supportsManualConfirmation(): bool
{
return false;
}
// --- Payment flow ---
public function paymentIntent($amount, Reservation $reservation, $data, ?string $returnUrl = null)
{
// TODO: create a checkout session with the provider's API.
// $amount->raw() = minor units (cents) as a numeric string.
// Give the provider $this->buildReturnUrl($reservation, $returnUrl) as its return URL.
$result = new \stdClass;
$result->id = 'PROVIDER_SESSION_ID'; // stored as reservation.payment_id
$result->redirectTo = 'https://provider.com/checkout/...'; // customer is sent here
return $result;
}
public function retrievePaymentIntent(string $paymentId, Reservation $reservation): ?object
{
// TODO: fetch the order from the provider's API. Return null ONLY when the provider
// says it doesn't exist (e.g. 404); let timeouts, 429s, 5xx and auth errors throw.
$result = new \stdClass;
$result->id = $paymentId;
$result->status = 'requires_payment_method'; // map the provider's status to Stripe's vocabulary
$result->redirectTo = 'https://provider.com/checkout/...'; // while the order is still payable
return $result;
}
protected function buildReturnUrl(Reservation $reservation, ?string $returnUrl = null): string
{
// $returnUrl is the payment page's link (already carrying ?ref=…&hash=…) or the
// checkout-complete entry; fall back to the latter when it's null.
$base = $returnUrl ?? Entry::find(config('resrv-config.checkout_completed_entry'))->absoluteUrl();
$separator = str_contains($base, '?') ? '&' : '?';
return $base.$separator.http_build_query([
'id' => $reservation->id,
'resrv_gateway' => 'paypal', // the config key you register the gateway under
]);
}
public function redirectsForPayment(): bool
{
return true;
}
public function handleRedirectBack(): array
{
if ($pending = $this->handlePaymentPending()) {
return $pending;
}
$token = request()->input('token');
$reservation = Reservation::findByPaymentId($token)->first();
if (! $reservation) {
return ['status' => false, 'reservation' => []];
}
// TODO: verify payment with the provider's API using $token.
$paid = true;
return [
'status' => $paid,
'reservation' => $reservation->toArray(),
];
}
public function handlePaymentPending(): bool|array
{
// Never look the id up yourself: it comes from the URL.
return PendingPayment::fromRequest();
}
// --- Cancellation ---
public function cancelPaymentIntent(string $paymentId, Reservation $reservation): void
{
try {
// TODO: void/cancel the order at the provider (best-effort).
} catch (\Throwable $e) {
Log::warning('Failed to cancel provider intent: '.$e->getMessage(), [
'payment_id' => $paymentId,
'reservation_id' => $reservation->id,
]);
}
}
// --- Webhooks ---
public function supportsWebhooks(): bool
{
return true;
}
public function verifyWebhook()
{
return true;
}
public function verifyPayment($request)
{
// TODO: verify signature, find the reservation (with metadata fallback),
// and route success through transitionTo(CONFIRMED, tolerant: true), which
// confirms both PENDING and AWAITING_PAYMENT reservations. Dispatch
// ReservationConfirmed with ReservationConfirmed::VIA_WEBHOOK.
return response()->json([], 200);
}
// --- Refund ---
public function supportsAutomaticRefunds(): bool
{
return true; // refund() returns money through the provider's API
}
public function refund($reservation)
{
try {
// TODO: call the provider's refund API using $reservation->payment_id.
} catch (\Exception $e) {
throw new RefundFailedException($e->getMessage());
}
return true;
}
// --- Keys ---
public function getPublicKey($reservation)
{
return config('resrv-config.paypal_client_id');
}
public function getSecretKey($reservation)
{
return config('resrv-config.paypal_secret');
}
public function getWebhookSecret($reservation)
{
return config('resrv-config.paypal_webhook_id');
}
}For an inline gateway, change these:
redirectsForPayment()returnsfalse.paymentIntent()andretrievePaymentIntent()return->client_secretinstead of->redirectTo.paymentIntent()can ignore$returnUrl, so you don't needbuildReturnUrl().paymentView()returns your Blade view (see The inline payment view).
Checklist
Before deploying a custom gateway:
- Every method is implemented, including
paymentIntent()'s 4th parameter,?string $returnUrl = null. name()is a unique lowercase slug, andsupportsManualConfirmation()istrueonly for offline gateways.paymentIntent()returns->idwith->client_secret(inline) or->redirectTo(redirect), and storesreservation_idin the provider metadata.- Redirect gateways build the return URL from
$returnUrl, fall back to the page with{{ resrv_checkout_redirect }}when it'snull, and use&when there's already a query string. retrievePaymentIntent()maps statuses to Stripe's names, returnsnullonly for a missing intent, throws on transient failures, and reportscanceledafter a void.handleRedirectBack()checkshandlePaymentPending()first, andhandlePaymentPending()returnsPendingPayment::fromRequest().verifyPayment()verifies the signature, confirms throughtransitionTo(CONFIRMED, tolerant: true)with nopending-only check, and dispatchesReservationConfirmedwithReservationConfirmed::VIA_WEBHOOKonly when that succeeds.verifyPayment()handles stale intents and orphans (withnotifyIfOrphaned()), leaves failed attempts alone, and never ends anawaiting_paymentreservation on a failed or cancelled intent.supportsWebhooks()istrueif you confirm through webhooks. Online gateways that returnfalsecan't be used for manual reservations.cancelPaymentIntent()voids the intent at the provider, or is a no-op for offline gateways.supportsAutomaticRefunds()istrueonly whenrefund()moves money, andrefund()throwsRefundFailedExceptionon failure.- The gateway is registered under
payment_gatewaysinconfig/resrv-config.php, and the provider's webhook URL ends in its config key. - Inline gateways have a working, publishable Blade view.
Related
- Payment gateways: the gateway picker, surcharges, amount limits and webhook routes.
- Stripe: the bundled inline gateway, including per-collection accounts.
- Mollie: maintained as a separate add-on.
- Manual reservations: bookings created in the Control Panel and paid through your gateway.
- Payment page: where customers pay for manual reservations.