infyomlabs/laravel-payumoney
Laravel PayUMoney offers a simple integration for accepting PayUMoney payments in Laravel apps, with configuration, payment request handling, and response callback support to streamline online checkout workflows.
1. Install the package
composer require infyomlabs/laravel-payumoney
On Laravel 5.5+ the PayUMoneyServiceProvider and PayUMoney facade are auto-discovered. On older versions register them manually in config/app.php:
'providers' => [
InfyOm\PayUMoney\PayUMoneyServiceProvider::class,
],
'aliases' => [
'PayUMoney' => InfyOm\PayUMoney\Facades\PayUMoney::class,
],
2. Publish and read the config first
php artisan vendor:publish --provider="InfyOm\PayUMoney\PayUMoneyServiceProvider"
This creates config/payumoney.php. This is the first file to read — it defines every credential and URL the wrapper uses (merchant key, salt, test/live mode, success/failure URLs, and any default surl/furl). Next, open src/PayUMoney.php to confirm the exact method signature for building a payment request, since the README is thin.
3. Set credentials via .env, never in the committed config:
PAYUMONEY_MERCHANT_KEY=your_key
PAYUMONEY_SALT=your_salt
PAYUMONEY_MODE=test
PAYUMONEY_SUCCESS_URL="${APP_URL}/payment/success"
PAYUMONEY_FAILURE_URL="${APP_URL}/payment/failure"
4. First use case — a single checkout button
Three routes are the minimum viable integration:
// routes/web.php
Route::get('/checkout/{order}', [PaymentController::class, 'checkout'])->name('checkout');
Route::post('/payment/success', [PaymentController::class, 'success'])->name('payment.success');
Route::post('/payment/failure', [PaymentController::class, 'failure'])->name('payment.failure');
// app/Http/Controllers/PaymentController.php
public function checkout(Order $order)
{
// Returns the redirect/auto-submitting form response to the gateway
return PayUMoney::payment([
'txnid' => $order->id . '-' . Str::random(8),
'amount' => number_format($order->total, 2, '.', ''),
'productinfo' => 'Order #' . $order->id,
'firstname' => $order->user->name,
'email' => $order->user->email,
'phone' => $order->user->phone,
'surl' => route('payment.success'),
'furl' => route('payment.failure'),
]);
}
The keys above (txnid, amount, productinfo, firstname, email, phone, surl, furl) match PayUMoney's own API contract; check src/PayUMoney.php to see whether the wrapper takes a single array or individual setters.
Rule of thumb: the package only initiates the payment. Everything about verifying and persisting the result is your job.
Wrap initiation in an action class, not the controller. The controller should do two things: resolve the order and hand off to a service.
class InitiatePayment
{
public function __invoke(Order $order)
{
$order->update([
'txnid' => $txnid = $order->id . '-' . Str::random(8),
'status' => 'pending',
]);
return PayUMoney::payment($this->payload($order, $txnid));
}
}
This gives you one place to change when the gateway (or the package) breaks.
Persist a pending order before redirecting. The gateway returns txnid, and that is your only reliable correlation key. Never rely on session state surviving the round-trip — the user may return in a different browser tab or after the session expires.
Treat success/failure/cancel as three separate routes with a shared handler.
public function success(Request $request)
{
$payload = $request->all();
$order = Order::where('txnid', $payload['txnid'])->firstOrFail();
if (!$this->hashIsValid($payload)) {
return $this->fail($order, 'hash_mismatch');
}
$order->update([
'status' => $payload['status'] === 'success' ? 'paid' : 'failed',
'gateway_payload'=> json_encode($payload),
'paid_at' => now(),
]);
return redirect()->route('orders.show', $order);
}
Verify the reverse hash server-side, always. Do not trust status=success alone. PayUMoney's reverse hash is sha512(salt|status|udf10|udf9|...|udf1|email|firstname|productinfo|amount|txnid|key) — note it reverses the UDF order versus the forward hash. Confirm against the current PayUMoney docs, because newer flows append additional-charge fields.
Use route() for surl/furl, never hardcoded hosts. This is the single most common source of failed sandbox tests (URLs pointing at localhost or a stale staging host).
Store the raw gateway payload. A json column on orders (or a payment_transactions table) makes reconciliation, support tickets, and disputes trivially resolvable. You will be asked "did the customer actually pay?" and the answer should be a database read, not an investigation.
Guard against duplicate callbacks. PayUMoney can post twice (user refreshes, retries). Make your handler idempotent:
if ($order->status === 'paid') {
return redirect()->route('orders.show', $order); // already handled
}
Keep a thin interface in front of the facade if payments are core to the app:
interface PaymentGateway {
public function initiate(Order $order): mixed;
public function verify(array $payload): bool;
}
Then PayUMoneyGateway implements it. This is cheap insurance against the package's age (last release April 2021) and lets you test checkout flows with a fake.
Testing pattern: in test mode use PayUMoney's sandbox credentials and the documented test card numbers. For unit tests, bind a fake PaymentGateway and assert your controller persists the pending order and returns the gateway response — don't hit the real sandbox in CI.
Log around the boundary. Log::info('payumoney.initiate', [...]) before redirect and Log::info('payumoney.callback', $request->all()) on return. This is the difference between a five-minute diagnosis and an hour of guessing.
The package is stale. Last release was v1.3.0 in April 2021, and composer.json almost certainly does not declare Laravel 10/11 support. Expect one of:
composer require fails with a constraint conflict → install with composer require infyomlabs/laravel-payumoney --ignore-platform-reqs, orilluminate/support constraint, and point a VCS repository at your fork.Service-provider auto-discovery still works fine on modern Laravel; the facade is the part most likely to need manual registration.
CSRF will silently break your callbacks. PayUMoney posts to surl/furl from an external origin with no token. Either add the callback paths to VerifyCsrfToken::$except or register them in routes/api.php.
Auth middleware will also break your callbacks. Your success route must be publicly reachable. Correlate the user via the order, not via Auth::user().
Never ship the salt to the browser. The hash must be computed server-side. If you find yourself passing the salt into a Blade view to "let the JS build the form," stop — that is a full account compromise for anyone who views source.
surl/furl must be publicly reachable from the internet. localhost and private staging hosts will never receive the callback. Use ngrok or expose during development, or your success handler will simply never run and every order stays pending.
Amount formatting matters. Send number_format($total, 2, '.', '') — a string with two decimals and no thousands separator. Passing a float can serialize as 100 vs 100.00 and produce a hash mismatch on the gateway side.
txnid must be unique per attempt. Reusing an order ID across retries causes duplicate-transaction rejections. Append a random suffix, and store it before redirecting.
Currency is INR. PayUMoney (India) does not do multi-currency; if your catalog is priced in USD you must convert before calling and record both amounts.
The wrapper does not verify responses for you. Its scope is request creation and redirect. Hash verification, status persistence, and any refund logic are entirely yours — refunds require calling the PayUMoney API directly, there is no wrapper method.
service_provider=payu_paisa is required in the payload for the classic PayUMoney form flow. If the package's defaults don't include it, add it explicitly or the gateway rejects the request.
When nothing works, log the exact hash input string. Mismatches are almost always a trailing pipe, a missing UDF placeholder, or a field-order difference. Print the concatenated string with delimiters visible (salt|status|...|key) and diff it character by character against the docs.
Extension points: config/payumoney.php is publishable and everything is resolved from it, so you can override per-environment. The package typically binds a concrete class in the container — you can $this->app->bind() your own subclass of PayUMoney if you need to intercept request building (add UDFs, custom logging, feature flags) without forking.
Watch for the test/live toggle leaking. A PAYUMONEY_MODE env var set to test in production is a silent revenue outage: payments "succeed" in the sandbox and no money moves. Add a deploy check or assert the mode at boot in non-local environments.
How can I help you explore Laravel packages today?