承接 gulf/payments 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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:

  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/: 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:

  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

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_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.

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.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 1
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-07-12

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固