izzudin96 / billplz-laravel
Lightweight Billplz client package for modern Laravel versions.
Requires
- php: ^8.2
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-05-27 06:10:55 UTC
README
A lightweight Billplz package for Laravel 11/12/13.
It provides a small Billplz client for common payment flow tasks:
- create bills
- fetch bill status
- verify callback/webhook signatures (strict mode)
- parse callback/webhook payloads (best-effort mode)
- configurable HTTP timeout/retry behavior
- safe input/config guards with clear exceptions
Features
- Create bill
- Get bill
- Verify redirect signature
- Verify webhook signature
- Best-effort redirect parsing
- Best-effort webhook parsing
When to use which method
verifyRedirect()andverifyWebhook(): Use when you want hard security checks and prefer exceptions on invalid signatures.parseRedirect(): Use when redirect/callback is for UX only and webhook is your source of truth.parseWebhook(): Use when you want null-on-failure behavior instead of exceptions.
Installation
composer require izzudin96/billplz-laravel
Configuration
Publish config:
php artisan vendor:publish --tag=billplz-config
Or set env variables directly:
BILLPLZ_API_KEY= BILLPLZ_X_SIGNATURE= BILLPLZ_COLLECTION_ID= BILLPLZ_VERSION=v3 BILLPLZ_SANDBOX=false BILLPLZ_TIMEOUT_SECONDS=10 BILLPLZ_RETRY_TIMES=1 BILLPLZ_RETRY_SLEEP_MS=200 BILLPLZ_USER_AGENT=billplz-laravel-client
Config supports both x-signature and x_signature keys for compatibility.
Optional config/services.php style usage is also supported:
return [ 'billplz' => [ 'key' => env('BILLPLZ_API_KEY'), 'x_signature' => env('BILLPLZ_X_SIGNATURE'), 'collection_id' => env('BILLPLZ_COLLECTION_ID'), 'sandbox' => env('BILLPLZ_SANDBOX', false), 'version' => env('BILLPLZ_VERSION', 'v3'), 'timeout_seconds' => env('BILLPLZ_TIMEOUT_SECONDS', 10), 'retry_times' => env('BILLPLZ_RETRY_TIMES', 1), 'retry_sleep_ms' => env('BILLPLZ_RETRY_SLEEP_MS', 200), 'user_agent' => env('BILLPLZ_USER_AGENT', 'billplz-laravel-client'), ], ];
HTTP behavior defaults
- Timeout: 10 seconds
- Retries: 1
- Retry backoff: 200ms
This applies to bill create/get requests.
Usage
How BillplzClient is resolved in Laravel
BillplzClient::class is only a class name string. To call client methods, you need an instance resolved by Laravel's service container.
Preferred patterns:
- Method injection (clean and explicit)
use Izzudin96\Billplz\BillplzClient; public function show(string $billId, BillplzClient $billplz) { $bill = $billplz->getBill($billId); }
- Constructor injection (good when used in multiple methods)
use Izzudin96\Billplz\BillplzClient; class PaymentController extends Controller { public function __construct(private BillplzClient $billplz) { } public function show(string $billId) { $bill = $this->billplz->getBill($billId); } }
- Facade usage (shortest call style)
use Izzudin96\Billplz\Facades\Billplz; $bill = Billplz::getBill($billId);
- app helper (works, but usually less preferred than DI)
use Izzudin96\Billplz\BillplzClient; $bill = app(BillplzClient::class)->getBill($billId);
Why DI/facade is friendlier:
- Better readability in controllers/services
- Easier testing and mocking
- No repeated container lookup calls
1) Create a bill
use Izzudin96\Billplz\BillplzClient; $bill = app(BillplzClient::class)->createBill( email: 'user@example.com', mobile: '60123456789', name: 'User Name', amountCents: 25900, // RM259.00 in cents/sen callbackUrl: route('payments.billplz.webhook'), description: 'Booking BK-10021', optional: [ 'redirect_url' => route('payments.billplz.callback'), 'reference_1_label' => 'Booking Ref', 'reference_1' => 'BK-10021', 'reference_2_label' => 'Customer ID', 'reference_2' => 'CUS-890', // Additional Billplz-supported fields can be passed through here. // Required fields from method params always take precedence. ], ); // Save for reconciliation and redirect user to Billplz page $billId = $bill['id']; $paymentUrl = $bill['url'];
2) Get bill status
use Izzudin96\Billplz\BillplzClient; $bill = app(BillplzClient::class)->getBill($billId); // Examples of useful fields from Billplz response: // $bill['paid'] // $bill['paid_at'] // $bill['state']
3) Strict signature verification
Use this when you want invalid signature payloads to fail immediately.
use Izzudin96\Billplz\BillplzClient; use Izzudin96\Billplz\Exceptions\FailedSignatureVerification; try { $redirect = app(BillplzClient::class)->verifyRedirect(request()->query()); $webhook = app(BillplzClient::class)->verifyWebhook(request()->all()); } catch (FailedSignatureVerification $e) { report($e); abort(403, 'Invalid Billplz signature'); }
4) Best-effort redirect (recommended for UX callback)
Use this when callback/redirect is only for showing payment result to user. Process fulfillment from webhook instead.
use Izzudin96\Billplz\BillplzClient; $redirect = app(BillplzClient::class)->parseRedirect(request()->query()); if ($redirect === null) { return redirect()->route('payments.failed') ->with('message', 'Payment information is incomplete.'); } // signature_valid can be true, false, or null (when signature key not configured) $isPaid = (bool) ($redirect['paid'] ?? false); return redirect()->route('payments.result')->with([ 'bill_id' => $redirect['id'] ?? null, 'paid' => $isPaid, 'signature_valid' => $redirect['signature_valid'], ]);
5) Best-effort webhook parser
Use this when you prefer null checks over exception handling.
use Izzudin96\Billplz\BillplzClient; $payload = app(BillplzClient::class)->parseWebhook(request()->all()); if ($payload === null) { return response()->json(['message' => 'Invalid payload'], 403); } if (($payload['paid'] ?? false) === true) { // Mark order/booking as paid } return response()->json(['ok' => true]);
End-to-end controller example
<?php namespace App\Http\Controllers; use App\Models\Order; use Izzudin96\Billplz\BillplzClient; use Illuminate\Http\RedirectResponse; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class BillplzPaymentController extends Controller { public function checkout(Order $order, BillplzClient $billplz): RedirectResponse { $bill = $billplz->createBill( email: $order->customer_email, mobile: $order->customer_mobile, name: $order->customer_name, amountCents: (int) round($order->total * 100), callbackUrl: route('payments.billplz.webhook'), description: 'Order '.$order->reference, optional: [ 'redirect_url' => route('payments.billplz.callback'), 'reference_1_label' => 'Order', 'reference_1' => $order->reference, ], ); $order->update([ 'billplz_bill_id' => $bill['id'], 'payment_url' => $bill['url'], ]); return redirect()->away($bill['url']); } public function callback(Request $request, BillplzClient $billplz): RedirectResponse { $redirect = $billplz->parseRedirect($request->query()); if ($redirect === null) { return redirect()->route('orders.index') ->with('error', 'Unable to read payment callback.'); } return redirect()->route('orders.index')->with('status', ($redirect['paid'] ?? false) ? 'Payment received. Waiting confirmation.' : 'Payment not completed.' ); } public function webhook(Request $request, BillplzClient $billplz): JsonResponse { $payload = $billplz->parseWebhook($request->all()); if ($payload === null) { return response()->json(['message' => 'Invalid signature'], 403); } $order = Order::where('billplz_bill_id', $payload['id'] ?? '')->first(); if (! $order) { return response()->json(['message' => 'Order not found'], 404); } if (($payload['paid'] ?? false) === true) { $order->update([ 'payment_status' => 'paid', ]); } return response()->json(['ok' => true]); } }
Suggested routes
use App\Http\Controllers\BillplzPaymentController; Route::post('/payments/{order}/checkout', [BillplzPaymentController::class, 'checkout']) ->name('payments.billplz.checkout'); Route::get('/payments/billplz/callback', [BillplzPaymentController::class, 'callback']) ->name('payments.billplz.callback'); Route::post('/payments/billplz/webhook', [BillplzPaymentController::class, 'webhook']) ->name('payments.billplz.webhook');
Use cases
- Marketplace checkout: create bill per order and reconcile payment on webhook.
- Booking system: attach booking reference via
reference_1and mark booking paid after webhook. - Membership/fees: use best-effort redirect for user messaging, strict webhook for state changes.
- Legacy migration: keep old callback behavior with
parseRedirect(), then gradually move to strict verification.
Notes
- Amount is in cents/sen. Example: RM12.34 =
1234. - Prefer webhook as the authoritative source for payment completion.
- Redirect callback can be delayed, interrupted, or tampered; treat it as user-facing signal only.
- If
BILLPLZ_X_SIGNATUREis not configured, signature checks are skipped andsignature_validisnullin parse methods. createBill()throwsInvalidArgumentExceptionwhen required config or inputs are missing/invalid. Example checks: empty API key, empty collection ID, invalid email, amount <= 0.
Testing
The package includes PHPUnit + Orchestra Testbench tests.
Run tests locally:
composer install
composer test
Current test coverage includes:
createBill()request payload and response handling.- Input validation exceptions for invalid payloads.
- Strict signature verification failure behavior.
- Best-effort parsing behavior for redirect and webhook.
- URL encoding behavior in
getBill().
GitHub Actions CI
CI workflow is available at .github/workflows/tests.yml and runs on:
- push to
main - pull requests
Matrix:
- PHP 8.2
- PHP 8.3
- PHP 8.4
Pipeline steps:
composer validate --strictcomposer installcomposer test