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

Payment Laravel Package

shetabit/payment

Shetabit Payment is a Laravel package that unifies multiple Iranian payment gateways behind one simple API. It supports popular drivers like Zarinpal and IDPay, manages callbacks, verification, and transactions, and lets you configure or swap gateways easily.

View on GitHub
Deep Wiki
Context7

Getting Started

shetabit/payment is a thin Laravel wrapper over shetabit/multipay: a uniform purchase → redirect → callback → verify flow across ~20 Iranian PSPs (Zarinpal, IDPay, PayIR, NextPay, Zibal, Sadad, Saman, Parsian, Pasargad, AsanPardakht, Payping, Vandar, YekPay…). If you're building for the Iranian market, this is the package you reach for instead of hand-rolling each gateway's SOAP/REST API.

Install and publish the config first — that file is your map of the package.

composer require shetabit/payment
php artisan vendor:publish --provider="Shetabit\Payment\PaymentServiceProvider"
# -> config/payment.php

config/payment.php has two things worth reading before anything else:

return [
    'default' => env('PAYMENT_DEFAULT_DRIVER', 'zarinpal'),
    'drivers' => [
        'zarinpal' => [
            'merchantId' => env('ZARINPAL_MERCHANT_ID'),
            'callbackUrl' => env('ZARINPAL_CALLBACK_URL'),
            'mode' => 'normal', // or 'sandbox'
        ],
        // ...
    ],
];

Look first at the drivers array: the keys are the strings you pass to via(), and each block tells you which env keys you must add. Different drivers need wildly different credentials (merchantId, terminalId, username/password, apiKey, key), so treat each block as its own mini-integration.

First use case: a single checkout page, paid via one gateway.

// config: 'default' => 'zarinpal'
use Shetabit\Multipay\Invoice;
use Shetabit\Payment\Facade\Payment;

$invoice = (new Invoice)->amount(10000); // amount is in Rial

$payment = Payment::callbackUrl(route('payment.callback'))
    ->purchase($invoice, function ($driver, $transactionId) use ($order) {
        $order->update(['transaction_id' => $transactionId, 'driver' => $driver]);
    });

return $payment->pay(); // redirects/posts to the gateway

Then the callback:

Route::match(['get', 'post'], '/payment/callback', function (Request $request) {
    $order = Order::where('transaction_id', $request->query('Authority') ?? $request->input('Authority'))->firstOrFail();

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

    $order->markPaid($receipt->getReferenceId());
    return redirect()->route('checkout.success');
})->name('payment.callback');

That flow — purchase stores a transaction id, verify turns it into a receipt — is 90% of what you'll write. Everything below is variations on it.

Implementation Patterns

Wrap the facade in your own payment service. The facade is stateless and global-config-driven; your domain isn't. Put the via() / amount() / callbackUrl() dance behind an application service so controllers never see gateway strings:

class PaymentService
{
    public function start(Order $order, string $driver): RedirectResponse
    {
        $invoice = (new Invoice)
            ->amount($order->amount)             // Rial
            ->detail('description', "Order #{$order->id}")
            ->detail('mobile', $order->user->mobile);

        return Payment::via($driver)
            ->callbackUrl(route('payment.callback'))
            ->purchase($invoice, fn ($d, $txId) => $order->payment()->create([
                'driver' => $d, 'transaction_id' => $txId, 'status' => 'pending',
            ]))
            ->pay();
    }
}

Persist everything the callback could need. You cannot re-derive amount or the driver from the callback request reliably. Store driver, transaction_id, amount, and a JSON blob of the gateway response on your own table. When reconciliation is needed six weeks later, that blob is the only record of what the PSP actually said.

Use via() for multi-gateway checkout. Let the user pick from a list built off config('payment.drivers'), then pass that exact key. Never hardcode a driver in a controller — the same PaymentService should serve all of them, since the purchase/verify contract is identical.

Make the callback idempotent. Gateways retry, users refresh, and browsers prefetch. Verify inside a DB transaction with a row lock, and short-circuit if the order is already paid:

DB::transaction(function () use ($order, $request) {
    $order = Order::lockForUpdate()->findOrFail($order->id);
    if ($order->isPaid()) return $order->receipt;

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

    $order->markPaid($receipt->getReferenceId());
});

Fire domain events after verify, not before. Emails, provisioning, and stock decrements belong in a listener on OrderPaid, never inline in the callback — gateway callbacks time out aggressively and a slow listener will cause a retry loop.

Route registration: GET or POST matters. Most Iranian PSPs return via GET, but several (Sadad, AsanPardakht, some Saman variants) POST back. Register the callback as Route::match(['get','post'], …) and add it to VerifyCsrfToken's except list — a POST callback with an invalid CSRF token will 419 and the order will silently hang in pending forever.

Testing: use the local driver. Multipay ships a local driver that simulates a gateway page and a successful verify, so you can assert the whole flow in feature tests without network calls or credentials:

// phpunit.xml or a test-specific config
'default' => 'local',

For anything beyond that, Http::fake() the driver's endpoint and assert your service persisted the transaction id. Keep a small fake of PaymentService injected into controllers so unit tests never touch the facade.

Extension points. Two worth knowing: (1) Payment::config([...]) lets you override driver config at runtime — useful for per-merchant setups or tests; (2) because shetabit/payment delegates to multipay, you can register a custom driver by implementing Multipay's DriverInterface and adding it to the drivers config under your own key. That's the escape hatch when a PSP isn't supported.

Gotchas and Tips

  • Rial vs Toman is the #1 production bug. Invoice::amount() is fed straight to the gateway with no conversion. Zarinpal and most others expect Rial; if your UI shows Toman, multiply by 10 in exactly one place (your PaymentService, not scattered across controllers). Getting this wrong charges users 10× or 1/10×.
  • verify() needs the same amount you purchased with for several drivers (Zarinpal in particular). Verify against the persisted amount, never against a value from the callback query string.
  • verify() throws on double-verification. Catch InvoiceAlreadyVerifiedException (and the general gateway exception) and treat "already verified" as success — it usually means a retry.
  • Callback URLs must be absolute, public, and HTTPS for real gateways. url()->current() inside a queued job or behind a proxy will produce garbage; always use route() with an explicit domain. Localhost callbacks are rejected by essentially every PSP.
  • Config caching bites hard. config/payment.php reads env() directly, so after changing a merchant ID you need php artisan config:clear in dev and a redeploy in production. Symptom: "credentials invalid" with obviously-correct .env values.
  • The config file is a template, not a contract. Some drivers need keys the published file doesn't list (a key, a terminalId, a sandbox flag). When a gateway rejects you with a vague error, read that driver's class in vendor/shetabit/multipay/src/Drivers/ — it shows exactly which config keys are read and what the request body looks like.
  • pay() vs render(). pay() sends a redirect or auto-submitting form depending on the driver; render() returns that form HTML for you to place in a view. If you get a blank page on a form-based gateway, you probably returned pay() from a controller expecting JSON.
  • SSL/timeout failures on older servers. Iranian PSP endpoints are plain HTTPS; "cURL error 60" means an outdated CA bundle on the host, not a package bug. Fix the server, don't disable verification.
  • Log the raw callback payload (Log::info('payment.callback', $request->all())) on every callback. Every real-world dispute is resolved by diffing your stored transaction against the gateway's dashboard, and the gateway's reference id lives in that payload, not in Receipt.
  • Pin the major version. The public API changed meaningfully across v2 → v3 → v4 (facade namespace, purchase() signature, Receipt methods). Anything you read online may target a different major; check composer.lock before copying snippets.
  • Sandbox modes are per-driver and inconsistently implemented. Some honor 'mode' => 'sandbox', some need a separate test merchant, some have no sandbox at all. Budget real money for a small live smoke test per gateway before launch — it's faster than chasing sandbox quirks.
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.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log