jomweb/billplz
A Laravel package for integrating Billplz, the Malaysian payment gateway. Create and manage bills, redirect customers to secure payment pages, and handle callbacks/webhooks through a clean, configurable API. Supports sandbox and production environments for fast checkout flows.
1. Install and pin
composer require jomweb/billplz
The package is framework-agnostic (no Laravel service provider is auto-registered by design), so the only thing you inherit is a thin client over BillPlz API v3. Check the php constraint in composer.json against your app before committing — this is the most common source of "it installs on my machine but not CI."
2. Collect credentials before writing code
From the BillPlz dashboard you need three things, and they are not interchangeable:
Stand up sandbox first (billplz-sandbox.com); sandbox keys and collections are completely separate from production, and mixing them produces 401/404 responses that look like code bugs.
3. Wire it into config
// config/services.php
'billplz' => [
'key' => env('BILLPLZ_API_KEY'),
'signature' => env('BILLPLZ_X_SIGNATURE'),
'collection' => env('BILLPLZ_COLLECTION_ID'),
'sandbox' => env('BILLPLZ_SANDBOX', true),
],
Bind it once as a singleton so you're not reconstructing the client per request:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\JomWeb\BillPlz\BillPlz::class, function () {
$client = new \JomWeb\BillPlz\BillPlz();
$client->setKey(config('services.billplz.key'));
$client->setVersion('v3');
$client->setMode(config('services.billplz.sandbox') ? 'sandbox' : 'production');
return $client;
});
Method names above follow the v3 resource-style client (
setKey,setMode,bill(),collection()). Confirm against the README of your pinned version — earlier releases used flatter, single-purpose methods. The patterns below are stable regardless.
4. First use case: create a collection, then a bill
Your first real day-to-day flow is: ensure a collection exists (usually once, at deploy time), then create a bill per checkout and redirect the customer to the returned url.
$collection = $billplz->collection()->create([
'title' => 'My Store',
]);
$bill = $billplz->bill()->create([
'collection_id' => $collection['id'],
'email' => $order->email,
'mobile' => $order->phone, // +60123456789
'name' => $order->customer_name,
'amount' => $order->total_cents, // SEN, not ringgit
'description' => "Order #{$order->number}",
'callback_url' => route('webhooks.billplz'),
'redirect_url' => route('checkout.return'),
'reference_1' => (string) $order->id,
]);
return redirect()->away($bill['url']);
Where to look first: the README's payload tables for bill()->create() and the BillPlz docs page on X-Signature. Those two determine 90% of your integration surface.
Store the bill ID, not the URL. Write billplz_bill_id onto your order row at creation time. The redirect and callback both hand you an ID; without the column you'll be reconciling by email and amount, which fails the moment a customer pays twice.
Treat redirect and callback as two different trust levels. The redirect is a browser GET (?billplz[id]=...&billplz[paid]=true&billplz[x_signature]=...) — fast UX feedback, but a user can land there by refreshing or back-navigating. The callback is server-to-server and can arrive late, out of order, or twice. Both should funnel into one reconciliation method.
Always verify, then re-fetch. Don't trust paid=true from either surface. Verify the X-Signature, then call bill()->get($id) and check paid, paid_at, and that the amount matches what you expect.
public function handle(Request $request, BillPlz $billplz)
{
abort_unless($billplz->verifySignature($request->all()), 403);
$bill = $billplz->bill()->get($request->input('billplz.id'));
if (! $bill['paid']) {
return response('ok'); // still pending
}
DB::transaction(function () use ($bill) {
$order = Order::where('billplz_bill_id', $bill['id'])->lockForUpdate()->firstOrFail();
if ($order->isPaid()) {
return; // idempotent
}
abort_unless($bill['amount'] === $order->total_cents, 409);
$order->markPaid($bill['paid_at']);
});
return response('ok'); // BillPlz expects a 200
}
Idempotency is the load-bearing pattern. BillPlz retries callbacks on non-2xx. Your handler must be a no-op the second time — guarded by lockForUpdate() plus an isPaid() short-circuit, and always return 200 once the state is settled.
Verify the signature correctly. The X-Signature scheme is: take all keys except x_signature, sort them ascending, concatenate key . value with no separator, then hash_hmac('sha256', $concatenated, $xSignatureKey). Do this in one place (verifySignature) and unit-test it with a captured real payload — getting the sort or the separator wrong silently rejects every genuine callback.
Queue the slow parts. BillPlz API latency is real and spiky. Create bills inside a job if checkout is sensitive, and hand reconciliation off to a queued job so the webhook responds inside BillPlz's timeout window. A dedicated ReconcileBillPlzBill job also gives you retries for free.
Backfill with reference_1 / reference_2. Use reference_1 for your order ID. When a callback arrives for an order you can't find (deploy rollback, wiped DB), the reference fields are your escape hatch.
Testing strategy. The package speaks HTTP directly, so Laravel's Http::fake() won't help. Two options that work: wrap the client behind your own PaymentGateway interface and swap in a fake in feature tests, and use the BillPlz sandbox for a small integration suite (creating a real bill and fetching it back is cheap and fast). Unit-test signature verification offline — it's pure computation and the highest-risk piece.
Amounts are in sen, always. RM 49.90 is 4990. Passing 49.90 creates a 49-sen bill that silently succeeds and undercharges the customer. Cast to int at the boundary and assert on it in tests.
The signature key is not the API key. They're two separate dashboard values. Swapping them yields valid-looking code where every callback 403s. Name your env vars explicitly (BILLPLZ_X_SIGNATURE).
paid_at is your source of truth for timing, not the webhook arrival time. Callbacks can be delayed by minutes; use paid_at when reconciling against accounting.
Only unpaid bills can be deleted, and deleting a bill does not delete the collection. Collection deletion has its own restrictions. Design for soft-cancellation in your own schema rather than relying on BillPlz cleanup.
Sandbox mode resets and is shared infrastructure. Bills created in sandbox can disappear; don't build assertions on long-lived sandbox state. Also, sandbox and production return structurally identical responses — there's no "this is a test" flag in the payload, so log which mode created each bill.
env() inside a service provider is a cache trap. With php artisan config:cache, env() returns null outside config files. Always read via config('services.billplz.*').
Exclude the webhook route from CSRF, and if you're behind a proxy, make sure the route is reachable without session/auth middleware — a 302 to login reads as a failure to BillPlz and triggers endless retries.
Mobile numbers need the +60 prefix in E.164-ish form. A local 0123456789 is rejected. Normalise in a small helper rather than sprinkling string surgery around the codebase.
Response shapes are plain arrays/objects, not typed models. Wrap them in your own DTOs (BillPlzBill, BillPlzCollection) with named accessors. When BillPlz adds a field or you swap gateways, only the DTO changes.
Extension point: the client is thin enough that the right seam is your wrapper class, not the vendor's. Define interface PaymentGateway { createBill(Order $o): string; reconcile(string $billId): bool; }, implement it with this package, and bind it in the container. That gives you fake implementations in tests, a path to a second gateway, and a single place to add logging, metrics, and retry policy. Keep the BillPlz-specific code confined to that one adapter — the package's value is that it's a small, replaceable dependency, not a framework you build around.
How can I help you explore Laravel packages today?