Download the PHP package gulf/payments without Composer

On this page you can find all versions of the php package gulf/payments. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package payments

gulf/payments

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

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

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

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

Zero-code Stripe in 5 minutes

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

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

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:

Point your Stripe webhook at POST /payments/webhook/stripe and copy its signing secret into STRIPE_WEBHOOK_SECRET. Everything else — every option, 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):

    Details: Status endpoint & frontend confirm kit.

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

    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:

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

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

Table of contents

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

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

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

Requirements

Installation

Then run the guided installer:

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:

  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:

Finally run the migrations to create the ledger:

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.

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:

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.

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

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.

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

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:

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

Manager operations

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

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:

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.

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():

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.

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.

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.

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

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:

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.

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.

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:

Webhooks & return-URL ingress

A single package route ingests every gateway callback:

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

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:

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:

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

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

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:

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

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

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.

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:

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:

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

Gateways

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

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

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

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

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

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

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

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

Paymob (Egypt / MENA)

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

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.

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.

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.

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.

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

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

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

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

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

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

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

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

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

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

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

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

PayPal (global — Orders v2 hosted approve-redirect)

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

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

Architecture

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.

License

MIT — see LICENSE.md.


All versions of payments with dependencies

PHP Build Version
Package Version
Requires php Version ^8.3
illuminate/support Version ^11.0 || ^12.0 || ^13.0
illuminate/contracts Version ^11.0 || ^12.0 || ^13.0
brick/money Version ^0.9 || ^0.10
psr/log Version ^3
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package gulf/payments contains the following files

Loading the files please wait ...