gulf/payments

Configuration-driven, multi-gateway payment orchestration for Laravel. Drivers expose primitives; flows compose them.

Maintainers

Package info

github.com/ziadadel001/laravel_Gulf_payments

pkg:composer/gulf/payments

Transparency log

Statistics

Installs: 40

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.11.3 2026-07-14 00:01 UTC

README

Configuration-driven, multi-gateway payment orchestration for Laravel.

gulf/payments is a gateway-agnostic engine that orchestrates payments rather than wrapping a single provider. Gateway drivers expose low-level primitives (createIntent, createCheckoutSession, authorize, capture, void, refund, retrieve, parseWebhook); named flows compose those primitives into the business behaviours you actually ship — pay now, hold now / pay later, deposit now / balance later, hosted checkout, recurring billing. Adding a gateway is one driver class plus one config block.

It ships as a complete platform, not just a Stripe wrapper: a fluent builder API, five reference flows, an opt-in universal data model (12 modules — vault, customers, refunds, disputes, line items, ledger, audit, payouts, fees, subscriptions, connected accounts, transfers), business-vertical config presets, model traits for $order->pay()->charge(...) ergonomics, a frontend confirm kit, a full diagnostics/scaffolding CLI, and a first-class testing fake. A comprehensive test suite backs the whole surface.

Lightweight by design: install only the gateways you need. The Stripe SDK is a suggest dependency, pulled in on demand by php artisan payments:install. A project that never selects Stripe never drags stripe/stripe-php into its vendor/. Every optional schema module and business preset is opt-in too — with everything left at its default the package runs on 2 tables and makes zero extra queries. Stripe is gateway #1, Paymob (Egypt / MENA) is #2, MyFatoorah (Kuwait / GCC / MENA) is #3, Tabby (BNPL — KSA / UAE / Kuwait) is #4, Tamara (BNPL — KSA / UAE / Bahrain / Kuwait / Oman) is #5, Tap (KNET / mada / GCC cards / Apple Pay) is #6, PayTabs (mada / cards — KSA / UAE / Egypt / GCC) is #7, Amazon Payment Services (APS / ex-PayFort — cards / mada, Egypt / UAE / KSA / GCC) is #8, Fawry / FawryPay (Egypt — cards / wallets / PayAtFawry cash) is #9, and PayPal (global — Orders v2 hosted approve-redirect) is #10 — the proof the config-driven architecture generalizes (see Gateways).

Zero-code Stripe in 5 minutes

A working Stripe integration with no application code — only config, .env keys, and facade calls:

composer require gulf/payments
php artisan payments:install --gateway=stripe   # config + core migrations + webhook route

Set the three keys in .env (see Stripe credentials):

STRIPE_SECRET=sk_...
STRIPE_KEY=pk_...
STRIPE_WEBHOOK_SECRET=whsec_...
php artisan migrate

That's it. With only those three keys set, server-side pay_now and the webhook confirmation route work out of the box — no preset, no modules, no custom PHP. Take your first payment:

use Gulf\Payments\Facades\Payments;
use Gulf\Payments\Data\Money;

Payments::flow('pay_now')->charge(Money::of('12.50', 'USD'))->execute();

Point your Stripe webhook at POST /payments/webhook/stripe and copy its signing secret into STRIPE_WEBHOOK_SECRET. Everything else — every flow, option, preset, and schema module — is reachable the same way: config + facade, no app code. Full detail lives in Installation and Quick start.

Browser checkout prerequisites (optional)

The server-side path above needs nothing more. To drive a browser checkout (SCA/3DS confirm or a status-poll UI) fully code-free, publish and secure three opt-in pieces first:

  1. Enable and gate the status endpoint. It is disabled by default and refuses to register without an auth guard; for any multi-user app add an ownership gate too (auth alone lets any signed-in user read another payment's client_secret by uuid):

    // config/payments.php
    'http' => ['status_endpoint' => [
        'enabled' => true,
        'middleware' => ['web', 'auth', 'can:view,payment'],
    ]],

    Details: Status endpoint & frontend confirm kit.

  2. Publish the confirm kit + the Stripe.js adapter:

    php artisan vendor:publish --tag=payments-frontend

    This ships confirm.js, the <x-payments::confirm> component, and the opt-in stripe-confirm.js adapter — which binds Stripe's confirmCardPayment / confirmCardSetup to the events the gateway-agnostic kit emits (set data-stripe-key to your pk_...).

  3. Publish the checkout controller + route. The one browser touchpoint that is app code by design is creating a payment — the charge amount must come from a server-authoritative record, never the browser. Publish the opt-in stubs with:

    php artisan payments:install --gateway=stripe --stubs

    This writes app/Http/Controllers/Payments/CheckoutController.php and appends its route to routes/web.php (both skipped if they already exist, so a re-run never clobbers your edits). Bind the controller to your own order/invoice model; the stub carries a loud money-safety note and a ready Payments::flow(...)->charge(...)->execute() call returning the confirm-kit JSON. (It also publishes the opt-in Stripe webhook controller/route — keep them only if you want to own that endpoint; the package already registers POST /payments/webhook/stripe for you.)

pay_link (hosted checkout) success_url / cancel_url point at your own app pages — the package registers no landing routes (money-truth still flows via the webhook / return-URL ingress).

Table of contents

New here? Zero-code Stripe in 5 minutes is the fastest path.

  1. Requirements
  2. Installation
  3. Quick start — your first payment is Stripe
  4. The five flows
  5. The fluent builder reference
  6. Manager operations
  7. Business presets
  8. The opt-in universal schema
  9. Model traits & scopes
  10. Subscriptions / recurring billing
  11. Configuration reference
  12. Webhooks & return-URL ingress
  13. Status endpoint & frontend confirm kit
  14. Scheduling
  15. Events
  16. Testing
  17. Command reference
  18. Extending — adding a gateway
  19. Architecture
  20. Deferred / not in v2

Deeper guides live under docs/ — start at the documentation index (docs/README.md), which links every page below. There is one page per flow (docs/flows/), plus docs/configuration.md, docs/webhooks.md, docs/status-endpoint.md, docs/testing.md, gateway fit matrices under docs/gateways/, integration recipes under docs/recipes/ (config splitting, covering fees, env/tenancy conventions, installments, marketplace destination charges), and docs/known-limitations.md (operational caveats + the v2 roadmap).

Requirements

  • PHP ^8.3
  • Laravel ^11.0 || ^12.0 || ^13.0
  • brick/money (hard dependency — ISO-4217 currency precision, incl. 3-decimal KWD/BHD/OMR and zero-decimal JPY)
  • stripe/stripe-php ^16.0 — only when you use the Stripe gateway (installed on selection, never forced)

Installation

composer require gulf/payments

Then run the guided installer:

php artisan payments:install

The installer:

  1. Publishes config/payments.php and the core migrations (2 tables: payments, payment_webhook_events) — idempotent, safe to re-run.

  2. Shows a multiselect of every registered gateway. Select Stripe.

  3. Appends the missing STRIPE_* keys to your .env and .env.example inside a marked block.

  4. If stripe/stripe-php is not installed, prints the exact line to run — the package never shells out to composer itself:

    composer require stripe/stripe-php:^16.0
  5. Interactively offers the opt-in schema modules as a second multiselect (see below for non-interactive flags).

Non-interactive / CI usage (no TTY) requires an explicit gateway selection:

php artisan payments:install --gateway=stripe        # one or more --gateway=<key>
php artisan payments:install --all                   # every registered gateway
php artisan payments:install --gateway=stripe --dry-run   # preview, writes nothing
php artisan payments:install --with=refunds --with=vault  # modules only, no gateway required
php artisan payments:install --full                  # every optional schema module
php artisan payments:install --preset=bookings        # print a business-preset fragment (guidance only)

Finally run the migrations to create the ledger:

php artisan migrate

Stripe credentials

The installer writes these keys; fill them from your Stripe Dashboard:

Env key Where to get it
STRIPE_SECRET Dashboard → Developers → API keys → Secret key (sk_...)
STRIPE_KEY Dashboard → Developers → API keys → Publishable key (pk_...)
STRIPE_WEBHOOK_SECRET Dashboard → Developers → Webhooks → your endpoint → Signing secret (whsec_...)
STRIPE_CONNECT_WEBHOOK_SECRET (optional, Connect only) The signing secret of a second, Connect webhook endpoint pointing at POST {prefix}/stripe/connect. Only needed for the marketplace /connect event stream — the route stays unregistered until this is set. See Connected accounts.

Optional: STRIPE_API_VERSION (defaults to the driver's pinned version) and STRIPE_TIMEOUT (seconds, default 30).

Quick start — your first payment is Stripe

The public entrypoint is the Payments facade (a singular Payment alias also works). Payments::flow($name) returns a fluent builder; a terminal amount verb (charge / hold / deposit) plus ->execute() runs the flow and returns a PendingPayment.

use Gulf\Payments\Facades\Payments;
use Gulf\Payments\Data\Money;

$pending = Payments::flow('pay_now')
    ->charge(Money::of('12.50', 'USD'))
    ->customer($user->stripe_customer_id)   // optional
    ->paymentMethod($request->input('pm'))  // a Stripe PaymentMethod id (pm_...)
    ->metadata(['order_id' => $order->id])
    ->for($order)                           // optional morph subject on the ledger row
    ->execute();

// 3-D Secure / next action needed? Hand the gateway-agnostic action to the client.
if ($pending->requiresAction()) {
    return response()->json([
        'client_secret' => $pending->clientSecret(),   // Stripe SCA confirm
        'redirect_url'  => $pending->redirectUrl(),     // hosted-redirect gateways
        'action'        => $pending->action(),           // the RedirectAction VO
    ]);
}

return response()->json([
    'status'    => $pending->status()->value,   // e.g. "succeeded"
    'reference' => $pending->gatewayReference(), // e.g. "pi_..."
    'payment'   => $pending->payment()->uuid(),  // the ledger row's uuid
]);

Money::of('12.50', 'USD') takes a human/major amount; precision beyond the currency's exponent throws rather than silently rounding. Use Money::ofMinor(1250, 'USD') for integer minor units, and note 3-decimal currencies — Money::of('12.500', 'KWD') is 12500 minor units.

The five flows

Each flow is a strategy class selected by a config key (config/payments.php under flows). Five flows ship as sensible defaults; rename, duplicate, or point new config entries at any strategy freely. Deep guides for the first three: docs/flows/.

pay_now — charge now

Strategy: payment_intent. Create a PaymentIntent and confirm it. The params.confirmation knob picks the sub-mode:

  • automatic (shipped default) — create and confirm in one call. When the card settles inline you get a synchronous Succeeded; otherwise the gateway hands back a RequiresAction/Processing state to finish client-side.
  • manual / webhook / poll — create the intent and return the client_secret; the terminal state arrives later via a webhook or the reconcile poll.
$pending = Payments::flow('pay_now')
    ->charge(Money::of('49.00', 'USD'))
    ->paymentMethod($pmId)
    ->execute();

Lifecycle: Created → (RequiresAction → Processing →) Succeeded (or Failed). Full guide: docs/flows/pay-now.md.

book_pay_later — hold now, capture or auto-void later

Strategy: authorize_capture. Authorize a hold (PaymentIntent capture_method: manual): the card is authorized and funds are held, but the customer is not charged until you capture. "Book for free" = an authorization hold.

use Gulf\Payments\Models\Payment;

$pending = Payments::flow('book_pay_later')
    ->hold(Money::of('100.00', 'USD'))
    ->expiresIn(hours: 24)                          // auto-void deadline
    ->for($booking)
    ->onCapture(fn (Payment $p) => /* fulfil */)     // process-local sugar
    ->onExpiry(fn (Payment $p) => /* release */)     // process-local sugar
    ->execute();
  • Capture within the window → Payments::capture($uuid) (optionally a partial Money) → Succeeded, fires PaymentCaptured (and the onCapture sugar).
  • Auto-void: the scheduled ExpireHeldPayments job voids any Authorized/RequiresCapture row past expires_atCanceled, fires PaymentCanceled.

Full guide: docs/flows/book-pay-later.md.

deposit_balance — deposit now, charge the balance later

Strategy: deposit_balance. Charge a deposit now (with setup_future_usage: off_session so the payment method is saved), then settle the remaining balance later off-session using the saved PM. Modelled as two charges on one ledger row, not partial capture (Stripe partial capture voids the remainder).

use Gulf\Payments\Models\Payment;

$pending = Payments::flow('deposit_balance')
    ->deposit(Money::of('30.00', 'USD'))            // charged now
    ->balance(Money::of('70.00', 'USD'))            // charged later
    ->customer($user->stripe_customer_id)           // required for the off-session charge
    ->paymentMethod($pmId)
    ->settleBalanceAfter(now()->addDays(7))         // optional auto-settle time
    ->onBalanceCharged(fn (Payment $p) => /* … */)
    ->execute();
  • Settle on demand → Payments::settleBalance($uuid) → a second off-session PaymentIntent on the saved PM; on success fires PaymentBalanceCharged.
  • Auto-settle: the scheduled SettleDueBalances job settles any row past balance_due_at.
  • A hard off-session decline records a linked Failed child row (fires PaymentFailed) and rethrows — the deposit itself stands and the balance is left intact for retry, up to config('payments.settlement.max_attempts') (see Subscriptions / recurring billing).

Full guide: docs/flows/deposit-balance.md.

hosted_checkout — redirect to a gateway-hosted page

Strategy: hosted_checkout (shipped under the config key pay_link — rename freely). Sends the customer to a gateway-hosted payment page (Stripe Checkout / pay-by-link) instead of confirming client-side; ideal for e-commerce checkout, donations, and invoicing pay-links.

$pending = Payments::flow('pay_link')
    ->charge(Money::of('25.00', 'USD'))
    ->returnUrl('/pay/success')
    ->cancelUrl('/pay/cancel')
    ->execute();

return redirect($pending->redirectUrl());

Lifecycle (zero new PaymentStatus cases, zero migrations): Created ─initiate()→ Redirectcheckout.session.completed moves it to Processing (money not yet moved) → payment_intent.succeeded moves it to Succeeded; checkout.session.expired moves it to Expired. params.mode accepts payment | setup | subscription.

subscription — recurring / off-session renewals

Strategy: recurring (shipped under the config key subscription). The first charge saves the card off-session (mirroring deposit_balance's precedent); call ->recurEvery() with an ISO-8601 period to schedule renewals once the opt-in subscriptions module is installed (see Subscriptions / recurring billing below).

$pending = Payments::flow('subscription')
    ->charge(Money::of('19.00', 'USD'))
    ->customer($user->stripe_customer_id)
    ->paymentMethod($pmId)
    ->recurEvery('P1M')   // monthly — needs the 'subscriptions' schema module
    ->execute();

The fluent builder reference

Payments::flow($name) returns a Gulf\Payments\PendingPaymentBuilder. Every setter returns $this. Exactly one terminal amount verb is required before ->execute().

Method Purpose
charge(Money $amount) Terminal. Pay-now amount (create + confirm).
hold(Money $amount) Terminal. Authorize/hold amount — funds held, not charged until capture.
deposit(Money $amount) Terminal. Deposit charged now (deposit_balance).
balance(Money $amount) The deferred remainder charged later (deposit_balance).
expiresIn(int $hours = 0, int $minutes = 0, int $days = 0, int $seconds = 0) Hold / session deadline as now() + interval (drives auto-void).
settleBalanceAfter(\DateInterval|\DateTimeInterface|int $after) When to auto-settle the balance; int = seconds from now.
for(Model $subject) Attach a polymorphic subject (order/booking) to the ledger row.
customer(string $reference) Gateway customer id (e.g. Stripe cus_...).
paymentMethod(string $paymentMethod) Gateway payment-method id (e.g. Stripe pm_...).
currency(string $currency) Records an uppercased currency hint. The authoritative currency is the one on the Money you pass to charge()/hold()/deposit().
idempotencyKey(string $key) App-supplied idempotency key (else the manager mints a UUIDv7).
metadata(array $metadata) Merged into the ledger row + forwarded to the gateway.
returnUrl(string $url) / cancelUrl(string $url) For redirect / hosted flows.
via(string $gateway) Override the flow's configured gateway.
recurEvery(string $period) ISO-8601 recurrence period (e.g. 'P1M', 'P1Y'). Anchors a payment_subscriptions schedule row to this charge — needs the opt-in subscriptions module; a no-op otherwise.
withLineItems(array $items) Immutable charge-time snapshot for the opt-in line_items module. Each item: amount (a Money, required), plus optional type, description, quantity, unit_amount (Money), tax_amount (Money), sellable (morphed Model), metadata. Validated up front — throws before any row is persisted.
withGatewayOptions(array $options) Merge gateway-specific request fields into the outgoing PaymentIntent / Checkout payload (the facade twin of the per-flow params.gateway_options config — builder wins on a key conflict). See Gateway options passthrough.
onCapture(callable) / onExpiry(callable) / onSucceeded(callable) / onFailed(callable) / onBalanceCharged(callable) Process-local, payment-scoped one-off listener sugar (see Events).
execute(): PendingPayment Terminal. Persists the created row, runs initiate(), returns the result.

PendingPayment exposes: requiresAction(): bool, action(): ?RedirectAction, redirectUrl(): ?string, clientSecret(): ?string, status(): PaymentStatus, gatewayReference(): ?string, payment(): PaymentRecord, result(): FlowResult.

Gateway options passthrough

Anything the gateway accepts that a flow does not model as a first-class knob is reachable without writing a custom flow strategy — via the reserved gateway_options passthrough. Its keys are merged verbatim (no translation) into the outgoing request payload, so it is bounded and deliberately gateway-specific (unportable across gateways by design). Set it two ways:

Config-only — a params.gateway_options array on any flow. Keys land on the PaymentIntent for the payment_intent family, and on the Checkout Session for hosted_checkout:

// config/payments.php → 'flows'
'pay_now' => [
    'gateway' => 'stripe',
    'strategy' => 'payment_intent',
    'params' => [
        'capture' => 'automatic',
        'confirmation' => 'automatic',
        'sca' => 'required',
        'gateway_options' => [
            'statement_descriptor' => 'ACME STORE',
            'receipt_email' => 'orders@acme.test',
        ],
    ],
],

'pay_link' => [
    'gateway' => 'stripe',
    'strategy' => 'hosted_checkout',
    'params' => [
        'mode' => 'payment',
        'success_url' => '/pay/success',
        'cancel_url' => '/pay/cancel',
        'gateway_options' => [
            'customer_email' => 'buyer@acme.test',
            'allow_promotion_codes' => true,
        ],
    ],
],

Facade-only->withGatewayOptions() on the builder, which wins over the config value on a key conflict:

Payments::flow('pay_now')
    ->charge(Money::of('49.00', 'USD'))
    ->withGatewayOptions(['statement_descriptor' => 'ACME STORE'])
    ->execute();

Manager operations

Follow-up operations are explicit on the Payments facade — they are never implicit side effects of the builder:

Payments::capture($uuid, Money::ofMinor(3000, 'USD')); // partial or full (null)
Payments::void($uuid);                                  // release an uncaptured hold
Payments::refund($uuid, Money::of('5.00', 'USD'), 'requested_by_customer'); // legacy one-shot
Payments::refunds()->of($uuid)->fullAmount()->execute();                    // v2 sub-manager (below)
Payments::settleBalance($uuid);                         // charge the saved balance
Payments::chargeSubscription($subscriptionUuid);        // off-session-charge the next renewal period

Payments::find($uuid);                                  // ?PaymentRecord — a read-only lookup
Payments::gateway('stripe')->capture($ref, $amount);    // raw driver primitive

Refunds

Two surfaces. Payments::refund($uuid, ?Money, ?string $reason) is the byte-stable one-shot. Payments::refunds() is the complete v2 lifecycle — a fluent builder with row-first uuid7 idempotency (persist a payment_refunds row before the gateway call), refund reasons, refund-by-charge, listing, retrieve, and a tightened cancel:

use Gulf\Payments\Enums\RefundReason;

Payments::refunds()->of($uuid)->fullAmount()->execute();          // full
Payments::refunds()->of($uuid)
    ->amount(Money::of('15.00', 'USD'))
    ->reason(RefundReason::RequestedByCustomer)
    ->execute();                                                  // partial + reason

Payments::refunds()->list($uuid);        // Collection<Data\RefundDetail> (local-first when the module is on)
Payments::refunds()->retrieve($re);      // one refund by re_
Payments::refunds()->cancel($re);        // ONLY a requires_action refund — clear exception otherwise
  • Idempotency: a retry passes the same ->idempotencyKey($k) (reuses the row, charges the ledger once); a genuinely new refund omits it (fresh uuid7, new row) — so two identical partial refunds both land. This closes the v1 same-amount-partial limitation for the sub-manager path.
  • Cancel + reversal: canceling (or a failed refund) decrements amount_refunded back through the transitioner — the sole ratified breaking change in v2.0.0 adds three state-machine reversal edges so a fully-reversed refund returns to succeeded.
  • Degrade-to-primitive: a driver without SupportsRefundManagement falls back to the frozen refund() automatically.
  • Events: RefundCreated (unchanged) + RefundSucceeded / RefundFailed / RefundCanceled, all after-commit.

Full guide: docs/refunds.md.

chargeSubscription() charges the saved off-session PM for the subscription's next period, parents the renewal as a normal payments row under the anchor charge (so refund/void/dispute work on it unchanged), and advances a dunning ladder (past_due, retry_attempt++, eventually canceled + PaymentBalanceSettlementExhausted) on a hard decline — see Subscriptions / recurring billing.

Customers & the payment-method vault

Payments::customers() (v2.0.0 Wave 2) is a first-class customer surface + a payment-method vault, opt-in via the customers / vault schema modules. It rides on the OPTIONAL Contracts\ManagesCustomers driver side-interface (the frozen GatewayDriver is untouched) and the CustomerSync / VaultSync sole-writers. Every list/search/methods op returns a Collection, never the driver array shape.

$cus = Payments::customers()->create($profile, $user);   // + local mirror
Payments::customers()->createOrGet($user);               // idempotent + race-safe (owner-unique)
Payments::customers()->attach($cus->reference, 'pm_123'); // fills brand/last4/exp/fingerprint
Payments::customers()->setDefault($cus->reference, 'pm_123'); // invoice_settings.default_payment_method
Payments::customers()->paymentMethods($cus->reference);  // Collection<PaymentMethod>
  • Platform-only (v2.0.0): a connected-account context is rejected (deferred to v2.1).
  • Closes the v1 vault-null caveat #2 — card columns are now populated.
  • Stripe Search (search(onGateway: true)) is eventually consistent (~1 min) and not available in all regions; the default local-first search is read-after-write correct.
  • Events (after-commit): CustomerCreated/Updated/Deleted, PaymentMethodAttached/ Detached/Updated. The customer.* / payment_method.* webhooks are a claim-first side branch that never touches the transitioner.

Full guide: docs/customers.md.

Save a card (save_card)

The save_card flow tokenizes a card against a customer with no money movement (a Stripe SetupIntent), via Payments::customers()->saveCard():

$pending = Payments::customers()->saveCard($user, ['return_url' => 'https://app.test/return']);
// $0 row, flow='save_card', keyed on the seti_; RequiresAction/Processing → Succeeded.

It traverses only pre-existing state-machine edges, harvests the pm_ on setup_intent.succeeded (mainline webhook, findByReference(seti_)), and fires the distinct CardSaved (not PaymentSucceeded) — so isPaid() / totalPaid() and ->onSucceeded never treat a $0 save-card as a charge. Front end: mark the confirm element data-mode="setup" so confirm.js confirms the seti_ secret with stripe.confirmCardSetup. Full guide: docs/save-card.md.

Connected accounts (Payments::connect())

Payments::connect() (v2.0.0 Wave 3) is a first-class Stripe Connect account surface — onboarding + CRUD + hosted links — opt-in via the connected_accounts schema module. It rides on the OPTIONAL Contracts\SupportsConnectedAccounts driver side-interface (the frozen GatewayDriver is untouched) and the ConnectedAccountSync sole-writer. Platform-key in this wave (no Stripe-Account header); account CRUD is platform-scoped.

use Gulf\Payments\Data\ConnectAccountRequest;
use Gulf\Payments\Enums\AccountType;

$account = Payments::connect()->createAccount(new ConnectAccountRequest(
    type: AccountType::Express, country: 'US', email: 'seller@example.test', owner: $seller,
));                                                       // + local mirror, ConnectedAccountCreated

$link = Payments::connect()->onboardingLink($account->reference, $refreshUrl, $returnUrl);
return redirect()->away($link->url);                     // single-use, short-lived, NEVER persisted

Payments::connect()->syncAccount($account->reference);   // force retrieve + upsert
Payments::connect()->rejectAccount($ref, 'fraud');       // reason validated: {fraud, terms_of_service, other}
Payments::connect()->listAccounts(new ConnectAccountQuery); // Collection (local-first when the module is on)
  • Separate connect webhook streamPOST {prefix}/{gateway}/connect, verified against STRIPE_CONNECT_WEBHOOK_SECRET (distinct from the main secret) and registered only when that secret is non-empty. account.updated / account.application.deauthorized are a claim-first side branch that never touches the transitioner. The stored event_id is acct-prefixed ("$acct:$evt") so the same evt_ id under two accounts never collides — zero core migration.
  • ConnectedAccountReady fires exactly once on the charges && payouts false→true flip (redelivery-safe, no marker column). Also Created/Updated/Restricted/Deauthorized.

Charge models (Wave 4)

Three fluent builder verbs turn any flow into a Connect charge — the same PaymentIntent lifecycle, different request params chosen at runtime (no new flow classes, no new driver interface method). They ride a trailing-nullable FlowContext.$connect into the frozen createIntent() / authorize() primitives, so a charge with no verb is byte-identical to a platform charge.

use Gulf\Payments\Data\Money;

// DIRECT — pi_ ON the connected account (Stripe-Account header + application_fee_amount).
Payments::flow('pay_now')->charge(Money::of('100.00','USD'))
    ->directTo('acct_seller', Money::of('10.00','USD'))->execute();

// DESTINATION — pi_ on the PLATFORM (transfer_data[destination] + on_behalf_of + fee; NO header).
Payments::flow('pay_now')->charge(Money::of('100.00','USD'))
    ->onBehalfOf('acct_seller', transferTo: Money::of('90.00','USD'), fee: Money::of('10.00','USD'))->execute();

// SEPARATE — a normal platform charge tagged with a transfer_group; move funds via connect()->transfer().
Payments::flow('pay_now')->charge(Money::of('100.00','USD'))->transferGroup('order_9021')->execute();
  • A direct charge persists payments.connected_account_id (opt-in connected_accounts module column) so capture/void/refund/retrieve/reconcile replay the Stripe-Account header; the charge-creation idempotency key is acct-dimensioned. Refunding a direct charge via Payments::refunds()->of($uuid)->execute() replays the header (the frozen refund() primitive is untouched).
  • Boot-time gate: a flow that carries a connect block fails on deploy if the driver disclaims Capability::DirectCharge / DestinationCharge.

Money movement + refunds on connect (Wave 5)

Payments::connect() also moves money once accounts exist and charges are made — each op routes writes through a sole-writer service so the API and /connect webhook paths converge on identical rows + events, gated on Capability::Transfers / Capability::Payouts.

use Gulf\Payments\Data\{Money, TransferRequest, PayoutQuery};

// Transfers (platform → connected account) — the SEPARATE-charge money leg.
$tr = Payments::connect()->transfer(new TransferRequest(
    amount: Money::of('90.00','USD'), currency: 'USD',
    destinationAccount: 'acct_seller', transferGroup: 'order_9021',
));                                                            // TransferCreated
Payments::connect()->reverseTransfer($tr->reference, Money::of('30.00','USD')); // TransferReversed (DELTA)

// Connected payouts (connected balance → seller's bank).
Payments::connect()->createPayout('acct_seller', Money::of('90.00','USD'));      // PayoutReceived

// Application-fee refund (Edge 2 single-owner of payment_fees.refunded_amount).
Payments::connect()->refundApplicationFee('fee_123', Money::of('10.00','USD'));  // ApplicationFeeRefunded
  • Empty-account guard (money-safety): createPayout('' , …) / listPayouts / retrievePayout / transfer(destinationAccount: '') throw — an empty ref would silently run on the platform balance (wrong-bank-account payout).
  • Monotonic amount_reversed floor: an out-of-order transfer.updated cannot regress the stored reversal total, un-reversed a row, or fire a spurious TransferReversed.
  • Refund-on-connect matrix (Payments::refunds()): directstripe_account header + refund_application_fee; destination (->reverseTransfer()) → reverse_transfer + refund_application_fee on the platform; separate/plain → a plain refund. ->refundApplicationFee() is tri-state — unset takes the matrix default, an explicit false is respected. Clawing back a separate transfer is connect()->reverseTransfer(), never a refund param.
  • Known limitation: createPayout() sends no idempotency key in v2.0.0 (retry hazard; optional caller key in v2.1) — reconcile via listPayouts() before retrying.

Full guides: docs/connect/onboarding.md, docs/connect/webhooks.md, docs/connect/choosing-a-charge-model.md, docs/connect/money-movement.md, docs/connect/refunds-on-connect.md.

Business presets

config('payments.preset') (env PAYMENTS_PRESET) applies a named ConfigPreset as a floor: the preset's flow/param/currency/schedule defaults apply first, then your app's actual config/payments.php customizations always win. A preset never flips a schema flag or rewrites PHP — payments:install --preset=<key> only prints the fragment it would apply, as a copy/paste reminder.

Preset Key What it does
E-commerce ecommerce Suggests a checkout (hosted_checkout) flow entry alongside pay_now, plus reconcile/expire cron cadences.
SaaS saas Adds a subscription (recurring) flow entry and a 15-minute reconcile cadence for off-session renewals.
Bookings bookings Tunes book_pay_later's hold window to 48h and a tighter 15-minute expire cadence.
Donations donations Adds a donation (hosted_checkout) pay-by-link flow entry.
Invoicing invoicing Adds invoice (webhook-confirmed pay_now) and installment (deposit_balance, time-settled) flow entries plus an hourly settle cadence.
Gulf gulf GCC-market defaults — money.default_currency = KWD (brick/money's own ISO-4217 table already handles the 3-decimal precision correctly).
php artisan payments:install --preset=bookings
PAYMENTS_PRESET=bookings

The opt-in universal schema

The core install is 2 tables: payments (the ledger) and payment_webhook_events (the dedupe/audit store). Twelve additional modules are available, each dual-gated — a config('payments.schema.<module>') flag and the table actually existing (SchemaFeatures memoizes Schema::hasTable()), so a flag with no migration published is a silent no-op, never a fatal. With every flag at its default (false) the package makes zero extra queries.

Module Flag / env Table What it adds
Customers schema.customers / PAYMENTS_SCHEMA_CUSTOMERS payment_customers Gateway customer identities per owner, opportunistically upserted.
Vault schema.vault / PAYMENTS_SCHEMA_VAULT payment_methods Saved cards (type/brand/last4/exp/fingerprint columns, populated by VaultSync across every write path since v2.0.0).
Refunds schema.refunds / PAYMENTS_SCHEMA_REFUNDS payment_refunds A per-refund row (id/amount/status/reason + v2 idempotency key, charge ref, failure reason, canceled_at, fr_/trr_, connected account), upserted from the API path (Payments::refunds(), row-first) and the webhook path.
Disputes schema.disputes / PAYMENTS_SCHEMA_DISPUTES payment_disputes Chargeback rows; drives the RemindDisputeEvidence sweep.
Line items schema.line_items / PAYMENTS_SCHEMA_LINE_ITEMS payment_line_items An immutable charge-time snapshot via ->withLineItems().
Ledger (Tier-A) schema.ledger / PAYMENTS_SCHEMA_LEDGER payment_transactions A transaction-level audit trail alongside the aggregate ledger.
Status audit schema.audit / PAYMENTS_SCHEMA_AUDIT payment_status_transitions Every applied status transition (feeds the status endpoint's timeline).
Payouts schema.payouts / PAYMENTS_SCHEMA_PAYOUTS payment_payouts Gateway payout/settlement records.
Fees schema.fees / PAYMENTS_SCHEMA_FEES payment_fees Gateway processing-fee records.
Subscriptions schema.subscriptions / PAYMENTS_SCHEMA_SUBSCRIPTIONS payment_subscriptions (+ a settle_attempts column on payments) Recurring-billing schedule rows + the settlement retry ceiling (see below).
Connected accounts schema.connected_accounts / PAYMENTS_SCHEMA_CONNECTED_ACCOUNTS payment_connected_accounts Stripe Connect acct_ mirror per owner (type + charges/payouts/details flags, requirements, deauthorized_at), upserted from Payments::connect() and the /connect webhook. Needs STRIPE_CONNECT_WEBHOOK_SECRET. Also adds a guarded payments.connected_account_id column for direct-charge header replay.
Transfers schema.transfers / PAYMENTS_SCHEMA_TRANSFERS payment_transfers Platform→connected-account transfer records (amount, amount_reversed high-water mark, transfer_group, source_transaction, back-ref to the funding payment), upserted from Payments::connect()->transfer() and the transfer.* /connect webhook (Wave 5). The fees module also adds payment_fees.refunded_amount and payouts adds payment_payouts.connected_account_id.

Publish a module's migrations without selecting a gateway:

php artisan payments:install --with=refunds --with=vault
php artisan payments:install --full   # every module

Then flip its flag (.env or config/payments.php) and php artisan migrate. php artisan payments:backfill-refunds retroactively fills payment_refunds from already-retained webhook payloads once the module is enabled after the fact; php artisan payments:backfill-connected-accounts does the same for payment_connected_accounts from retained account.* connect payloads.

Model traits & scopes

Mix these onto your own Eloquent models for $order->pay()->charge(...) ergonomics without touching the package internals:

Trait Use on Gives you
Concerns\HasPayments Any subject model (Order, Booking, ...) Read-only: payments() (MorphMany), payment() (latest row), paymentStatus(), isPaid(), isPaymentPending(), totalPaid(), totalRefunded().
Concerns\Payable Any subject model Everything in HasPayments, plus write sugar: pay(?string $flow = null), holdPayment(), depositPayment() — each returns a PendingPaymentBuilder pre-attached via ->for($this); you still finish with ->execute().
Concerns\HasPaymentMethods The model that owns a gateway customer (User/Team/...) paymentCustomers(), paymentMethods(), defaultPaymentMethod() — reads the opt-in customers/vault modules.
Concerns\HasConnectedAccount The seller/vendor model (Stripe Connect owner) asConnectedAccount(), createConnectedAccount(AccountType), connectedAccountReference() — reads the opt-in connected_accounts module (returns null when off).
Concerns\HasPaymentsConnection Any optional-schema-module model Routes the model onto config('payments.persistence.connection') when set, else its own default connection.
class Order extends Model
{
    use Payable;
}

$order->pay()->charge(Money::of('49.00', 'USD'))->paymentMethod($pmId)->execute();

$order->isPaid();          // bool
$order->totalRefunded();   // Money

Models\Payment also ships query scopes used internally by payments:doctor and the scheduled jobs, handy for your own dashboards: nonTerminal(), stuck(?int $thresholdMinutes), expiringSoon(?int $windowMinutes), dueForSettlement(), forSubject(Model $subject), failedChildrenOf($parent).

Subscriptions / recurring billing

Recurring billing composes existing primitives rather than adding new state:

  1. Payments::flow('subscription')->charge(...)->recurEvery('P1M')->execute() charges the first period off-session-saving the PM and, when the subscriptions module is installed, creates a payment_subscriptions row anchored to that payment.
  2. The scheduled SettleDueSubscriptions job (or an explicit Payments::chargeSubscription($uuid)) off-session-charges the next period as a normal payments row parented to the anchor — refund/void/dispute work on it unchanged.
  3. A deterministic idempotency key (sub:<uuid>:p<period>:r<retry>) means a concurrent duplicate of the same period+attempt no-ops rather than double-charging, while a genuine dunning retry (retry_attempt bumped) re-attempts the card.
  4. On a hard decline, the ladder advances: past_due, retry_attempt++, until config('payments.settlement.max_attempts') is reached (or a hard decline code short-circuits it via settlement.hard_decline_is_terminal), at which point the subscription is canceled and PaymentBalanceSettlementExhausted fires. The same ceiling applies to deposit_balance balance-charge retries once the subscriptions module's settle_attempts column is present.
$schedule->job(new \Gulf\Payments\Jobs\SettleDueSubscriptions)->hourly();

Configuration reference

config/payments.php is the single source of truth. Full annotation: docs/configuration.md.

Key Meaning
default Flow used when Payments::flow() is called with no name. Ships as pay_now. Env: PAYMENTS_DEFAULT_FLOW.
preset Optional business preset key (see Business presets). Env: PAYMENTS_PRESET.
money.default_currency Declares the project's default currency. In practice each builder call passes a Money that already carries its currency. Env: PAYMENTS_CURRENCY (default USD).
idempotency.enabled Auto-mint a UUIDv7 key when the app supplies none. When false an app key is still forwarded, and the DB unique index still dedupes. Env: PAYMENTS_IDEMPOTENCY.
idempotency.reuse_window Seconds after which a stuck created row may be re-keyed / reconciled (default 86400).
webhooks.enabled Register the ingress route. false → confirmation happens via poll/reconcile instead. Env: PAYMENTS_WEBHOOKS.
webhooks.prefix Route prefix (default payments/webhookPOST payments/webhook/{gateway}).
webhooks.middleware Middleware for the ingress route (default ['api']).
webhooks.tolerance Signature timestamp window in seconds (default 300).
webhooks.return_url_enabled Registers GET {prefix}/{gateway}/return for redirect-first gateways. Disabled by default. Env: PAYMENTS_RETURN_URL.
persistence.enabled The owned ledger is on by default. Env: PAYMENTS_PERSISTENCE.
persistence.connection Optional DB connection for the ledger (and every optional-schema-module model). Env: PAYMENTS_DB_CONNECTION.
persistence.repository Swappable PaymentRepository implementation.
persistence.model Swappable Payment model.
tenancy.resolver Optional TenantResolver for per-request credentials. null = single-tenant. Env: PAYMENTS_TENANCY_RESOLVER.
schema.* The 12 opt-in module flags (see The opt-in universal schema).
logging.channel Log channel for lifecycle lines (webhook received/duplicate, transition applied, sweep counts) — every context array is redacted first. Env: PAYMENTS_LOG_CHANNEL (default payments).
metrics.driver Optional Contracts\Metrics implementation class; null = a no-op NullMetrics. Env: PAYMENTS_METRICS_DRIVER.
schedule.auto_register true registers the safety-net jobs onto the app scheduler for you. Env: PAYMENTS_SCHEDULE_AUTO.
schedule.queue / .connection Optional shared queue/connection for the auto-registered jobs.
schedule.reconcile.cron / .expire.cron / .settle.cron / .settle_subscriptions.cron / .dispute_reminder.cron Per-job cron override; null uses the sensible shipped default frequency.
doctor.thresholds.* Warning thresholds (webhook_lag_minutes, stuck_minutes, expiring_holds_window_minutes) for payments:doctor --prod.
validation.strict_params true makes the boot-time ConfigValidator error (rather than log) on an unknown/disallowed flow param. Env: PAYMENTS_STRICT_PARAMS.
settlement.max_attempts / .hard_decline_is_terminal The off-session retry ceiling for deposit balances and subscription dunning. Env: PAYMENTS_SETTLEMENT_MAX_ATTEMPTS / PAYMENTS_SETTLEMENT_HARD_DECLINE_TERMINAL.
http.status_endpoint.* The opt-in status-polling endpoint (see Status endpoint & frontend confirm kit).
gateways.stripe Stripe driver class + credentials (all from env).
flows.* Each binds a gateway + strategy + data-only params.

The two toggles the product owner asked for:

  • Webhook or notwebhooks.enabled. true registers POST /payments/webhook/{gateway} for terminal-state confirmations. false removes the route entirely; the ReconcilePendingPayments schedule (poll) then drives confirmation. Per-flow you can also set params.confirmation to automatic | manual | webhook | poll.
  • Idempotency or notidempotency.enabled. true auto-mints a key when you supply none. false does not disable dedup: an app-supplied key is still honoured, and the single-column DB unique index on idempotency_key always applies (the manager namespaces app keys per tenant as {tenant}:{key}, so a global unique is tenant-safe). Dedup is never fully off.

Webhooks & return-URL ingress

A single package route ingests every gateway callback:

POST /payments/webhook/stripe

Set STRIPE_WEBHOOK_SECRET to your endpoint's signing secret, point Stripe at that URL, and the ingress guarantees:

  • Signature-verified — a bad signature returns 400 and never touches state.
  • Deduped(gateway, event_id) is recorded, but the slot is consumed only after a real, persisted transition.
  • Out-of-order-safe — a stale/older event is a no-op that still ACKs 200 (no retry storm).
  • Race-safe — a webhook that arrives before its ledger row exists returns 202 and does not consume the dedupe slot, so the redelivery (or reconcile) applies it later.

Handled Stripe events: payment_intent.succeeded, payment_intent.processing, payment_intent.payment_failed, payment_intent.requires_action, payment_intent.amount_capturable_updated, payment_intent.canceled, checkout.session.completed, checkout.session.expired, charge.refunded, charge.refund.updated, charge.dispute.created. Any other event type is acknowledged and ignored.

Setting webhooks.enabled=false removes the route — confirmation then relies on the ReconcilePendingPayments poll. Full guide: docs/webhooks.md.

Return-URL ingress (redirect-first gateways)

Hosted-page-first gateways (Tap/PayTabs/Moyasar, PayPal approve-return) confirm via a browser GET back to the app rather than a webhook. Setting webhooks.return_url_enabled=true registers:

GET /payments/webhook/{gateway}/return

which runs the same verify → dedupe → advance → commit → emit pipeline as a webhook, reusing WebhookController. A {gateway} whose driver does not implement Contracts\SupportsReturnConfirmation returns 404. Disabled by default.

Status endpoint & frontend confirm kit

config('payments.http.status_endpoint') is an opt-in, disabled-by-default JSON endpoint (GET {prefix}/{uuid}) for front-end confirmation polling. It refuses to register unless its middleware includes an app-named auth guard (core never ships an unauthenticated money-data route), but auth alone is not ownership: with only the shipped ['web', 'auth'] default, any authenticated user can read any other user's payment (including a live client_secret) by uuid. For any multi-user app, add an ownership/authorization gate to the middleware list, e.g. ['web', 'auth', 'can:view,payment']payments:doctor warns (status_endpoint_auth) whenever the endpoint is enabled with the bare default and nothing else. Full guide, including the policy pattern: docs/status-endpoint.md.

The response body — and the exact shape the frontend kit below consumes:

{
  "uuid": "018f2e4b-...",
  "status": "requires_action",
  "requires_action": true,
  "client_secret": "pi_..._secret_...",
  "redirect_url": null,
  "timeline": [ /* only when the opt-in 'audit' module is installed */ ]
}

A zero-dependency, zero-build-step JS module and a Blade component consume this endpoint, published under the payments-frontend tag:

<x-payments::confirm :uuid="$payment->uuid" />
import { confirmPayment, autoBind } from '/vendor/payments/confirm.js';

// Either wire it yourself…
confirmPayment({
    statusUrl: `/payments/status/${uuid}`,
    onRequiresAction: (body) => stripe.confirmCardPayment(body.client_secret),
    onSucceeded: (body) => showReceipt(body),
    onFailed: (body) => showError(body),
});

// …or let the Blade component's data attributes drive it automatically —
// autoBind() dispatches `payments:requires-action` / `payments:succeeded` /
// `payments:failed` CustomEvents on the element.

Scheduling

Five queued jobs are the safety nets and sweeps. Register them yourself in your scheduler (routes/console.php or app/Console/Kernel.php), or set payments.schedule.auto_register=true (env PAYMENTS_SCHEDULE_AUTO) to let the package register them for you via ScheduleRegistrar (honoring an optional shared queue/connection and per-job cron overrides under schedule.*.cron):

use Gulf\Payments\Jobs\ExpireHeldPayments;
use Gulf\Payments\Jobs\SettleDueBalances;
use Gulf\Payments\Jobs\ReconcilePendingPayments;
use Gulf\Payments\Jobs\SettleDueSubscriptions;
use Gulf\Payments\Jobs\RemindDisputeEvidence;

$schedule->job(new ExpireHeldPayments)->everyFiveMinutes();
$schedule->job(new SettleDueBalances)->hourly();
$schedule->job(new ReconcilePendingPayments)->everyTenMinutes();
$schedule->job(new SettleDueSubscriptions)->hourly();       // needs the 'subscriptions' module
$schedule->job(new RemindDisputeEvidence)->daily();          // needs the 'disputes' module
  • ExpireHeldPayments — voids book_pay_later holds past expires_at.
  • SettleDueBalances — off-session-charges deposit_balance rows past balance_due_at.
  • ReconcilePendingPayments — polls retrieve() for rows stuck in an early non-terminal state past idempotency.reuse_window; this is what makes the webhook-before-initiate 202 self-heal even without a redelivery.
  • SettleDueSubscriptions — off-session-charges the next period of every active/past-due subscription past next_charge_at. A no-op (zero queries) when the subscriptions module is off; auto_register only schedules it when the module's flag is enabled.
  • RemindDisputeEvidence — dispatches DisputeEvidenceDue for disputes whose evidence_due_at falls within a 24-hour window. A no-op when the disputes module is off; auto_register only schedules it when enabled.

Events

Domain events are dispatched by the transitioner after the DB transaction commits (DB::afterCommit), so a listener always observes a durable ledger row. Each event carries the PaymentRecord (dispute events carry their PaymentDispute row).

Lifecycle: PaymentInitiated, PaymentRequiresAction, PaymentProcessing, PaymentAuthorized, PaymentCaptured, PaymentSucceeded, PaymentFailed, PaymentCanceled, PaymentExpired, PaymentRefunded, PaymentPartiallyRefunded, PaymentDisputed, PaymentBalanceCharged, PaymentBalanceSettlementExhausted, PaymentTransitionRejected.

Ingress / sync (audit-only, pre-transition): WebhookReceived, WebhookUnmatched.

Opt-in-module / sub-manager events (all after-commit), grouped by surface:

  • Refund outcomes: RefundCreated, RefundSucceeded, RefundFailed, RefundCanceled.
  • Disputes: DisputeOpened, DisputeClosed, DisputeEvidenceDue.
  • Payouts: PayoutReceived.
  • Customers & vault: CustomerCreated, CustomerUpdated, CustomerDeleted, PaymentMethodAttached, PaymentMethodDetached, PaymentMethodUpdated, CardSaved (the distinct save_card event — never PaymentSucceeded).
  • Connect & transfers: ConnectedAccountCreated, ConnectedAccountUpdated, ConnectedAccountReady, ConnectedAccountRestricted, ConnectedAccountDeauthorized, TransferCreated, TransferReversed, ApplicationFeeRefunded.
  • Reconcile: ReconcileRepaired.

Attach real listeners for durable side effects — and queue them for heavy work:

use Gulf\Payments\Events\PaymentSucceeded;

Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) {
    $payment = $event->payment;            // PaymentRecord
    Mail::to(/* … */)->queue(new ReceiptMail($payment));
});

Scaffold a durable listener (never edits your EventServiceProvider — it only prints the registration line):

php artisan payments:make:listener SendReceiptEmail --event=PaymentSucceeded

The builder's on* closures (onCapture/onExpiry/onSucceeded/onFailed/onBalanceCharged) are process-local sugar: payment-scoped one-off listeners that fire only for events dispatched in the current process (great for a synchronous pay_now confirmation). They are not persisted — a capture or balance settle in a later request/job will not trigger them. For durable, cross-request side effects use real listeners; the domain events remain the source of truth.

Testing

Payments::fake() swaps in a FakePaymentManager that runs the real builder, flows and state machine against a network-free fake driver — so full flows execute and persist real ledger rows — and records what happened for assertions.

use Gulf\Payments\Facades\Payments;
use Gulf\Payments\Data\Money;

it('charges a booking deposit and settles the balance', function () {
    $fake = Payments::fake();

    $pending = Payments::flow('deposit_balance')
        ->deposit(Money::of('30.00', 'USD'))
        ->balance(Money::of('70.00', 'USD'))
        ->customer('cus_test')
        ->paymentMethod('pm_test')
        ->execute();

    $fake->assertInitiated('deposit_balance');

    Payments::settleBalance($pending->payment()->uuid());

    $fake->assertBalanceSettled($pending->payment()->uuid());
});

Available assertions: assertInitiated(?string $flow = null), assertCaptured(?string $uuid = null), assertVoided(?string $uuid = null), assertRefunded(?string $uuid = null), assertBalanceSettled(?string $uuid = null), assertSubscriptionCharged(?string $uuid = null), assertNothingCharged(), plus recordedInitiations() for custom assertions. Program declines / next-actions via Payments::fake()->driver()->program(...), and jump past a hold/balance deadline in a test with Payments::fake()->travel(days: 2). Full guide: docs/testing.md.

Command reference

Command Purpose
payments:install Publish core config + migrations, install gateways, publish optional schema modules, print preset guidance. See Installation.
payments:doctor Network-free health check (--prod adds read-only production rules, --json, --strict). Exit codes: 0 all Ok, 1 warnings, 2 errors (or warnings with --strict).
payments:flows Read-only introspection over config('payments.flows'): gateway, strategy, configured params, and (when the strategy implements DescribesParams) its declared param schema. --flow=<name>, --json.
payments:status {uuid} Render a payment record: status, ledger, expiry/balance, saved PM, parent/child linkage, metadata. --json.
payments:webhook:listen Prints Stripe CLI / webhook-secret / replay-curl guidance — never spawns a process itself. --gateway=, --url=.
payments:config:diff Report-only: diffs the installed package's reference config/payments.php against your published file, one level deep into gateways/flows. Never writes to your config. --json.
payments:backfill-refunds Best-effort backfill of payment_refunds rows from already-retained refund webhook payloads, once the refunds module is enabled after the fact. --dry-run.
payments:make:listener {name} --event= Scaffold a Listeners\ScopedPaymentListener subclass; prints (never writes) the EventServiceProvider registration line.
payments:make:gateway {name} Scaffold Gateway/Installer/StatusMapper/WebhookParser + a DriverConformanceTest skeleton; prints the config block + registry registration line. --key=, --label=.
payments:make:flow {name} Scaffold a PaymentFlow strategy skeleton; prints the config('payments.flows.*') block + Payments::registerFlow() line. --strategy=.

Every make:* / config:diff / webhook:listen command follows the same rule as the installer: instruct, never silently rewrite — nothing but payments:install and the make:* commands' own generated files ever touches your app's files, and even those never touch your config or providers.

Extending — adding a gateway

A gateway is "the class named in config('payments.gateways.<name>.driver')", autowired by the container. To add one:

  1. Write a driver implementing Gulf\Payments\Contracts\GatewayDriver — the primitives (createIntent, createCheckoutSession, authorize, capture, void, refund, retrieve, parseWebhook) plus supports(Capability). Return false from supports() for anything the gateway can't do; a flow that needs a missing capability fails at boot via ConfigValidator, not on the first charge. Extend Gulf\Payments\Gateways\AbstractGateway for a driver that only needs to implement the primitives it actually supports — every other primitive already throws UnsupportedCapability for you, and the base mixes in HTTP/retry, money-conversion and idempotency-forwarding plumbing (StripeGateway itself predates it and is not required to extend it).
  2. Add a config block under gateways, and reference it from a flows entry.
  3. (Optional) ship an Installable so payments:install can scaffold env keys and the SDK instruction.
  4. Prove it against Gulf\Payments\Testing\DriverConformanceTest — an inherited CI checklist (capability honesty, status-mapping totality, parseWebhook() determinism, RedirectAction well-formedness, and the critical reference round-trip: the reference initiate() stores must be matchable by parseWebhook() for every webhook fixture, or the row becomes unreachable by webhooks despite money moving).

Scaffold all of the above in one shot:

php artisan payments:make:gateway Tap --key=tap --label="Tap Payments"
php artisan payments:make:flow InstallmentFlow --strategy=installment

Both commands only print (never write) the resulting config block and registration line — see docs/gateways/ for the PayPal and Gulf-PSP (Tap/PayTabs/Moyasar) fit matrices these commands and AbstractGateway target.

For a quick custom driver without a config class, register a factory:

Payments::extend('acme', fn ($app, array $config) => new AcmeGateway($config));

Need bespoke orchestration? Point a flow at your own strategy class:

// config/payments.php
'flows' => [
    'my_flow' => ['gateway' => 'stripe', 'strategy' => 'my_strategy'],
],
Payments::registerFlow('my_strategy', App\Payments\MyFlow::class);

Gateways

Ten real drivers ship today; each is one driver class + one config block. Adding a gateway never touches the frozen GatewayDriver contract.

Gateway Regions Initiation Follow-up ops Confirmation Guide
Stripe Global payment_intent, hosted_checkout, authorize_capture, save_card, Connect capture / void / refund (+ full RefundManagement, Connect money movement) Webhook (+ SCA return) fullest-featured reference
Paymob Egypt / KSA / UAE / Oman hosted_checkout (Unified Intention API → Unified Checkout redirect) capture / void / refund (full + partial) Webhook (authoritative) + browser return docs/gateways/paymob.md
MyFatoorah Kuwait / KSA / UAE / Qatar / Bahrain / Oman / Jordan / Egypt hosted_checkout (v2 SendPayment → hosted invoice redirect; KNET, mada, cards, Apple Pay) refund (full + partial + repeated partial) Webhook V2 (authoritative) + retrieve-confirmed browser return docs/gateways/myfatoorah.md
Tabby KSA / UAE / Kuwait hosted_checkout (BNPL pay-in-4 → hosted-page redirect; auth lands RequiresCapture) capture (full + partial, on fulfillment) / void (close) / refund (post-close) Webhook — static shared-secret header (authoritative) + retrieve-confirmed browser return docs/gateways/tabby.md
Tamara KSA / UAE / Bahrain / Kuwait / Oman hosted_checkout (BNPL → hosted-page redirect; order_approved → a webhook-driven authorise seam → RequiresCapture) capture (full + partial) / void (cancel) / refund (post-capture) Webhook — hand-rolled HS256 JWT (authoritative) + retrieve-confirmed browser return docs/gateways/tamara.md
Tap Kuwait / KSA / UAE / Bahrain / Oman / Qatar / Egypt hosted_checkout and authorize_capture (KNET / mada / GCC cards / Apple Pay → hosted-page redirect; real card 3DS) capture (full + partial, via charge-on-authorize) / void / refund (full + partial) Webhook — hashstring HMAC-SHA256 (authoritative) + retrieve-confirmed browser return docs/gateways/tap.md
PayTabs KSA / UAE / Egypt / Oman / Jordan / Global hosted_checkout and authorize_capture (mada / cards / KNET → hosted-page redirect; real card 3DS) capture (full + partial) / void / refund (full + partial) — one endpoint, switching tran_type Webhook — raw-body HMAC-SHA256 (authoritative) + signed, retrieve-confirmed browser return docs/gateways/paytabs.md
Amazon Payment Services (ex-PayFort) Egypt / UAE / KSA / Jordan / GCC hosted_checkout and authorize_capture (cards / mada / Apple Pay / KNET → form-POST redirect via payments::post-redirect; real card 3DS) capture (full + partial) / void (full-only) / refund (full + partial) Webhook — two-phrase SHA (authoritative) + signed browser return docs/gateways/aps.md
Fawry / FawryPay Egypt hosted_checkout and authorize_capture (the PayAtFawry reference-code cash model → no redirect, row sits processing for days; cards → form-POST redirect via payments::post-redirect; wallets; real card 3DS) capture (full + partial) / void (routed: unpaid reference or card hold) / refund (full + partial) Webhook — plain secret-suffixed SHA-256 (NOT HMAC; authoritative) + retrieve-confirmed browser return docs/gateways/fawry.md
PayPal Global hosted_checkout and authorize_capture (Orders v2 → approve-link redirect; order_approved → a webhook-driven capture/authorize reactor; PayPal SCA folded in) capture (full + partial, via the authorization) / void (uncaptured holds) / refund (full + partial) Webhook — asymmetric RSA/certificate (offline CRC32 + SHA256withRSA, verify-webhook-signature fallback; authoritative) + retrieve-confirmed browser return docs/gateways/paypal.md

Install one gateway, several, or all — and what each needs

Every gateway is opt-in. payments:install publishes the core scaffolding once, then wires only the gateway(s) you name — install as few or as many as you like, now or later (re-running is idempotent):

# one gateway
php artisan payments:install --gateway=tap

# several — repeat --gateway
php artisan payments:install --gateway=stripe --gateway=tabby --gateway=tap

# every registered gateway
php artisan payments:install --all

# interactive: run with no flags and pick from a multiselect
php artisan payments:install

Then, for any install: run php artisan migrate, uncomment + fill the gateway's block in config/payments.php, and point at least one flow at it ('gateway' => '<key>'). The webhook route is auto-registered — no controller code to write. (--stubs adds optional browser controller/route stubs; --dry-run previews without writing; --with=<module> / --full publish optional schema-module migrations.)

What each gateway needs — only Stripe pulls an SDK; the five regional drivers are pure HTTP (zero extra Composer dependencies). payments:install seeds these keys into .env / .env.example as blank placeholders for you to fill:

Gateway Extra Composer package Env keys (installer-seeded) Also needs
Stripe stripe/stripe-php:^16.0 — the installer offers to run composer require for you in an interactive run (else prints the line) STRIPE_SECRET, STRIPE_KEY, STRIPE_WEBHOOK_SECRET (+ a Connect signing secret when the connected_accounts module is enabled)
Paymob none PAYMOB_SECRET_KEY, PAYMOB_PUBLIC_KEY, PAYMOB_API_KEY, PAYMOB_HMAC_SECRET, PAYMOB_INTEGRATION_CARD, PAYMOB_INTEGRATION_WALLET, PAYMOB_BASE_URL (region)
MyFatoorah none MYFATOORAH_API_TOKEN, MYFATOORAH_WEBHOOK_SECRET, MYFATOORAH_BASE_URL (per-country host; sandbox default)
Tabby none TABBY_SECRET_KEY, TABBY_MERCHANT_CODE, TABBY_WEBHOOK_SECRET, TABBY_WEBHOOK_SECRET_HEADER, TABBY_BASE_URL (UAE/KWT vs KSA host)
Tamara none TAMARA_API_TOKEN, TAMARA_NOTIFICATION_TOKEN, TAMARA_BASE_URL (sandbox default), TAMARA_AUTO_AUTHORISE (default on) a running queue worker (php artisan queue:work) — the merchant-authorise step is a queued reactor — and the scheduler (reconcile)
Tap none TAP_SECRET_KEY, TAP_MERCHANT_ID, TAP_BASE_URL, TAP_LANG_CODE
PayTabs none PAYTABS_PROFILE_ID, PAYTABS_SERVER_KEY, PAYTABS_CLIENT_KEY (optional), PAYTABS_BASE_URL (region host — required, no default) publish the opt-in return stub (--stubs) + enable return_url_enabled for the browser return
Amazon Payment Services (ex-PayFort) none APS_ACCESS_CODE, APS_MERCHANT_IDENTIFIER, APS_SHA_REQUEST_PHRASE, APS_SHA_RESPONSE_PHRASE, APS_SHA_TYPE (sha256), APS_TEST_MODE, APS_DEFAULT_CUSTOMER_EMAIL render payments::post-redirect at the checkout touchpoint (form-POST); publish the opt-in return stub (--stubs) for the browser return
Fawry / FawryPay none FAWRY_MERCHANT_CODE, FAWRY_SECURE_KEY, FAWRY_BASE_URL (staging default; prod requires the explicit host) schedule ReconcilePendingPayments (the PayAtFawry processing reference sits for days); render payments::post-redirect for the CARD form-POST; publish the opt-in return stub (--stubs) for the browser return
PayPal none PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET (OAuth pair — exchanged for a cached Bearer token), PAYPAL_WEBHOOK_ID (the verify anchor; ingress fails closed without it), PAYPAL_BASE_URL (sandbox default; set the live host to go live), PAYPAL_AUTO_AUTHORISE (default on) a running queue worker (php artisan queue:work) — the approve→capture step is a queued reactor — and the scheduler (reconcile); publish the opt-in return stub (--stubs) + enable return_url_enabled for the browser return

Test vs live is selected by the key prefix (sk_test_/sk_live_), the per-gateway *_BASE_URL, or APS's APS_TEST_MODE host pair — there is no separate test-mode flag for the key-prefix / base-URL gateways. Running the scheduler (the reconciliation safety-net jobs) benefits every install and is required for Tamara.

Paymob (Egypt / MENA)

Paymob is the package's second real gateway (v2.2.0) — a smallest-viable-first driver targeting the MENA market (cards, mobile wallets, BNPL via the hosted Unified Checkout). It is additive and zero-new-dependency: it rides the shared HTTP seam, so nothing is dragged into vendor/ and the Stripe path is byte-identical.

php artisan payments:install --gateway=paymob   # writes the PAYMOB_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.paymob block, then:
'flows' => [
    'pay_link_paymob' => [
        'gateway' => 'paymob',
        'strategy' => 'hosted_checkout',
        'params' => ['success_url' => '/pay/success', 'cancel_url' => '/pay/cancel'],
    ],
],
$pending = Payments::flow('pay_link_paymob')
    ->charge(Money::of('40.00', 'EGP'))
    ->execute();

return redirect($pending->redirectUrl());   // → the Unified Checkout hosted page

Paymob's server-to-server webhook is authoritative; the browser return is UX only, and both collapse onto one dedupe slot. Refunds/capture/void ride the frozen primitives (Payments::refund() / capture() / void()). Everything Paymob does not do — payment_intent (no client-confirm intent), authorize_capture (Auth/Cap is a dashboard Integration-ID property), saved cards, connect — is disclaimed loudly via supports() + a boot-time ConfigValidator failure, never on a live charge. Full capability table, HMAC field orders and the reference model are in docs/gateways/paymob.md.

MyFatoorah (Kuwait / GCC / MENA)

MyFatoorah is the package's third real gateway (v2.3.0) — a hosted-page-first aggregator for the Gulf and wider MENA market (KNET, mada, cards, Apple Pay picked on MyFatoorah's own page). Like Paymob it is additive and zero-new-dependency: it rides the shared HTTP seam, so the Stripe path stays byte-identical.

php artisan payments:install --gateway=myfatoorah   # writes the MYFATOORAH_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.myfatoorah block, then:
'flows' => [
    'pay_link_myfatoorah' => [
        'gateway' => 'myfatoorah',
        'strategy' => 'hosted_checkout',
        'params' => ['mode' => 'payment'],
    ],
],
$pending = Payments::flow('pay_link_myfatoorah')
    ->charge(Money::of('12.500', 'KWD'))   // MAJOR-unit decimals (KWD is 3-decimal)
    ->execute();

return redirect($pending->redirectUrl());   // → the MyFatoorah hosted invoice page

MyFatoorah's Webhook V2 is authoritative (MyFatoorah-Signature, an event-specific HMAC-SHA256). The browser return is unsigned — it carries only ?paymentId, so the driver's parseReturn() confirms it with a server-side GetPaymentStatus retrieve (the SupportsReturnConfirmation contract sanctions exactly this), and both channels collapse onto one dedupe slot. The ledger is single-axis: the row is keyed on the InvoiceId and never re-keyed. Refunds (full / partial / repeated partial) ride the frozen Payments::refund(). Everything MyFatoorah v2 does not do — payment_intent (embedded is v3-only), authorize_capture (no capture/void endpoint), saved cards, connect/marketplace — is disclaimed loudly via supports() + a boot-time ConfigValidator failure. The country-scoped token + per-country base-URL table, the Webhook-V2 setup, and the HMAC field-order sandbox-verify caveat are in docs/gateways/myfatoorah.md.

Tabby (BNPL — KSA / UAE / Kuwait)

Tabby is the package's fourth real gateway (v2.4.0) — the first honest manual-capture BNPL driver. The customer is redirected to Tabby's hosted pay-in-4 page; Tabby scores + OTP-verifies them and the payment lands AUTHORIZED — money is only collected when you explicitly capture() on fulfillment. Like Paymob / MyFatoorah it is additive and zero-new-dependency: it rides the shared HTTP seam, so the Stripe path stays byte-identical.

php artisan payments:install --gateway=tabby   # writes the TABBY_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.tabby block, then:
'flows' => [
    'pay_link_tabby' => [
        'gateway' => 'tabby',
        'strategy' => 'hosted_checkout',
        'params' => ['mode' => 'payment'],
    ],
],
$pending = Payments::flow('pay_link_tabby')
    ->charge(Money::of('300.00', 'SAR'))
    ->execute();

return redirect($pending->redirectUrl());   // → the Tabby hosted pay-in-4 page

// later, on fulfillment:
Payments::capture($payment);                // collect the money → Succeeded

The lifecycle is hosted_checkoutauthorized webhook lands RequiresCapturePayments::capture() (full or partial) → Succeeded → close-as-void (Payments::void()) / refund-post-close (Payments::refund()). Webhook verification is a static shared-secret header compare — NOT an HMAC (you choose the header name + value at registration; Tabby echoes them verbatim; an empty configured secret fails closed). An ineligible buyer is a clean decline (HTTP 200 + status: rejected → a Failed row, no exception). The unsigned browser return is confirmed by a server-side GET /payments/{id} retrieve (the payment id recovered from your ledger via a baked tabby_ref), collapsing onto the webhook's one dedupe slot. The ledger is single-axis (payment.id, never re-keyed). Everything Tabby does not do — payment_intent/authorize_capture (no server intent/authorize primitive), saved cards, connect — is disclaimed loudly via supports() + a boot-time ConfigValidator failure. The static-header verification, the required-reference_id idempotency, the currency allow-list, the 3-tier zero-code strategy, and the sandbox-verify Open Decisions are in docs/gateways/tabby.md.

Tamara (BNPL — KSA / UAE / Bahrain / Kuwait / Oman)

Tamara is the package's fifth and final real gateway (v2.5.0) — and the only one that required an additive shared-code change: the webhook-driven merchant-authorise seam. The customer finishes Tamara's hosted BNPL page (approved); then the merchant must fire POST /orders/{id}/authorise in reaction to the order_approved webhook (not the browser return) or the order wedges at approved and expires at 72h. The package delivers that as a post-commit, config-gated, queued reactor, after which the payment lands RequiresCapture. Like the other regional drivers it is additive and zero-new-dependency (the HS256 JWT verify is hand-rolled), so the Stripe path stays byte-identical.

php artisan payments:install --gateway=tamara   # writes the TAMARA_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.tamara block, then:
'flows' => [
    'pay_link_tamara' => [
        'gateway' => 'tamara',
        'strategy' => 'hosted_checkout',
        'params' => ['mode' => 'payment'],
    ],
],
$pending = Payments::flow('pay_link_tamara')
    ->charge(Money::of('300.00', 'SAR'))
    ->execute();

return redirect($pending->redirectUrl());   // → the Tamara hosted BNPL page

// later, on fulfillment:
Payments::capture($payment);                // collect the money → Succeeded

The lifecycle is hosted_checkoutorder_approved webhook lands Processing → the config-gated queued reactor (Listeners\AuthorizeApprovedOrder, on the existing PaymentProcessing event, gated by the per-gateway auto_authorise flag — default true) fires POST /orders/{id}/authoriseorder_authorised webhook lands RequiresCapturePayments::capture() (cumulative) → cancel-as-void / post-capture refund (increment). The reactor is a pure outbound command — it writes nothing to the ledger, so the webhook stays the sole applier and PaymentAuthorized fires exactly once. Webhook verification is a hand-rolled HS256 JWT (the tamaraToken, signed with your Notification Token; an empty secret fails closed; the token is header-only, so preserve Authorization at your web server). A queue worker is required (the reactor is queued) and ReconcilePendingPayments is the authorise / decline / 72h-expiry backstop. The ledger is single-axis (order_id, never re-keyed). Everything Tamara does not do — payment_intent/authorize_capture (its authorise is the webhook-driven side-interface, not the sync primitive), saved cards, connect — is disclaimed loudly via supports() + a boot-time ConfigValidator failure. The authorise seam, the single additive state-machine edge, the capture-cumulative/refund-increment asymmetry, and the sandbox-verify Open Decisions are in docs/gateways/tamara.md.

Tap (KNET / mada / GCC cards / Apple Pay)

Tap is the package's sixth real gateway (v2.7.0) — the cleanest additive card PSP so far: a hosted-page-first card gateway with real auth-then-capture, genuine card 3-D Secure, a native 24h idempotency window, and both webhook + browser-return confirmation. Like the other regional drivers it is additive and zero-new-dependency (it rides the shared HTTP seam), so the Stripe path stays byte-identical — and it needed zero shared-code changes (the frozen contract, NormalizedEvent, and the ingress are all untouched).

php artisan payments:install --gateway=tap   # writes the TAP_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.tap block, then:
'flows' => [
    'pay_link_tap' => ['gateway' => 'tap', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_tap' => ['gateway' => 'tap', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
$pending = Payments::flow('pay_link_tap')
    ->charge(Money::of('3.000', 'KWD'))   // MAJOR-unit decimals (KWD is 3-decimal)
    ->execute();

return redirect($pending->redirectUrl());   // → the Tap hosted card page (transaction.url)

The lifecycle is hosted_checkoutCAPTURED webhook lands Succeeded (or authorize_captureAUTHORIZED webhook lands RequiresCapturePayments::capture()Succeeded) → Payments::void() (cancel) / Payments::refund() (post-capture). Capture has no /capture route — it is a charge-on-authorize (POST /v2/charges with source.id = auth_…) followed by a required GET /v2/authorize/{id} for the cumulative. Webhook verification is the hashstring — an HMAC-SHA256 (hex) over an ordered x_<name>-labelled field concatenation with a per-currency decimal-formatted amount, signed with the SAME sk_… key that bearers every call (there is no separate webhook secret; an empty secret fails closed). Environment IS the key prefix (sk_test_ / sk_live_) on a single host. The unsigned browser return is confirmed by a server-side GET /v2/charges|/v2/authorize/{tap_id} retrieve (the tap_id is the gateway-side id on the query — no ledger coupling), collapsing onto the webhook's one dedupe slot. The ledger is single-axis per flow (chg_ / auth_, never re-keyed). NativeIdempotency (a real 24h reference.idempotent window) and ThreeDSecure (real card 3DS) are honestly true; everything Tap does not do — payment_intent (CreateIntent), and the Tap vault (cus_/card_/save_cardManageCustomers/OffSession/SetupIntent, DEFERRED to a Tap-native wave rather than force-fit onto the Stripe-vault interface) — is disclaimed loudly via supports() + a boot-time ConfigValidator failure. The hashstring amount-format trap, the capture-on-authorize wrinkle, the 3-tier zero-code, and the sandbox-verify Open Decisions are in docs/gateways/tap.md.

PayTabs (mada / cards — KSA / UAE / Egypt / GCC)

PayTabs is the package's seventh real gateway (v2.8.0) — the richest regional PSP so far: a hosted-page-first card gateway with real hosted checkout AND a real dedicated auth → capture (partial) → void → refund family, all on ONE endpoint (POST {region}/payment/request) with a switching tran_type. Like the other regional drivers it is additive and zero-new-dependency (it rides the shared HTTP seam), so the Stripe path stays byte-identical — and it needed zero shared-code changes (the frozen contract, NormalizedEvent, and the ingress are all untouched).

php artisan payments:install --gateway=paytabs   # writes the PAYTABS_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.paytabs block, then:
'flows' => [
    'pay_link_paytabs' => ['gateway' => 'paytabs', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_paytabs' => ['gateway' => 'paytabs', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
$pending = Payments::flow('pay_link_paytabs')
    ->charge(Money::of('200.00', 'SAR'))   // MAJOR-unit decimals (never minor)
    ->execute();

return redirect($pending->redirectUrl());   // → the PayTabs hosted card page (redirect_url)

The lifecycle is hosted_checkout → the sale callback lands Succeeded (or authorize_capture → the auth callback lands RequiresCapturePayments::capture()Succeeded) → Payments::void() (un-captured holds) / Payments::refund() (post-capture). Every money movement is POST /payment/request with a switching tran_type (sale/auth/capture/void/refund); capture does a required follow-up POST /payment/query for the cumulative captured total (the ledger overlays it — contrast refund(), which returns the increment). The make-or-break is the TWO HMAC-SHA256 schemes (one algorithm, two pre-images): the callback/IPN hashes the entire raw body vs the Signature header; the browser return hashes a rebuilt, sorted query string vs the signature field (drop-empties + ksort + http_build_query, quirks verbatim). Auth is the RAW Server Key in the authorization header (NOT Bearer) + profile_id in every body; the SAME Server Key signs BOTH channels (no separate webhook secret; an empty key fails closed on both). The region base_url is REQUIRED (no default) — the profile_id + Server Key are region-scoped and a mismatch surfaces as PayTabs' "(E): Authentication failed". PayTabs POSTs the browser return, so the opt-in stub 302-forwards it to the package's GET return route, which verifies the signature THEN retrieve-confirms via POST /payment/query, collapsing onto the webhook's one dedupe slot. The ledger is single-axis (tran_ref, never re-keyed). ThreeDSecure (real card 3DS) is honestly true; everything PayTabs does not do — payment_intent (CreateIntent), the bare card-token model (ManageCustomers / OffSession / SetupIntent, disclaimed rather than force-fit onto the Stripe-vault interface), and NativeIdempotency (no documented key) — is disclaimed loudly via supports() + a boot-time ConfigValidator failure. The two-scheme verification, the capture-cumulative / refund-increment asymmetry, the 3-tier zero-code, and the sandbox-verify Open Decisions are in docs/gateways/paytabs.md.

Amazon Payment Services (APS / ex-PayFort — cards / mada, Egypt / UAE / KSA / GCC)

APS is the package's eighth real gateway (v2.9.0) and its first form-POST-redirect PSP. Like the other regional drivers it is additive and zero-new-dependency (it rides the shared HTTP seam), so the Stripe path stays byte-identical — and it needed zero shared-code changes (the frozen contract, RedirectAction, ActionType, NormalizedEvent, and the ingress are all untouched; APS is merely the first producer of the long-existing ActionType::RedirectPost).

php artisan payments:install --gateway=aps   # writes the APS_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.aps block, then:
'flows' => [
    'pay_link_aps' => ['gateway' => 'aps', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_aps' => ['gateway' => 'aps', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
$pending = Payments::flow('pay_link_aps')->charge(Money::of('200.00', 'SAR'))->execute();

// APS returns a FORM-POST action, not a GET URL — render the shipped view (redirectUrl() is NOT enough):
return $pending->action()?->type === \Gulf\Payments\Enums\ActionType::RedirectPost
    ? view('payments::post-redirect', ['action' => $pending->action()])
    : redirect()->away($pending->redirectUrl());

The crux: initiation makes no API call — the driver locally builds a signed hidden-field set the browser must HTTP-POST to APS's …/FortAPI/paymentPage, so the next-action is a RedirectPost action rendered by the shipped payments::post-redirect view. The confirmation returns as two signed form-POSTs (the browser return + the server notification), both verified with the SHA RESPONSE phrase; maintenance ops (CAPTURE + a required CHECK_STATUS follow-up for the cumulative, full-only VOID_AUTHORIZATION, REFUND) are JSON POSTs to a different host. The make-or-break is the two-phrase signature: one plain-SHA algorithm (sort → k=v → wrap the phrase at BOTH ends → hash, NOT HMAC) keyed by two secrets — the REQUEST phrase signs outbound, the RESPONSE phrase verifies inbound; crossing them surfaces as message code 008. There is no auth header (APS authenticates per-message); amounts are minor-unit integers. The ledger is keyed on our merchant_reference and re-keyed to fort_id on the first confirmation (the Paymob alternate-reference mechanism). ThreeDSecure is honestly true; CreateIntent, ManageCustomers / OffSession / SetupIntent, and NativeIdempotency are disclaimed loudly. The two-phrase signature, the form-POST recipe, the capture-cumulative / refund-increment asymmetry, and the zero-code tiers are in docs/gateways/aps.md.

Fawry / FawryPay (Egypt — cards / wallets / PayAtFawry cash)

Fawry is the package's ninth real gateway (v2.10.0) — Egypt's dominant "pay-anywhere" network. Like the other regional drivers it is additive and zero-new-dependency (it rides the shared HTTP seam), so the Stripe path stays byte-identical — and it needed zero shared-code changes (the frozen contract, NormalizedEvent, PaymentStateMachine, and the ingress are all untouched; it reuses the payments::post-redirect view APS introduced).

php artisan payments:install --gateway=fawry   # writes the FAWRY_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.fawry block, then:
'flows' => [
    'pay_link_fawry' => ['gateway' => 'fawry', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_fawry' => ['gateway' => 'fawry', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
$pending = Payments::flow('pay_link_fawry')->charge(Money::of('250.00', 'EGP'))->execute();

// PayAtFawry returns NO redirect — the row sits at `processing`; show the cash reference:
if ($pending->status() === PaymentStatus::Processing && $pending->redirectUrl() === null) {
    $reference = $pending->result()->raw->get('referenceNumber');   // customer pays this at any Fawry outlet
}

The defining trait: a PayAtFawry checkout makes a server-to-server charge and returns no redirect — the row sits at the existing processing status for days until the customer pays cash against the referenceNumber at any Fawry outlet / ATM / app, and the Notification V2 webhook flips it to PAID (a state duration, not a new operation — no new PaymentStatus; schedule ReconcilePendingPayments, which polls and never abandons the row). The CARD branch instead returns a signed RedirectPost rendered by the shipped payments::post-redirect view; MWALLET mirrors the pending posture. The make-or-break is a plain secret-suffixed SHA-256 verify — NOT HMAC: hash('sha256', field-concatenation . secure_key); the secure_key is the auth (no bearer token), suffixed into every request and the webhook messageSignature (a BODY field). Amounts are major-unit 2dp EGP strings; the ledger is single-axis (keyed on merchant_reference, never re-keyed). void() is routed (an unpaid reference → cancel-unpaid-order; a card hold → payment/cancel); refund() resolves the Fawry referenceNumber first. ThreeDSecure is honestly true; CreateIntent, ManageCustomers / OffSession / SetupIntent, and NativeIdempotency are disclaimed loudly. The pending model, the plain-SHA signature (with the three traps and the worked example), the routed void, the capture-cumulative / refund-increment asymmetry, and the zero-code tiers are in docs/gateways/fawry.md.

PayPal (global — Orders v2 hosted approve-redirect)

PayPal is the package's tenth and final real gateway (v2.11.0) — the first truly global driver. Like the other regional drivers it is additive and zero-new-dependency (the OAuth mint, the business calls, and the RSA webhook verify ride the shared HTTP seam + ext-openssl + core crc32(), no SDK), so the Stripe path stays byte-identical — and it needed only one shared-code change: the pre-pinned, one-time move of the merchant-authorise reactor's Event::listen from TamaraServiceProvider::boot() into the core provider (mandated by the Tamara design for the SECOND SupportsOrderAuthorization adopter; behaviorally inert for every other gateway).

php artisan payments:install --gateway=paypal   # writes the PAYPAL_* env keys + setup notes
// config/payments.php — uncomment the shipped gateways.paypal block, then:
'flows' => [
    'pay_link_paypal' => ['gateway' => 'paypal', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_paypal' => ['gateway' => 'paypal', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
$pending = Payments::flow('pay_link_paypal')->charge(Money::of('100.00', 'USD'))->returnUrl($url)->execute();

return redirect()->away($pending->redirectUrl());   // the PayPal `approve` link

The two firsts: PayPal is the package's first OAuth Bearer credential — a client_id/ secret pair exchanged for a cached, short-lived token (PayPalTokenProvider, mirroring Paymob), with a single 401-refresh-retry on every call — and its first asymmetric webhook verify: there is no shared secret, so PayPalWebhookVerifier self-verifies offline (CRC32 of the raw body + SHA256withRSA against the cert at the host-pinned PAYPAL-CERT-URL, cached per-URL) and falls back to the authenticated POST /v1/notifications/verify-webhook-signature when it cannot produce a confident local verdict. The webhook ingress fails CLOSED without PAYPAL_WEBHOOK_ID (the anti-replay anchor of both paths). The defining lifecycle: an approved PayPal order is never captured by PayPal on its own — the package reacts to order_approved (Processing) by calling POST /orders/{id}/capture (or /authorize, per the order intent) from a queued reactor (a PURE outbound command, no ledger write — the later webhook is the sole applier), so a queue worker is required. The ledger is single-axis (keyed on the order id, never re-keyed; alternateReferences = []); amounts are major-unit decimal strings (HUF/TWD excluded by default — PayPal treats them as zero-decimal, and the driver refuses to silently round); capture() returns the cumulative captured total while refund() returns the increment. ThreeDSecure is honestly true (PayPal's own SCA); CreateIntent, OffSession, ManageCustomers / SetupIntent, and NativeEventDedup are disclaimed loudly. The OAuth token provider, the asymmetric verify (with the host-pin + fallback), the reactor reuse, the single-axis order-id model, the HUF/TWD trap, and the capture/refund asymmetry are in docs/gateways/paypal.md.

Architecture

  • Drivers expose primitives, not business verbs. The product thesis is orchestration; a purchase() driver would pre-empt decisions the engine must own.
  • Flows compose primitives. Named PHP strategy classes selected by config — correctness lives in typed, tested PHP, never a config DSL.
  • One PaymentStatus enum + one state machine. Every driver-native status maps onto it, and the transition table covers every case. Succeeded is deliberately non-terminal so refunds and disputes can transition.
  • Money is brick/money, wrapped in Gulf\Payments\Data\Money, so 3-decimal Gulf currencies and zero-decimal currencies are always correct — no hand-rolled exponent tables.
  • An owned payments ledger with an amount ledger (amount_authorized/captured/refunded) behind a swappable PaymentRepository, plus a payment_webhook_events dedupe table, and twelve opt-in modules layered on with additive, nullable columns/tables — never a breaking migration.
  • DB-unique idempotency is the source of truth — a single-column unique index on idempotency_key (tenant-namespaced by the manager), with per-attempt keys for off-session retries.
  • Config supplies data, never logic. Presets are a merge floor, not generated code; payments:config:diff / payments:make:* only ever print — they never silently rewrite your config or providers.

Deferred / not in v2

The architecture supports these; they are intentionally deferred rather than half-shipped. See docs/known-limitations.md for the full list plus the operational caveats of shipped features (off-module events consume the dedupe slot with no dispute/payout backfill yet; a Processing off-session renewal waits for a Succeeded observation before advancing the schedule).

Vault card-detail enrichment and first-class Connect marketplace / split payments were on this list in v1 — both shipped in v2.0.0 (Waves 2 and 4–5) and are documented above under Customers & the payment-method vault and Connected accounts.

  • Real PayPal / Gulf-PSP (Tap/PayTabs/Moyasar) drivers. docs/gateways/ are fit-matrix design docs proving the frozen GatewayDriver contract needs no new primitive for either — the driver classes themselves are a later (or community) contribution.
  • payments:backfill-disputes / payments:backfill-payouts. The refunds backfill precedent (retained webhook payloads → opt-in table) generalizes directly; not yet built.
  • A gateway-agnostic transfer abstraction across a second PSP. The Stripe Connect money-movement surface is first-class today; a portable cross-gateway transfer contract waits until a second money-movement PSP exists to prove the shape isn't Stripe-Connect-specific.
  • FX / multi-currency settlement columns. Every amount is stored in its charged currency; a presented-vs-settled FX pair is future work.
  • A Tier-B (double-entry) ledger. The opt-in ledger module today is a Tier-A transaction audit trail (payment_transactions) alongside the aggregate payments ledger, not full double-entry bookkeeping.
  • Vue / Livewire variants of the frontend confirm kit — the framework-free JS module + Blade component (and the opt-in Stripe.js adapter) ship today.

License

MIT — see LICENSE.md.