omalizadeh/laravel-multi-payment
Laravel Multi Payment offers a unified, driver-based API for integrating multiple payment gateways into Laravel applications. Configure providers, initiate payments, verify callbacks, and manage transactions through one consistent interface.
Installation
composer require omalizadeh/laravel-multi-payment
php artisan vendor:publish --tag=payment-config
The published config/payment.php is the single source of truth: it defines the default gateway, per‑gateway credentials (merchant ID, terminal ID, API keys, sandbox flags), and the list of enabled drivers. Read this file first — it tells you exactly which gateways exist in the version you installed.
First look at the source tree
src/Payment/ — the orchestrator that holds amount, order ID, callback URL, gateway selection.src/Gateways/ — one class per provider (Zarinpal, PayIR, IDPay, NextPay, Sadad, Saman, Parsian, Pasargad, Mellat, AsanPardakht, and now Paystar as of v3.1.0).src/Contracts/ — the gateway contract; read this to understand what a driver must implement (purchase, verify, pay/redirect).src/Exceptions/ — GatewayNotFoundException, PaymentFailedException, InvalidConfigurationException and friends. Catch these, not \Exception.First use case — a single redirect checkout
use Omalizadeh\MultiPayment\Facades\Payment;
$payment = Payment::gateway('zarinpal')
->callbackUrl(route('payment.callback'))
->amount(50000) // Rial, see Gotchas
->orderId($order->id)
->purchase();
return $payment->pay(); // returns a redirect View/Response to the gateway
Then a callback route:
Route::get('/payment/callback/{gateway}', function (string $gateway) {
$transaction = Payment::gateway($gateway)->verify();
if ($transaction->isSuccessful()) {
// mark order paid, using $transaction->getReferenceId()
}
return redirect()->route('orders.show');
})->name('payment.callback');
Because the package is driver‑based, everything you learn on Zarinpal transfers verbatim to Paystar (or any other provider) — the only thing that changes is the string passed to gateway() and the credentials block in config.
Config‑driven gateway selection (recommended default)
Never hardcode 'zarinpal' in controllers. Store the chosen gateway on the order or read it from config('payment.default'), then pass it through:
$gateway = $order->gateway ?? config('payment.default');
Payment::gateway($gateway)->callbackUrl(...)->purchase(...);
This makes the new Paystar driver a one‑line config change rather than a refactor.
One service class per concern, not per gateway
Wrap the package in a PaymentService that owns purchase + verify. Because the driver is resolved at runtime, you do not need a separate service per provider — resist the temptation to build ZarinpalService and PaystarService, that's the abstraction the package already gives you.
final class PaymentService
{
public function start(Order $order): RedirectResponse
{
return Payment::gateway($order->gateway)
->callbackUrl(route('payment.callback', $order->gateway))
->amount($order->amount_rial)
->orderId((string) $order->id)
->purchase()
->pay();
}
public function finish(string $gateway): Transaction
{
return Payment::gateway($gateway)->verify();
}
}
Persist the gateway on the record before redirecting
The callback URL is the only context the gateway gives you back. Encode the gateway in the callback route (/payment/callback/{gateway}) and store it on the order row. Belt and braces — if someone tampers with the segment you can reject it against the stored value.
Verification is the only source of truth
Never trust ?status=OK or the Authority/token query parameter alone. Only the result of verify() commits the order. Structure the callback as: verify → check isSuccessful() → idempotency guard (has this referenceId already been recorded?) → mark paid.
Multi‑gateway fallback / A‑B routing
For Iranian PSPs, downtime is real. A lightweight pattern:
foreach ([$primary, $fallback] as $gateway) {
try {
return $this->start(new PaymentContext($gateway));
} catch (PaymentFailedException $e) {
report($e);
}
}
Keep fallback logic in your app layer, not in config — the package doesn't (and shouldn't) guess your business tolerance for switching PSPs mid‑order.
Adding a custom gateway
The contract‑based design is the extension point. Extend the abstract gateway, implement the required methods, register the driver key in config/payment.php, and it behaves identically to the bundled ones. This is also how you'd vendor‑fork Paystar if you need a parameter the driver doesn't expose yet.
Testing
Don't hit sandbox endpoints in a test suite. Either (a) enable each gateway's sandbox flag in config/payment.php under phpunit.xml env overrides, or (b) mock the orchestrator: bind a fake into the container and assert on the fluent calls (amount, orderId, callbackUrl). Assert your domain outcomes — order marked paid, idempotency respected — rather than the package's internals.
Amounts are in Rial, not Toman. This is the single most common production bug with every Iranian gateway package. If your UI shows Toman, multiply by 10 once, at the boundary, and name the column amount_rial so nobody re‑converts. Some drivers convert internally and some don't — check the driver before assuming.
Config caching will bite you. php artisan config:cache in deploy is expected, but changing credentials in .env afterwards does nothing until you recache. If a gateway suddenly "rejects the merchant", check the cached config first.
verify() is not idempotent by default. Gateways will re‑deliver callbacks (user hits refresh, browser retries). Wrap verification in a DB transaction with a unique index on the reference/transaction ID, or you'll double‑fulfil orders. This is app responsibility, not package responsibility.
Callback URL must exactly match the registered one. Trailing slashes, http vs https, and www vs apex can all cause a rejection at the gateway before your code ever runs. Generate it with route() and pin APP_URL in production.
Order ID types. Some drivers cast order IDs to strings and some gateways reject non‑numeric or over‑long values (Mellat in particular has tight limits). Pass (string) $order->id and keep IDs short/numeric.
Exceptions are thrown, not returned. A failed verify() throws PaymentFailedException; a mistyped gateway key throws GatewayNotFoundException (which is a config error, not a user error — log it at error, return 500). Return a friendly "payment failed" page only for the former.
Sandbox flags live in three places. Per‑gateway config, sometimes an env var, and sometimes an argument on purchase(). Grep the specific driver for sandbox before debugging — the source is authoritative, the README usually lags.
The Paystar addition (v3.1.0) is purely additive. No signature changes, no config keys removed, no deprecations — upgrading from v3.0.0 is a composer update with no migration. But note that any config file you've published previously won't contain the new paystar block; re‑publish the config (vendor:publish --tag=payment-config --force) or hand‑add the stanza, otherwise Payment::gateway('paystar') will fail on missing credentials.
Driver parity is not guaranteed. Each gateway has its own quirks (reference ID field names, supported currencies, refund support). The uniform interface is a lowest common denominator: getReferenceId() may be null on some drivers, refunds aren't universal, and error messages are whatever the PSP returned in Persian. Normalise them in your service layer if you surface them to users.
Version 3 is Laravel 9+ / PHP 8.1+. If you're on an older codebase, v3 won't install — and the v2→v3 jump did include breaking API changes, so pinning is wise if you maintain multiple apps on different Laravel majors.
Logging. Set a dedicated channel for payment traffic; the package itself logs sparingly. Log the request payload (minus credentials), the raw callback query, and the verify response — PSP disputes are unwinnable without the raw strings.
How can I help you explore Laravel packages today?