ejoi / payment-gateway
A modular, framework-agnostic PHP payment gateway abstraction with drivers for Malaysian and global providers (CHIP, Billplz, toyyibPay, Stripe, PayPal).
Requires
- php: ^8.2
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- orchestra/testbench: ^9.0
- phpunit/phpunit: ^11.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client auto-discovered by the default HTTP transport
- laravel/framework: For the Laravel service provider, config publishing, and facade
README
A modular PHP payment-gateway abstraction for Malaysian and global providers. Write your checkout code once against a single interface; switch or add gateways with a config change and one small driver class.
Supported drivers: CHIP ยท Billplz ยท toyyibPay ยท Stripe ยท PayPal.
- ๐ docs/GATEWAYS.md โ setup, usage & sandbox verification for every gateway.
- ๐ค handover.md โ full project handover (architecture, decisions, testing, next steps).
- ๐งฉ examples/Laravel โ a business-logic listener example.
Two layers
- Framework-agnostic core โ pure PHP over a PSR-18 HTTP client. One
PaymentGatewayinterface, one driver per provider, normalized DTOs, onePaymentStatusenum. No framework, no database. - Laravel adapter (optional) โ a
paymentsledger, an auto-registered webhook endpoint, an idempotent status flow, domain events, a reconciliation job, and out-of-the-box email notifications.
Every provider follows the same lifecycle; the differences (auth, amount unit, signature scheme, status vocabulary) are absorbed by each driver:
createPayment() โ redirect to hosted page โ verifyCallback() (signed webhook) โ queryStatus() (requery to confirm)
Your application only ever deals with the PaymentGateway
interface, the DTOs (PaymentRequest, PaymentResponse, PaymentStatusResult), and the
PaymentStatus enum.
Requirements
- PHP 8.2+
- Any PSR-18 HTTP client (e.g. Guzzle) โ auto-discovered.
Installation
composer require ejoi/payment-gateway guzzlehttp/guzzle
Laravel
The service provider and facades auto-discover. Publish config + migrations and migrate:
php artisan vendor:publish --tag=payment-gateway-config php artisan vendor:publish --tag=payment-gateway-migrations php artisan migrate
Set credentials in .env (see docs/GATEWAYS.md for each gateway's keys).
Every gateway defaults to sandbox until you set *_SANDBOX=false.
Quick start (Laravel)
use Ejoi\PaymentGateway\Data\{Customer, Money, PaymentRequest}; use Ejoi\PaymentGateway\Laravel\Facades\Payments; use Ejoi\PaymentGateway\Laravel\Events\PaymentStatusChanged; use Ejoi\PaymentGateway\Laravel\Jobs\ReconcilePendingPayments; use Ejoi\PaymentGateway\Enums\PaymentStatus; // 1. Create + persist a payment (swap 'billplz' for any gateway) $payment = Payments::create('billplz', new PaymentRequest( reference: $order->reference, amount: Money::fromMinor(4990, 'MYR'), description: "Order {$order->reference}", customer: new Customer($order->email, $order->name), redirectUrl: route('payment.return', $order), callbackUrl: route('payment-gateway.webhook', 'billplz'), )); return redirect()->away($payment->checkout_url); // 2. The webhook route is auto-registered and does verify โ requery โ persist โ dedupe โ event. // You just react to the outcome: Event::listen(PaymentStatusChanged::class, fn ($e) => $e->payment->status === PaymentStatus::Paid && $order->fulfil() ); // 3. Reconcile stragglers (FPX may not webhook) on a schedule: $schedule->job(ReconcilePendingPayments::class)->everyFiveMinutes()->withoutOverlapping(); // Optional: keep the webhook audit table bounded (90-day default retention). $schedule->command('model:prune', ['--model' => [\Ejoi\PaymentGateway\Laravel\Models\PaymentWebhook::class]])->daily();
On paid/failed the package also emails the merchant and customer (configurable). See
docs/GATEWAYS.md for per-gateway credentials and webhook registration.
Quick start (plain PHP core)
use Ejoi\PaymentGateway\PaymentGatewayManager; use Ejoi\PaymentGateway\Data\CallbackPayload; $manager = new PaymentGatewayManager($config); // $config = the array from config/payment-gateway.php $response = $manager->gateway('billplz')->createPayment($request); header('Location: ' . $response->redirectUrl); // On the webhook: $result = $manager->gateway('billplz')->verifyCallback(CallbackPayload::fromGlobals()); if (! $result->verified) { $result = $manager->gateway('billplz')->queryStatus($result->gatewayReference); } // $result->status is a normalized PaymentStatus โ persist it yourself.
What the Laravel layer gives you
paymentsledger (Paymentmodel) โ the package's own record, linked to your orders byreference.- Auto webhook route
POST /payment-gateway/webhook/{gateway}โ verify โ requery โ persist โ dedupe โ fire event. CSRF-exempt; prefix/middleware configurable; disable withPAYMENT_GATEWAY_WEBHOOK_ROUTE=false. PaymentStatusChangedevent โ fires once per real transition; listen for it to run business logic.ReconcilePendingPaymentsjob โ requeries pending payments on your schedule.- Email notifications โ merchant + customer, on
paid/failed, out of the box (Laravel Notifications). transaction_idโ the provider's charge/transaction id, indexed. All other provider data stays inlast_response(JSON).
The two golden rules
- Trust the server webhook, never the browser return URL.
verifyCallback()returnsverified = falsewhen a signature can't be checked (e.g. toyyibPay) โ then you mustqueryStatus(). - Status updates are idempotent. The webhook and the reconcile job can both land; the package only transitions a non-final payment, so a duplicate is a no-op.
Adding a new gateway
Extend AbstractGateway, implement three methods, register it โ no fork needed:
use Ejoi\PaymentGateway\Gateway\AbstractGateway; final class MyGateway extends AbstractGateway { protected const NAME = 'mygateway'; public function createPayment(PaymentRequest $request): PaymentResponse { /* ... */ } public function verifyCallback(CallbackPayload $payload): PaymentStatusResult { /* ... */ } public function queryStatus(string $gatewayReference): PaymentStatusResult { /* ... */ } } $manager->extend('mygateway', fn ($config, $http) => new MyGateway($config, $http));
Add a config block under gateways and you're done. Mirror
BillplzGateway โ it's the reference implementation.
Architecture
src/
โโโ Contracts/ PaymentGateway (the interface), HttpClient
โโโ Data/ PaymentRequest, PaymentResponse, PaymentStatusResult,
โ CallbackPayload, Customer, Money (immutable DTOs)
โโโ Enums/ PaymentStatus, PaymentMethod, Currency
โโโ Config/ GatewayConfig Http/ PsrHttpClient, HttpResponse
โโโ Support/ Signature (constant-time HMAC) Exceptions/ (typed hierarchy)
โโโ Gateway/
โ โโโ AbstractGateway.php
โ โโโ Drivers/ Chip ยท Billplz ยท Toyyibpay ยท Stripe ยท Paypal
โโโ Laravel/ ServiceProvider ยท Facades ยท Payments ยท Models (Payment, PaymentWebhook)
โ Http/PaymentWebhookController ยท Events ยท Listeners ยท Jobs ยท CallbackPayloadFactory
โโโ PaymentGatewayManager.php (resolves drivers by name from config)
config/ ยท database/migrations/ ยท tests/ ยท docs/GATEWAYS.md ยท handover.md
Provider notes
| Gateway | Amount unit | Callback signature | Notes |
|---|---|---|---|
| CHIP | minor (sen) | RSA public-key signature | Cross-border & crypto capable |
| Billplz | minor (sen) | HMAC-SHA256 x_signature |
MYR only; webhook mandatory |
| toyyibPay | minor (sen) | none โ always requery | MYR only; cheapest FPX |
| Stripe | minor (cents) | Stripe-Signature (HMAC + 300s window) |
Hosted Checkout Session |
| PayPal | decimal string | verify-webhook-signature API | Orders v2 (capture on requery) |
Testing
composer install php vendor/bin/phpunit
The suite covers all five drivers (with signature verification), Money, the manager, and the
Laravel persistence/notification flow (via orchestra/testbench on in-memory SQLite). Drivers are
unit-tested with a FakeHttpClient (no network). See handover.md for details.
License
MIT.