ajaymahato / laravel-esewa-epay-v2
Laravel eSewa ePay v2 integration (HMAC, callback verify, status check)
Installs: 10
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/ajaymahato/laravel-esewa-epay-v2
Requires
- php: ^8.1|^8.2|^8.3
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.11
- orchestra/testbench: ^9.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
README
Laravel eSewa ePay v2 integration for Laravel 10/11/12. Generate HMAC signatures, post to the ePay form endpoint, verify callbacks, and record every attempt in your database with a single facade call.
Features
- Drop-in facade:
return Esewa::pay([...]);renders an auto-submit payment form - HMAC-SHA256 (Base64) signing helper for requests and webhook payloads
- Callback verification + event dispatch (
EsewaPaymentVerified) with DB persistence - Status check client for reconciliation workflows
- Ships with migration, model, enum, controllers, routes, and Blade view
Requirements
- PHP 8.1+
- Laravel 10 or 11 (or any app with
illuminate/support10/11/12) - eSewa merchant credentials for UAT or Production
Payment Status Constants/Enums
case PENDING = 'PENDING'; case COMPLETE = 'COMPLETE'; case FULL_REFUND = 'FULL_REFUND'; case PARTIAL_REFUND = 'PARTIAL_REFUND'; case AMBIGUOUS = 'AMBIGUOUS'; case NOT_FOUND = 'NOT_FOUND'; case CANCELED = 'CANCELED';
Installation
-
Require the package
composer require ajaymahato/laravel-esewa-epay-v2
-
Publish config + migration, then run migrations
php artisan vendor:publish --tag=esewa-config php artisan migrate
-
Configure your
.envESEWA_MODE=uat # uat (testing) or production ESEWA_PRODUCT_CODE=EPAYTEST # merchant code ESEWA_SECRET_KEY=8gBm/:&EnhH.1/q ESEWA_SUCCESS_URL=https://your-app.com/esewa/relay ESEWA_FAILURE_URL=https://your-app.com/esewa/relay # Optional overrides ESEWA_ROUTE_PREFIX= # set if you want /prefix/esewa/...
Add this to your order/booking table
$table->foreignId('payment_id')->nullable() ->constrained('esewa_payments')->nullOnDelete(); $table->string('transaction_uuid')->unique(); $table->string('payment_status')->default('UNPAID'); // cache $table->string('esewa_ref')->nullable(); // external proof $table->timestamp('paid_at')->nullable();
Quick Start
Create your order/booking as usual. In your controller, generate the UUID, queue the delayed reconciliation job, then return the payment form using the same UUID.
use Illuminate\Support\Str; use App\Jobs\ReconcileEsewaPaymentJob; public function payOrder(\App\Models\Order $order) { // Generate a UUID you control (so jobs/admin tools can reference it) $uuid = now()->format('ymd-His').'-'.Str::upper(Str::random(4)); // Schedule a safety-net reconcile in case the browser callback never arrives ReconcileEsewaPaymentJob::dispatch($uuid)->delay(now()->addMinutes(8)); // Return the auto-submitting eSewa form return \Esewa::pay([ 'transaction_uuid' => $uuid, // use the same UUID 'amount' => (int) $order->total, 'total_amount' => (int) $order->total, 'tax_amount' => 0, 'product_service_charge' => 0, 'product_delivery_charge' => 0, 'meta' => [ 'payable' => ['type' => $order::class, 'id' => $order->id], ], 'success_url' => route('thank.you'), //it should be different from .env urls 'failure_url' => route('payment.failed'), //put the success or failed page routes here ]); }
Handling Verified Payments
Hook one listener to flip your own record (booking/order/cart) to PAID.
- Make the listener
php artisan make:listener MarkOrderPaid
app/Listeners/MarkOrderPaid.php
public function handle(\AjayMahato\Esewa\Events\EsewaPaymentVerified $event): void { $payment = $event->payment; if (($payment->status?->value ?? $payment->status) !== 'COMPLETE') { return; } $meta = $payment->meta['payable'] ?? null; if (! $meta) { return; } $model = app($meta['type'])::find($meta['id']); if (! $model) { return; } $model->update([ 'payment_id' =? $payment->id, 'payment_status' => 'PAID', 'esewa_ref' => $payment->ref_id, 'paid_at' => now(), ]); }
Tip: add a tiny helper on your models:
public function isPaid(): bool { return $this->payment_status === 'PAID'; }
Reconciliation Safety Nets (Optional to make the project more Secure)
Delayed jobs, scheduled sweeps, and manual tools ensure you update stale payments even if callbacks fail. Choose any one option among the three according to your convenience.
A) Delayed job fallback
-
Create the job
php artisan make:job ReconcileEsewaPaymentJob
-
Implement the job (
app/Jobs/ReconcileEsewaPaymentJob.php):<?php namespace App\Jobs; use AjayMahato\Esewa\Models\EsewaPayment; use AjayMahato\Esewa\Events\EsewaPaymentVerified; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; class ReconcileEsewaPaymentJob implements ShouldQueue { use Queueable; public function __construct(public string $uuid) {} public function handle(): void { $payment = EsewaPayment::where('transaction_uuid', $this->uuid)->first(); if (! $payment || ($payment->status?->value ?? $payment->status) === 'COMPLETE') { return; // nothing to do } $resp = \Esewa::statusCheck( $payment->product_code, (string) $payment->total_amount, $payment->transaction_uuid ); $payment->update([ 'raw_response' => $resp, 'ref_id' => $resp['ref_id'] ?? $payment->ref_id, 'status' => $resp['status'] ?? $payment->status, ]); if (($resp['status'] ?? null) === 'COMPLETE') { event(new EsewaPaymentVerified($payment->fresh())); } } }
-
Dispatch it when you start the payment (already shown above). The job should run ~8�10 minutes later and only act if the row is still
PENDING.
B) Scheduled sweep (belt-and-suspenders)
-
Generate the command
php artisan make:command EsewaReconcileCommand
-
Implement the command (
app/Console/Commands/EsewaReconcileCommand.php):<?php namespace App\Console\Commands; use Illuminate\Console\Command; use AjayMahato\Esewa\Models\EsewaPayment; use AjayMahato\Esewa\Events\EsewaPaymentVerified; class EsewaReconcileCommand extends Command { protected $signature = 'esewa:reconcile {uuid?}'; protected $description = 'Reconcile pending eSewa payments (or a single UUID)'; public function handle(): int { $query = EsewaPayment::query()->where('status', 'PENDING'); if ($uuid = $this->argument('uuid')) { $query->where('transaction_uuid', $uuid); } $query->chunkById(100, function ($payments) { foreach ($payments as $payment) { $resp = \Esewa::statusCheck( $payment->product_code, (string) $payment->total_amount, $payment->transaction_uuid ); $payment->update([ 'raw_response' => $resp, 'ref_id' => $resp['ref_id'] ?? $payment->ref_id, 'status' => $resp['status'] ?? $payment->status, ]); if (($resp['status'] ?? null) === 'COMPLETE') { event(new EsewaPaymentVerified($payment->fresh())); } } }); $this->info('Reconciliation run complete.'); return self::SUCCESS; } }
-
Schedule it (e.g. hourly) in
app/Console/Kernel.php:protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void { $schedule->command('esewa:reconcile')->hourly(); // or everyTenMinutes() }
C) Manual admin action
Provide customer support with a button to reconcile a single payment on demand.
public function reconcile(string $uuid) { $payment = \AjayMahato\Esewa\Models\EsewaPayment::where('transaction_uuid', $uuid)->firstOrFail(); $resp = \Esewa::statusCheck( $payment->product_code, (string) $payment->total_amount, $payment->transaction_uuid ); $payment->update([ 'raw_response' => $resp, 'ref_id' => $resp['ref_id'] ?? $payment->ref_id, 'status' => $resp['status'] ?? $payment->status, ]); if (($resp['status'] ?? null) === 'COMPLETE') { event(new \AjayMahato\Esewa\Events\EsewaPaymentVerified($payment->fresh())); } return back()->with('status', 'Reconciled.'); }
Recommended setup: dispatch the delayed job for every payment, keep the scheduled sweep as a backstop, and expose the manual action for support/admin tooling.
Security Notes
- Request signature order:
total_amount,transaction_uuid,product_code - Validate every callback with
signed_field_names+ signature comparison (handled for you) - Never commit your secret key; keep it in
.env
License
Released under the MIT License.