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.
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.
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.
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.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/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.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.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.purchase() signature, Receipt methods). Anything you read online may target a different major; check composer.lock before copying snippets.'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.How can I help you explore Laravel packages today?