Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Multipay Laravel Package

shetabit/multipay

Multipay is a Laravel package that unifies multiple payment gateways behind one clean API, letting you create payment requests, redirect users, verify callbacks, and manage transactions without rewriting code when you switch providers.

Deep Wiki
Context7

Getting Started

Install via Composer; Laravel's package discovery wires up the service provider and the Payment facade automatically.

composer require shetabit/multipay
php artisan vendor:publish --provider="Shetabit\Payment\PaymentServiceProvider"

That drops config/payment.php into your app. Open it first — it's the whole mental model of the package: a default driver name plus a drivers array where each gateway (Zarinpal, Pay.ir, IDPay, Nextpay, Saman, Parsian, Pasargad, Sadad, Mellat, Asan Pardakht, etc.) has its own credential keys and a callbackUrl.

// config/payment.php
return [
    'default' => env('PAYMENT_DRIVER', 'zarinpal'),
    'drivers' => [
        'zarinpal' => [
            'merchantId'  => env('ZARINPAL_MERCHANT_ID'),
            'callbackUrl' => env('APP_URL').'/payment/callback',
            'mode'        => env('ZARINPAL_MODE', 'normal'), // 'sandbox' for dev
        ],
    ],
];

Then the canonical happy path — build an Invoice, purchase, redirect the user, verify on return:

use Shetabit\Multipay\Invoice;
use Shetabit\Payment\Facade\Payment;

// 1. Purchase: returns something you can render (auto-submitting form or redirect)
$invoice = (new Invoice)->amount(50000); // amount is in the gateway's base unit (Rial)

return Payment::purchase($invoice, function ($driver, $transactionId) use ($order) {
    $order->update(['gateway' => $driver, 'transaction_id' => $transactionId]);
})->pay()->render();
// 2. Callback route (no auth, no CSRF)
Route::get('/payment/callback', function () {
    $order = Order::where('transaction_id', request('Authority'))->firstOrFail();

    try {
        $receipt = Payment::amount($order->amount)
            ->transactionId($order->transaction_id)
            ->verify();

        $order->markPaid($receipt->getReferenceId());
        return view('payment.success', ['ref' => $receipt->getReferenceId()]);
    } catch (\Shetabit\Multipay\Exceptions\PaymentFailedException $e) {
        return view('payment.failed', ['message' => $e->getMessage()]);
    }
});

Where to look next, in order: config/payment.php → the driver class under src/Drivers/<Gateway>/ (each driver's constructor documents which config keys it actually reads) → the README's per-driver examples, since parameter names differ between gateways.

Implementation Patterns

Wrap it in a payment service. Don't scatter Payment:: calls across controllers. A thin PaymentService that takes an Order, builds the invoice, and records the transaction ID gives you one place to switch drivers and one place to log.

class PaymentService
{
    public function start(Order $order, string $driver = null)
    {
        $invoice = (new Invoice)
            ->amount($order->amount_rial)
            ->uuid($order->id)
            ->detail('description', "Order #{$order->id}")
            ->detail('mobile', $order->user->mobile); // some gateways require this

        return Payment::via($driver ?? config('payment.default'))
            ->purchase($invoice, fn ($driverName, $txId) => $order->update([
                'gateway' => $driverName,
                'transaction_id' => $txId,
            ]))
            ->pay()
            ->render();
    }

    public function verify(Order $order): \Shetabit\Multipay\Receipt
    {
        return Payment::via($order->gateway)
            ->amount($order->amount_rial)
            ->transactionId($order->transaction_id)
            ->verify();
    }
}

Always verify with the stored amount and transaction ID, not with values from the query string. verify() takes an optional amount, and passing the amount you charged is your protection against a tampered callback — if the gateway reports a different amount, the package throws PaymentFailedException (via the "Invalid amount" check in most drivers).

Model the order as a state machine, not a boolean. pending → redirected → verified | failed | canceled. Gateways call back multiple times (user refreshes the return page, browser prefetch, retries). Make markPaid idempotent — a unique index on transaction_id plus if ($order->paid_at) return.

Runtime driver selection. Payment::via('idpay') returns a fluent builder, so you can let the user pick a gateway at checkout and read the choice back on the callback from your own DB column. Keep default set to whatever your most common gateway is so the common case needs no via().

Testing. Every driver that supports it reads mode => 'sandbox' from its config block. Flip it via env, then keep a small set of feature tests that fake the callback route with the gateway's expected query params and assert the state transition. You won't get far mocking the driver — it's easier to point the config at sandbox credentials and drive the real thing against a test order.

Receipts are your audit trail. $receipt->getReferenceId() is the bank/gateway reference (not your transaction ID). Store it alongside the transaction ID, the driver name, and the raw response if the driver exposes it — reconciliation with the gateway's merchant panel is a monthly chore otherwise.

Gotchas and Tips

Amounts are in Rial for nearly every driver. If your app prices in Toman (very common in Iranian apps), multiply by 10 exactly once, in one place, and never again. Amount mismatches between purchase and verify are the single most common cause of a "successful payment that fails verification".

Exclude the callback route from CSRF. It's a GET request from the gateway in most cases, but if you expose a POST callback, add it to VerifyCsrfToken::$except and to your auth middleware exceptions. Forgetting this produces a 419 on the return leg while the money has already been taken.

The callback URL must match what's registered with the gateway. In sandbox mode Zarinpal/IDPay will accept localhost; in production the domain is often whitelisted in the merchant panel. A mismatch manifests as an opaque error from the gateway's redirect, not from this package.

Clear config cache after editing credentials. php artisan config:clear — otherwise you'll spend twenty minutes debugging a stale merchantId.

Catch both exception types. PaymentFailedException (gateway said no) and InvalidPaymentException / configuration errors are distinct; a bare catch (\Exception) will swallow a bad-merchant-ID misconfiguration as if it were a declined card.

Extension dependencies vary by driver. Parsian, Sadad, and Mellat historically talk SOAP, so the soap extension must be present; everything needs curl. Check composer.json's suggests / the driver source before assuming a fresh container will work.

Driver config keys are not uniform. merchantId vs apiKey vs terminalId vs username/password/terminalId — copy the exact block from the README for the gateway you're adding rather than guessing from a neighbouring driver.

Custom drivers extend Shetabit\Multipay\Abstracts\Driver and implement the purchase/pay/verify contract. Registration goes through the drivers config entry (check src/DriverFactory.php in the installed version for the exact key it resolves — it has changed across releases). This is the extension point for adding a gateway the package doesn't ship.

pay()->render() returns a response object, not a string — return it directly from a controller. It emits an auto-submitting HTML form for POST-based gateways and an HTTP redirect for GET-based ones, which is why it has to be a Laravel response rather than echoed output.

Version pinning matters. The package tracks Laravel versions closely and the namespace split (Shetabit\Multipay core vs Shetabit\Payment Laravel glue) has shifted between majors. Pin a tested minor in composer.json and read the upgrade notes before bumping, particularly around the verify() signature.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource