PHPackages                             gulf/payments - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. gulf/payments

ActiveLibrary[Payment Processing](/categories/payments)

gulf/payments
=============

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

v2.11.3(1mo ago)040MITPHPPHP ^8.3CI failing

Since Jul 12Pushed 1mo agoCompare

[ Source](https://github.com/ziadadel001/laravel_Gulf_payments)[ Packagist](https://packagist.org/packages/gulf/payments)[ RSS](/packages/gulf-payments/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (12)Versions (6)Used By (0)

gulf/payments
=============

[](#gulfpayments)

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](#gateways)).

Zero-code Stripe in 5 minutes
-----------------------------

[](#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-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](#the-five-flows), [option](#configuration-reference), [preset](#business-presets), and [schema module](#the-opt-in-universal-schema) — is reachable the same way: config + facade, no app code. Full detail lives in [Installation](#installation) and [Quick start](#quick-start--your-first-payment-is-stripe).

### Browser checkout prerequisites (optional)

[](#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 &amp; frontend confirm kit](#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 `` 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
-----------------

[](#table-of-contents)

**New here?** [Zero-code Stripe in 5 minutes](#zero-code-stripe-in-5-minutes) is the fastest path.

1. [Requirements](#requirements)
2. [Installation](#installation)
3. [Quick start — your first payment is Stripe](#quick-start--your-first-payment-is-stripe)
4. [The five flows](#the-five-flows)
5. [The fluent builder reference](#the-fluent-builder-reference)
6. [Manager operations](#manager-operations)
7. [Business presets](#business-presets)
8. [The opt-in universal schema](#the-opt-in-universal-schema)
9. [Model traits &amp; scopes](#model-traits--scopes)
10. [Subscriptions / recurring billing](#subscriptions--recurring-billing)
11. [Configuration reference](#configuration-reference)
12. [Webhooks &amp; return-URL ingress](#webhooks--return-url-ingress)
13. [Status endpoint &amp; frontend confirm kit](#status-endpoint--frontend-confirm-kit)
14. [Scheduling](#scheduling)
15. [Events](#events)
16. [Testing](#testing)
17. [Command reference](#command-reference)
18. [Extending — adding a gateway](#extending--adding-a-gateway)
19. [Architecture](#architecture)
20. [Deferred / not in v2](#deferred--not-in-v2)

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

Requirements
------------

[](#requirements)

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

Installation
------------

[](#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](#the-opt-in-universal-schema)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=
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

[](#stripe-credentials)

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

Env keyWhere 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](#connected-accounts-paymentsconnect).Optional: `STRIPE_API_VERSION` (defaults to the driver's pinned version) and `STRIPE_TIMEOUT` (seconds, default `30`).

Quick start — your first payment is Stripe
------------------------------------------

[](#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
--------------

[](#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/`](docs/flows/).

### `pay_now` — charge now

[](#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`](docs/flows/pay-now.md).

### `book_pay_later` — hold now, capture or auto-void later

[](#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_at` → `Canceled`, fires `PaymentCanceled`.

Full guide: [`docs/flows/book-pay-later.md`](docs/flows/book-pay-later.md).

### `deposit_balance` — deposit now, charge the balance later

[](#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](#subscriptions--recurring-billing)).

Full guide: [`docs/flows/deposit-balance.md`](docs/flows/deposit-balance.md).

### `hosted_checkout` — redirect to a gateway-hosted page

[](#hosted_checkout--redirect-to-a-gateway-hosted-page)

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

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

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

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

### `subscription` — recurring / off-session renewals

[](#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](#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
----------------------------

[](#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()`.

MethodPurpose`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](#gateway-options-passthrough).`onCapture(callable)` / `onExpiry(callable)` / `onSucceeded(callable)` / `onFailed(callable)` / `onBalanceCharged(callable)`Process-local, payment-scoped one-off listener sugar (see [Events](#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

[](#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
------------------

[](#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

[](#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 (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`](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](#subscriptions--recurring-billing).

### Customers &amp; the payment-method vault

[](#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
```

- **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`](docs/customers.md).

### Save a card (`save_card`)

[](#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`](docs/save-card.md).

### Connected accounts (`Payments::connect()`)

[](#connected-accounts-paymentsconnect)

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

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

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

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

Payments::connect()->syncAccount($account->reference);   // force retrieve + upsert
Payments::connect()->rejectAccount($ref, 'fraud');       // reason validated: {fraud, terms_of_service, other}
Payments::connect()->listAccounts(new ConnectAccountQuery); // Collection (local-first when the module is on)
```

- **Separate connect webhook stream** — `POST {prefix}/{gateway}/connect`, verified 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)

[](#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)

[](#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()`): **direct** → `stripe_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/onboarding.md), [`docs/connect/webhooks.md`](docs/connect/webhooks.md), [`docs/connect/choosing-a-charge-model.md`](docs/connect/choosing-a-charge-model.md), [`docs/connect/money-movement.md`](docs/connect/money-movement.md), [`docs/connect/refunds-on-connect.md`](docs/connect/refunds-on-connect.md).

Business presets
----------------

[](#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=` only prints the fragment it would apply, as a copy/paste reminder.

PresetKeyWhat it doesE-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-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.')` 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.

ModuleFlag / envTableWhat it addsCustomers`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 &amp; scopes
-------------------------

[](#model-traits--scopes)

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

TraitUse onGives you`Concerns\HasPayments`Any subject model (Order, Booking, ...)Read-only: `payments()` (`MorphMany`), `payment()` (latest row), `paymentStatus()`, `isPaid()`, `isPaymentPending()`, `totalPaid()`, `totalRefunded()`.`Concerns\Payable`Any subject modelEverything 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 modelRoutes 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
---------------------------------

[](#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::p:r`) 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
-----------------------

[](#configuration-reference)

`config/payments.php` is the single source of truth. Full annotation: [`docs/configuration.md`](docs/configuration.md).

KeyMeaning`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](#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](#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 &amp; frontend confirm kit](#status-endpoint--frontend-confirm-kit)).`gateways.stripe`Stripe driver class + credentials (all from env).`flows.*`Each binds a `gateway` + `strategy` + data-only `params`.**The two toggles the product owner asked for:**

- **Webhook or not** — `webhooks.enabled`. `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 not** — `idempotency.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 &amp; return-URL ingress
---------------------------------

[](#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`](docs/webhooks.md).

### Return-URL ingress (redirect-first gateways)

[](#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 &amp; frontend confirm kit
------------------------------------------

[](#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`](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:

```

```

```
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
----------

[](#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
------

[](#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 &amp; vault:** `CustomerCreated`, `CustomerUpdated`, `CustomerDeleted`, `PaymentMethodAttached`, `PaymentMethodDetached`, `PaymentMethodUpdated`, `CardSaved` (the distinct `save_card` event — never `PaymentSucceeded`).
- **Connect &amp; 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
-------

[](#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`](docs/testing.md).

Command reference
-----------------

[](#command-reference)

CommandPurpose`payments:install`Publish core config + migrations, install gateways, publish optional schema modules, print preset guidance. See [Installation](#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=`, `--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
----------------------------

[](#extending--adding-a-gateway)

A gateway is "the class named in `config('payments.gateways..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/`](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
--------

[](#gateways)

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

GatewayRegionsInitiationFollow-up opsConfirmationGuide**Stripe**Global`payment_intent`, `hosted_checkout`, `authorize_capture`, `save_card`, Connectcapture / 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`](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`](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`](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`](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`](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`](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`](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`](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`](docs/gateways/paypal.md)### Install one gateway, several, or all — and what each needs

[](#install-one-gateway-several-or-all--and-what-each-needs)

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

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

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

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

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

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

GatewayExtra Composer packageEnv 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 returnTest 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-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`](docs/gateways/paymob.md).

### MyFatoorah (Kuwait / GCC / MENA)

[](#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`](docs/gateways/myfatoorah.md).

### Tabby (BNPL — KSA / UAE / Kuwait)

[](#tabby-bnpl--ksa--uae--kuwait)

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

```
php artisan payments:install --gateway=tabby   # writes the TABBY_* env keys + setup notes
```

```
// config/payments.php — uncomment the shipped gateways.tabby block, then:
'flows' => [
    'pay_link_tabby' => [
        'gateway' => 'tabby',
        'strategy' => 'hosted_checkout',
        'params' => ['mode' => 'payment'],
    ],
],
```

```
$pending = Payments::flow('pay_link_tabby')
    ->charge(Money::of('300.00', 'SAR'))
    ->execute();

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

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

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

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

[](#tamara-bnpl--ksa--uae--bahrain--kuwait--oman)

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

```
php artisan payments:install --gateway=tamara   # writes the TAMARA_* env keys + setup notes
```

```
// config/payments.php — uncomment the shipped gateways.tamara block, then:
'flows' => [
    'pay_link_tamara' => [
        'gateway' => 'tamara',
        'strategy' => 'hosted_checkout',
        'params' => ['mode' => 'payment'],
    ],
],
```

```
$pending = Payments::flow('pay_link_tamara')
    ->charge(Money::of('300.00', 'SAR'))
    ->execute();

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

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

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

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

[](#tap-knet--mada--gcc-cards--apple-pay)

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

```
php artisan payments:install --gateway=tap   # writes the TAP_* env keys + setup notes
```

```
// config/payments.php — uncomment the shipped gateways.tap block, then:
'flows' => [
    'pay_link_tap' => ['gateway' => 'tap', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_tap' => ['gateway' => 'tap', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
```

```
$pending = Payments::flow('pay_link_tap')
    ->charge(Money::of('3.000', 'KWD'))   // MAJOR-unit decimals (KWD is 3-decimal)
    ->execute();

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

The lifecycle is `hosted_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_`-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`](docs/gateways/tap.md).

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

[](#paytabs-mada--cards--ksa--uae--egypt--gcc)

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

```
php artisan payments:install --gateway=paytabs   # writes the PAYTABS_* env keys + setup notes
```

```
// config/payments.php — uncomment the shipped gateways.paytabs block, then:
'flows' => [
    'pay_link_paytabs' => ['gateway' => 'paytabs', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_paytabs' => ['gateway' => 'paytabs', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
```

```
$pending = Payments::flow('pay_link_paytabs')
    ->charge(Money::of('200.00', 'SAR'))   // MAJOR-unit decimals (never minor)
    ->execute();

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

The lifecycle is `hosted_checkout` → the sale callback lands **`Succeeded`** (or `authorize_capture` → the auth callback lands **`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`](docs/gateways/paytabs.md).

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

[](#amazon-payment-services-aps--ex-payfort--cards--mada-egypt--uae--ksa--gcc)

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

```
php artisan payments:install --gateway=aps   # writes the APS_* env keys + setup notes
```

```
// config/payments.php — uncomment the shipped gateways.aps block, then:
'flows' => [
    'pay_link_aps' => ['gateway' => 'aps', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_aps' => ['gateway' => 'aps', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
```

```
$pending = Payments::flow('pay_link_aps')->charge(Money::of('200.00', 'SAR'))->execute();

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

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

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

[](#fawry--fawrypay-egypt--cards--wallets--payatfawry-cash)

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

```
php artisan payments:install --gateway=fawry   # writes the FAWRY_* env keys + setup notes
```

```
// config/payments.php — uncomment the shipped gateways.fawry block, then:
'flows' => [
    'pay_link_fawry' => ['gateway' => 'fawry', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_fawry' => ['gateway' => 'fawry', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
```

```
$pending = Payments::flow('pay_link_fawry')->charge(Money::of('250.00', 'EGP'))->execute();

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

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

### PayPal (global — Orders v2 hosted approve-redirect)

[](#paypal-global--orders-v2-hosted-approve-redirect)

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

```
php artisan payments:install --gateway=paypal   # writes the PAYPAL_* env keys + setup notes
```

```
// config/payments.php — uncomment the shipped gateways.paypal block, then:
'flows' => [
    'pay_link_paypal' => ['gateway' => 'paypal', 'strategy' => 'hosted_checkout', 'params' => ['mode' => 'payment']],
    'pay_auth_paypal' => ['gateway' => 'paypal', 'strategy' => 'authorize_capture', 'params' => ['capture' => 'manual']],
],
```

```
$pending = Payments::flow('pay_link_paypal')->charge(Money::of('100.00', 'USD'))->returnUrl($url)->execute();

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

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

Architecture
------------

[](#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
--------------------

[](#deferred--not-in-v2)

The architecture supports these; they are intentionally deferred rather than half-shipped. See [`docs/known-limitations.md`](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 &amp; the payment-method vault](#customers--the-payment-method-vault)and [Connected accounts](#connected-accounts-paymentsconnect).

- **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
-------

[](#license)

MIT — see [LICENSE.md](LICENSE.md).

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance90

Actively maintained with recent releases

Popularity8

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity52

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

48d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/a3055d91e0fa79a98bbefca3b63afdb4c22b65d0378dd22026cbfb098d80ecfd?d=identicon)[ziadadel001](/maintainers/ziadadel001)

---

Top Contributors

[![ziadadel001](https://avatars.githubusercontent.com/u/97390071?v=4)](https://github.com/ziadadel001 "ziadadel001 (28 commits)")

---

Tags

laravelstripepaymentspayment gatewayorchestrationmyfatoorahtamaramadabnpltappaymobtabbyknetbrick-moneygcc

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/gulf-payments/health.svg)

```
[![Health](https://phpackages.com/badges/gulf-payments/health.svg)](https://phpackages.com/packages/gulf-payments)
```

###  Alternatives

[illuminate/broadcasting

The Illuminate Broadcasting package.

7227.7M254](/packages/illuminate-broadcasting)[illuminate/log

The Illuminate Log package.

6426.0M716](/packages/illuminate-log)[psalm/plugin-laravel

Psalm plugin for Laravel

3365.5M359](/packages/psalm-plugin-laravel)[illuminate/mail

The Illuminate Mail package.

6410.9M612](/packages/illuminate-mail)[illuminate/database

The Illuminate Database package.

3.0k56.7M13.6k](/packages/illuminate-database)[laravel/ai

The official AI SDK for Laravel.

1.1k6.4M360](/packages/laravel-ai)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
