lahirulhr/laravel-payhere
A Laravel package for integrating the PayHere payment gateway into your applications. Simplify checkout, payment initiation, and notification handling with a clean, framework-friendly API designed for quick and reliable Sri Lankan payment integration.
Install and publish config
composer require lahirulhr/laravel-payhere
php artisan vendor:publish --tag=payhere-config
Confirm the service provider is auto-discovered (php artisan about or check bootstrap/providers.php on Laravel 11+).
Set the credentials in .env — the values come straight from the PayHere merchant dashboard (Settings → Domains & Credentials):
PAYHERE_MERCHANT_ID=1210000
PAYHERE_MERCHANT_SECRET=xxxxxxxxxxxxxxxx
PAYHERE_SANDBOX=true
PAYHERE_RETURN_URL=/payhere/return
PAYHERE_NOTIFY_URL=/payhere/notify
Cache-bust after changing them: php artisan config:clear.
Where to look first
config/payhere.php — merchant ID, secret, sandbox flag, and the return/notify route names. The sandbox flag is what swaps sandbox.payhere.lk for www.payhere.lk; verify it actually does so by dumping the generated checkout URL.src/ directory — look for a hashing helper (MD5 of merchant secret), a "checkout payload" builder, and a notify-verification method. These three are the whole surface area of the package; everything else is Laravel plumbing.First use case to get working: one-off credit-card/wallet payment for a fixed-price order. Create an Order row with status pending, generate the hash and payload, render the auto-submitting form to the PayHere checkout, then flip the order to paid only when the notify webhook arrives with a valid hash and status_code == 2.
Persist the order before redirecting. Never build the checkout payload from request input alone — write the order (with a server-generated order_id, amount, currency) first, then generate the hash from the stored row. This gives you a single source of truth to compare against when the notify arrives.
Payload generation in a dedicated action. Keep a CreatePayHereCheckout class (or a service method) that takes an Order and returns the array the package expects. That isolates the amount formatting and hash logic from your controllers:
$payload = PayHere::checkout([
'order_id' => $order->reference, // unique, you generate it
'items' => $order->description,
'amount' => number_format($order->total, 2, '.', ''),
'currency' => 'LKR',
'first_name' => $order->first_name,
'last_name' => $order->last_name,
'email' => $order->email,
'phone' => $order->phone,
'address' => $order->address,
'city' => $order->city,
'country' => 'Sri Lanka',
]);
return view('payhere.redirect', compact('payload'));
Auto-submit form view. PayHere's hosted checkout expects a POST to the checkout URL, so you need a Blade view that renders hidden inputs and calls form.submit() on load. A spinner + "Redirecting to secure payment…" line is worth adding for slow mobile networks.
Notify handler as the source of truth. Treat the return URL as cosmetic only. The notify endpoint is the authoritative signal:
public function notify(Request $request)
{
Log::channel('payhere')->info('notify', $request->all());
abort_unless(PayHere::verifyNotify($request->all()), 403, 'Bad hash');
$order = Order::where('reference', $request->input('order_id'))->firstOrFail();
abort_unless($order->total == $request->input('payhere_amount'), 422, 'Amount mismatch');
if ($order->status === 'paid') {
return response('OK', 200); // idempotent replay
}
match ((int) $request->input('status_code')) {
2 => $order->markAsPaid($request->input('payhere_id')),
0 => $order->markAsPending(),
-1 => $order->markAsCancelled(),
-2 => $order->markAsFailed(),
-3 => $order->markAsChargedback(),
};
return response('OK', 200);
}
Routing. PayHere posts form-encoded data with no CSRF token, so the notify route must live outside web middleware or be added to VerifyCsrfToken::$except (Laravel ≤10). Route it as POST and return literally OK with a 200 — anything else and PayHere retries.
Status flow. Model your order.status as a small enum: pending → paid | failed | cancelled | chargedback. Never show "payment successful" from the return URL alone; the return page should poll or simply say "we're confirming your payment" and link to the order.
Testing workflow. Sandbox credentials are separate from live ones. PayHere's sandbox test card is 4916217501611292, any future expiry, CVV 111, and the OTP step accepts 123456 in sandbox. Use php artisan serve with a tunnel (ngrok/Expose) so PayHere can reach your notify URL — localhost will silently never fire.
Integration tips.
OrderPaid) from the notify handler and hang fulfilment, email, and stock decrement off that — keeps the webhook controller thin.PayHereGateway interface so you can swap to the on-site/JS SDK later without touching callers.payhere.lk/pay/refund / subscription APIs) are usually not covered by small packages like this — plan to write raw Guzzle calls with the merchant token if you need them.strtoupper(md5($merchant_id . $order_id . $amount . $currency . strtoupper(md5($merchant_secret)))). If the package builds this for you, still verify it against PayHere's docs once with a manually computed value — a silently wrong hash produces a generic "Invalid request" page with no clue.. separator, no thousands separator, no LKR prefix. 1000.5 will fail where 1000.50 works. Store money as integer cents and format at the boundary.200 OK. Guard with a payhere_id column and a unique index; a duplicate notify should be a no-op, not a second receipt email.payhere_amount, payhere_currency, status_code (not status), and payhere_id are the field names you need to verify against — dump them into a dedicated channel so you can reconcile disputes later.http:// notify silently never fires — which looks exactly like a package bug.merchant_id should stay a string. It's numeric but leading zeros and env coercion can bite; validate it's non-empty and log it (not the secret) on boot in local.How can I help you explore Laravel packages today?