Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Billplz Laravel Package

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.

Deep Wiki
Context7

Getting Started

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:

  • API Key — authenticates your server calls.
  • X-Signature — the HMAC secret used to verify callbacks and redirects.
  • Collection ID — bills must belong to a collection, and collections are created via API or the dashboard.

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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator