ejoi/payment-gateway

A modular, framework-agnostic PHP payment gateway abstraction with drivers for Malaysian and global providers (CHIP, Billplz, toyyibPay, Stripe, PayPal).

Maintainers

Package info

github.com/ejoi8/payment-gateway

pkg:composer/ejoi/payment-gateway

Transparency log

Statistics

Installs: 43

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-07 15:53 UTC

This package is auto-updated.

Last update: 2026-07-11 09:26:58 UTC


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

  1. Framework-agnostic core โ€” pure PHP over a PSR-18 HTTP client. One PaymentGateway interface, one driver per provider, normalized DTOs, one PaymentStatus enum. No framework, no database.
  2. Laravel adapter (optional) โ€” a payments ledger, 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

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

  • payments ledger (Payment model) โ€” the package's own record, linked to your orders by reference.
  • Auto webhook route POST /payment-gateway/webhook/{gateway} โ€” verify โ†’ requery โ†’ persist โ†’ dedupe โ†’ fire event. CSRF-exempt; prefix/middleware configurable; disable with PAYMENT_GATEWAY_WEBHOOK_ROUTE=false.
  • PaymentStatusChanged event โ€” fires once per real transition; listen for it to run business logic.
  • ReconcilePendingPayments job โ€” 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 in last_response (JSON).

The two golden rules

  1. Trust the server webhook, never the browser return URL. verifyCallback() returns verified = false when a signature can't be checked (e.g. toyyibPay) โ€” then you must queryStatus().
  2. 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.