gulf/payments
Composer 安装命令:
composer require gulf/payments
包简介
Configuration-driven, multi-gateway payment orchestration for Laravel. Drivers expose primitives; flows compose them.
README 文档
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,
and Tamara (BNPL — KSA / UAE / Bahrain / Kuwait / Oman) is #5 — 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:
-
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_secretby uuid):// config/payments.php 'http' => ['status_endpoint' => [ 'enabled' => true, 'middleware' => ['web', 'auth', 'can:view,payment'], ]],
Details: Status endpoint & frontend confirm kit.
-
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-instripe-confirm.jsadapter — which binds Stripe'sconfirmCardPayment/confirmCardSetupto the events the gateway-agnostic kit emits (setdata-stripe-keyto yourpk_...). -
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.phpand appends its route toroutes/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 readyPayments::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 registersPOST /payments/webhook/stripefor 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.
- Requirements
- Installation
- Quick start — your first payment is Stripe
- The five flows
- The fluent builder reference
- Manager operations
- Business presets
- The opt-in universal schema
- Model traits & scopes
- Subscriptions / recurring billing
- Configuration reference
- Webhooks & return-URL ingress
- Status endpoint & frontend confirm kit
- Scheduling
- Events
- Testing
- Command reference
- Extending — adding a gateway
- Architecture
- Deferred / not in v2
Deeper guides live under docs/: 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 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:
-
Publishes
config/payments.phpand the core migrations (2 tables:payments,payment_webhook_events) — idempotent, safe to re-run. -
Shows a multiselect of every registered gateway. Select Stripe.
-
Appends the missing
STRIPE_*keys to your.envand.env.exampleinside a marked block. -
If
stripe/stripe-phpis not installed, prints the exact line to run — the package never shells out to composer itself:composer require stripe/stripe-php:^16.0
-
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 synchronousSucceeded; otherwise the gateway hands back aRequiresAction/Processingstate to finish client-side.manual/webhook/poll— create the intent and return theclient_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 partialMoney) →Succeeded, firesPaymentCaptured(and theonCapturesugar). - Auto-void: the scheduled
ExpireHeldPaymentsjob voids anyAuthorized/RequiresCapturerow pastexpires_at→Canceled, firesPaymentCanceled.
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 firesPaymentBalanceCharged. - Auto-settle: the scheduled
SettleDueBalancesjob settles any row pastbalance_due_at. - A hard off-session decline records a linked
Failedchild row (firesPaymentFailed) and rethrows — the deposit itself stands and the balance is left intact for retry, up toconfig('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()→ Redirect → checkout.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 (freshuuid7, 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_refundedback through the transitioner — the sole ratified breaking change in v2.0.0 adds three state-machine reversal edges so a fully-reversed refund returns tosucceeded. - Degrade-to-primitive: a driver without
SupportsRefundManagementfalls back to the frozenrefund()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. Thecustomer.*/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 stream —
POST {prefix}/{gateway}/connect, verified againstSTRIPE_CONNECT_WEBHOOK_SECRET(distinct from the main secret) and registered only when that secret is non-empty.account.updated/account.application.deauthorizedare a claim-first side branch that never touches the transitioner. The storedevent_idis acct-prefixed ("$acct:$evt") so the sameevt_id under two accounts never collides — zero core migration. ConnectedAccountReadyfires exactly once on thecharges && payoutsfalse→trueflip (redelivery-safe, no marker column). AlsoCreated/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-inconnected_accountsmodule column) so capture/void/refund/retrieve/reconcile replay theStripe-Accountheader; the charge-creation idempotency key is acct-dimensioned. Refunding a direct charge viaPayments::refunds()->of($uuid)->execute()replays the header (the frozenrefund()primitive is untouched). - Boot-time gate: a flow that carries a
connectblock fails on deploy if the driver disclaimsCapability::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_reversedfloor: an out-of-ordertransfer.updatedcannot regress the stored reversal total, un-reverseda row, or fire a spuriousTransferReversed. - Refund-on-connect matrix (
Payments::refunds()): direct →stripe_accountheader +refund_application_fee; destination (->reverseTransfer()) →reverse_transfer+refund_application_feeon the platform; separate/plain → a plain refund.->refundApplicationFee()is tri-state — unset takes the matrix default, an explicitfalseis respected. Clawing back a separate transfer isconnect()->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 vialistPayouts()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:
Payments::flow('subscription')->charge(...)->recurEvery('P1M')->execute()charges the first period off-session-saving the PM and, when thesubscriptionsmodule is installed, creates apayment_subscriptionsrow anchored to that payment.- The scheduled
SettleDueSubscriptionsjob (or an explicitPayments::chargeSubscription($uuid)) off-session-charges the next period as a normalpaymentsrow parented to the anchor — refund/void/dispute work on it unchanged. - 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_attemptbumped) re-attempts the card. - On a hard decline, the ladder advances:
past_due,retry_attempt++, untilconfig('payments.settlement.max_attempts')is reached (or a hard decline code short-circuits it viasettlement.hard_decline_is_terminal), at which point the subscription iscanceledandPaymentBalanceSettlementExhaustedfires. The same ceiling applies todeposit_balancebalance-charge retries once thesubscriptionsmodule'ssettle_attemptscolumn 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/webhook → POST 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 not —
webhooks.enabled.trueregistersPOST /payments/webhook/{gateway}for terminal-state confirmations.falseremoves the route entirely; theReconcilePendingPaymentsschedule (poll) then drives confirmation. Per-flow you can also setparams.confirmationtoautomatic | manual | webhook | poll. - Idempotency or not —
idempotency.enabled.trueauto-mints a key when you supply none.falsedoes not disable dedup: an app-supplied key is still honoured, and the single-column DB unique index onidempotency_keyalways 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
400and 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
202and 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— voidsbook_pay_laterholds pastexpires_at.SettleDueBalances— off-session-chargesdeposit_balancerows pastbalance_due_at.ReconcilePendingPayments— pollsretrieve()for rows stuck in an early non-terminal state pastidempotency.reuse_window; this is what makes the webhook-before-initiate202self-heal even without a redelivery.SettleDueSubscriptions— off-session-charges the next period of every active/past-due subscription pastnext_charge_at. A no-op (zero queries) when thesubscriptionsmodule is off;auto_registeronly schedules it when the module's flag is enabled.RemindDisputeEvidence— dispatchesDisputeEvidenceDuefor disputes whoseevidence_due_atfalls within a 24-hour window. A no-op when thedisputesmodule is off;auto_registeronly 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 distinctsave_cardevent — neverPaymentSucceeded). - 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 synchronouspay_nowconfirmation). 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:
- Write a driver implementing
Gulf\Payments\Contracts\GatewayDriver— the primitives (createIntent,createCheckoutSession,authorize,capture,void,refund,retrieve,parseWebhook) plussupports(Capability). Returnfalsefromsupports()for anything the gateway can't do; a flow that needs a missing capability fails at boot viaConfigValidator, not on the first charge. ExtendGulf\Payments\Gateways\AbstractGatewayfor a driver that only needs to implement the primitives it actually supports — every other primitive already throwsUnsupportedCapabilityfor you, and the base mixes in HTTP/retry, money-conversion and idempotency-forwarding plumbing (StripeGatewayitself predates it and is not required to extend it). - Add a config block under
gateways, and reference it from aflowsentry. - (Optional) ship an
Installablesopayments:installcan scaffold env keys and the SDK instruction. - Prove it against
Gulf\Payments\Testing\DriverConformanceTest— an inherited CI checklist (capability honesty, status-mapping totality,parseWebhook()determinism,RedirectActionwell-formedness, and the critical reference round-trip: the referenceinitiate()stores must be matchable byparseWebhook()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
Five 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 |
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_checkout → authorized webhook lands RequiresCapture →
Payments::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_checkout → order_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}/authorise → order_authorised webhook lands
RequiresCapture → Payments::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.
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
PaymentStatusenum + one state machine. Every driver-native status maps onto it, and the transition table covers every case.Succeededis deliberately non-terminal so refunds and disputes can transition. - Money is
brick/money, wrapped inGulf\Payments\Data\Money, so 3-decimal Gulf currencies and zero-decimal currencies are always correct — no hand-rolled exponent tables. - An owned
paymentsledger with an amount ledger (amount_authorized/captured/refunded) behind a swappablePaymentRepository, plus apayment_webhook_eventsdedupe 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 frozenGatewayDrivercontract 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
ledgermodule today is a Tier-A transaction audit trail (payment_transactions) alongside the aggregatepaymentsledger, 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.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-12