Search by

bayarcash / laravel

webimpian

Laravel integration for the Bayarcash payment gateway.

Package info

github.com/bayarcash/laravel

pkg:composer/bayarcash/laravel

Statistics

Installs: 628

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.4.0 2026-11-03 01:56 UTC

This package is auto-updated.

Last update: 2026-11-03 02:00:01 UTC


README

Latest Version on Packagist Total Downloads PHP Version License Tests

A Laravel integration for the Bayarcash payment gateway. It wraps the framework-agnostic bayarcash/php-sdk and adds a Laravel-idiomatic developer experience: config, a facade, a model trait, optional database persistence, automatic callback/return handling with checksum verification, scheduled reconciliation, and events.

Targets Bayarcash API v3.

It fits two setups:

  • Single merchant — one set of credentials in .env. Everything in Usage works out of the box.
  • Multi-tenant (SaaS) — each tenant has its own Bayarcash account with credentials stored in your database. See Multi-tenant.

Either way you choose whether to store payment records in your database with BAYARCASH_STORE_RECORDS.

Requirements

  • PHP 8.1+
  • Laravel 10, 11, 12, or 13

Installation

composer require bayarcash/laravel

Publish the config file:

php artisan vendor:publish --tag=bayarcash-config

Publish and run the migrations (skip if you want stateless mode):

php artisan vendor:publish --tag=bayarcash-migrations
php artisan migrate

Configuration

Add your credentials to .env:

BAYARCASH_API_TOKEN=your-personal-access-token
BAYARCASH_API_SECRET_KEY=your-api-secret-key
BAYARCASH_SANDBOX=true

# Optional
BAYARCASH_STORE_RECORDS=true
BAYARCASH_CALLBACK_PATH=bayarcash/callback
BAYARCASH_RETURN_PATH=bayarcash/return
BAYARCASH_RETURN_REDIRECT=payments.thank-you   # named route or absolute URL
BAYARCASH_RETURN_INCLUDE_REFERENCE=true        # add the payment reference to the redirect
BAYARCASH_RECONCILE=true

In the Bayarcash portal, point your portal's URLs at the package routes:

  • Callback URL → https://your-app.test/bayarcash/callback
  • Return URL → https://your-app.test/bayarcash/return

Usage

1. Make a model payable

Add the HasBayarcashPayments trait to any Eloquent model (an Order, User, Invoice, ...):

use Bayarcash\Laravel\Concerns\HasBayarcashPayments;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    use HasBayarcashPayments;
}

This adds payments() and mandates() relations plus the charge() and enrollDirectDebit() helpers.

2. Create a payment

use Bayarcash\Bayarcash;

$intent = $order->charge([
    'portal_key'             => 'your_portal_key',
    'payment_channel'        => Bayarcash::FPX,
    'order_number'           => $order->reference, // optional; auto-generated when omitted
    'amount'                 => '10.00',
    'payer_name'             => $order->customer_name,
    'payer_email'            => $order->customer_email,
    'payer_telephone_number' => $order->customer_phone,
]);

// Redirect the customer to the hosted checkout.
return redirect()->away($intent->url);

The checksum is generated for you. When record storage is enabled, a pending BayarcashTransaction is stored and linked to $order, and a PaymentCreated event fires.

3. Enrol a Direct Debit mandate

use Bayarcash\FpxDirectDebit;

$mandate = $order->enrollDirectDebit([
    'portal_key'             => 'your_portal_key',
    'amount'                 => '10.00',
    'payer_name'             => 'Ahmad bin Abdullah',
    'payer_id_type'          => FpxDirectDebit::NRIC,
    'payer_id'               => '900101011234',
    'payer_email'            => 'ahmad@example.com',
    'payer_telephone_number' => '0123456789',
    'application_reason'      => 'Monthly subscription',
    'frequency_mode'         => FpxDirectDebit::MODE_MONTHLY,
]);

return redirect()->away($mandate->url);

Bayarcash sends every mandate callback to the mandate's return_url, so the package sets it to the callback route unless you pass your own. It also cleans payer_name and payer_telephone_number the way Bayarcash does before signing.

The mandate's status follows its callbacks:

Callback Mandate status Event
Bank verification (RM1) paid STATUS_WAITING_APPROVAL MandateAuthorized
Bank approves STATUS_ACTIVE MandateApproved
Bank rejects the enrolment STATUS_REJECTED or STATUS_ERROR MandateRejected
Termination approved, including one the payer starts at their bank STATUS_TERMINATED MandateTerminated

Each deduction Bayarcash makes on the mandate arrives at the same callback route:

  • It is recorded as a payment: a BayarcashTransaction with payment_channel 3 (FPX Direct Debit).
  • It takes the mandate's owner and order number, and stores its mandate_id and cycle.
  • It fires PaymentSucceeded or PaymentFailed like any other payment.
  • Check isDirectDebit() when a listener should treat it differently.
  • Its paid_at is the date Bayarcash took the deduction, not when the callback arrived.
  • A cancelled deduction (Bayarcash deleted the billing file) is recorded but fires no event.
$order->payments()->directDebit()->get();   // every deduction for this owner
$mandate->payments()->successful()->get();  // deductions taken on one mandate
$transaction->mandate;                      // the BayarcashMandate it came from

To stop a mandate, cancel it:

use Bayarcash\Laravel\Exceptions\MandateNotCancellable;

try {
    $mandate->cancel(); // deactivated on Bayarcash, fires MandateTerminated
} catch (MandateNotCancellable $e) {
    // Still waiting for the bank: try again once it is active.
}
  • An active mandate is deactivated on Bayarcash and becomes STATUS_INACTIVE.
  • One Bayarcash already stopped just takes that status.
  • One still waiting for the bank throws and stays as it is, because Bayarcash may still deduct from it.

Missed callbacks

Bayarcash drops a mandate callback after five failed attempts. The bayarcash:sync-mandates command, scheduled twice a day, asks Bayarcash directly:

  • Enrolments still waiting for the bank (between an hour and 30 days old) are approved or rejected.
  • A few active mandates per run are re-checked, and ones the payer stopped at their bank are terminated.
  • Deductions from the last 14 days that never arrived are recorded, through the same code as the callback.

Only mandates stored by this app are touched. Tune it under mandate_sync in config/bayarcash.php, or turn it off with BAYARCASH_MANDATE_SYNC=false.

4. Handle results

The package registers two routes automatically — you do not write controllers:

Route Method Purpose
/bayarcash/callback POST Server-to-server, authoritative. Checksum-verified (invalid → 403). Updates the transaction and fires the status event.
/bayarcash/return GET Browser redirect, best-effort. Verifies the checksum when present, never aborts, then redirects (or returns JSON).

Set BAYARCASH_RETURN_REDIRECT to a named route or URL to control where the customer lands after payment. When it is null, the return route responds with JSON.

When the return's checksum verifies, the redirect carries the payment reference as query parameters, read from the recorded transaction:

Parameter Value
order_number Your order number
transaction_id The Bayarcash transaction id, when known
status The recorded status code (3 = successful, 2 = failed, ...)
https://your-app.test/payments/thank-you?order_number=INV-123&transaction_id=trx_abc&status=3

A named route receives them as route parameters, so a route such as payments/{order_number} is filled in and anything else goes on the query string. An unverified return, or one without a checksum, redirects with no parameters. Treat them as a pointer to the payment, not as proof of payment: look the order up and trust its stored status (the callback is authoritative). Set BAYARCASH_RETURN_INCLUDE_REFERENCE=false to redirect without them.

5. Listen for events

use Bayarcash\Laravel\Events\PaymentSucceeded;
use Illuminate\Support\Facades\Event;

Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) {
    $event->transaction->owner->markPaid();
});

Available events:

Event Payload
PaymentCreated ->transaction
PaymentSucceeded ->transaction (also fires for each Direct Debit deduction)
PaymentFailed ->transaction (also fires for each Direct Debit deduction)
PaymentCancelled ->transaction (also fires for an abandoned payment, status 5)
MandateAuthorized ->mandate
MandateApproved ->mandate
MandateRejected ->mandate
MandateTerminated ->mandate
WebhookReceived ->recordType, ->payload

6. Query stored records

$order->payments()->successful()->get();
$order->payments()->pending()->get();

$transaction->statusLabel(); // "Successful", "Pending", ...

Reconciliation

Callbacks and return redirects can be missed (downtime, network issues). The package ships a bayarcash:reconcile command that re-queries pending payments and auto-cancels stale ones. It is scheduled automatically (every minute) when BAYARCASH_RECONCILE=true — you only need Laravel's scheduler running:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

You can also run it manually:

php artisan bayarcash:reconcile

or from your own code, including a web request such as an admin "Run reconcile now" button:

use Illuminate\Support\Facades\Artisan;

Artisan::call('bayarcash:reconcile');
$summary = Artisan::output(); // "Bayarcash reconcile: 2 re-queried, 1 auto-cancelled."

Reconciliation is multi-tenant aware: each pending payment is re-queried, and auto-cancelled once stale, with the credentials of the tenant stored on it (tenant_id), resolved through your CredentialResolver. Each tenant's credentials are resolved once per run, and payments without a tenant use the .env credentials.

Reconciliation requires stored records.

Store records (store data, or pass-through)

BAYARCASH_STORE_RECORDS decides whether the package keeps a local copy of every payment and mandate in your database.

BAYARCASH_STORE_RECORDS=true (default) — stateful

Transactions (including Direct Debit deductions) and mandates are recorded in the bayarcash_transactions and bayarcash_mandates tables. This is what you get:

Capability What happens
Pending row on charge() A BayarcashTransaction is created, linked to the payable model ($order->payments()), storing the payment_intent_id so the callback can complete the same row.
Automatic webhook writes Callbacks update the record's status, set paid_at on success, and store the verified payload in raw_callback.
Queryable history $order->payments()->successful(), ->pending(), status labels, reporting — no extra API calls.
Reconciliation bayarcash:reconcile can re-query and auto-cancel stale pending payments (it needs the stored rows to know what to reconcile).

Requires the migrations:

php artisan vendor:publish --tag=bayarcash-migrations
php artisan migrate

Use this mode when you want an authoritative local record of payments (reporting, reconciliation, linking payments to your models) — the typical SaaS setup.

BAYARCASH_STORE_RECORDS=false — stateless (pass-through)

The package becomes a thin SDK wrapper. charge() / enrollDirectDebit() create the intent and return it without touching the database, and the callback/return routes still verify checksums and fire events — they just skip persistence. No migrations are needed, and bayarcash:reconcile is disabled. Use this when you already store payment state yourself and only want checksum-safe request/callback handling.

Multi-tenant (credentials in the database)

In a SaaS app each tenant has its own Bayarcash account. Store each tenant's credentials in your own table and let the package resolve them per request. There is one shared webhook for every tenant — no tenant id in the URL.

1. Store per-tenant credentials

The package ships an encrypted bayarcash_accounts table for this. Publish and run it:

php artisan vendor:publish --tag=bayarcash-tenant-migrations
php artisan migrate

Then store each tenant's credentials — token and secret_key are encrypted at rest:

use Bayarcash\Laravel\Models\BayarcashAccount;

BayarcashAccount::create([
    'tenant_id'  => $tenant->id,
    'token'      => $token,
    'secret_key' => $secretKey,
    'sandbox'    => false,
]);

2. Turn multi-tenant on

Point the package at its built-in resolver and enable multi-tenant:

// config/bayarcash.php
'credential_resolver' => \Bayarcash\Laravel\DatabaseCredentialResolver::class,
BAYARCASH_MULTI_TENANT=true

Credentials already elsewhere? If your tenants' Bayarcash credentials live on your own model/table, skip the migration and implement the resolver yourself instead — return token, secret_key, and sandbox for a tenant:

use Bayarcash\Laravel\Contracts\CredentialResolver;

class MyCredentialResolver implements CredentialResolver
{
    public function resolve(mixed $tenant = null): array
    {
        $account = BayarcashAccount::where('tenant_id', $tenant)->firstOrFail();

        return [
            'token'      => $account->token,
            'secret_key' => $account->secret_key,
            'sandbox'    => (bool) $account->sandbox,
        ];
    }
}

3. Create payments per tenant

Pass the tenant to charge() / enrollDirectDebit(). The package generates the checksum and calls the gateway with that tenant's credentials, and stamps the stored row with tenant_id:

$intent = $order->charge($data, tenant: $tenantId);

With no tenant: argument the default .env credentials are used — so single- and multi-tenant code live side by side.

4. One shared webhook for every tenant

Point every tenant's portal Callback/Return URLs at the same package routes — https://your-app.test/bayarcash/callback and .../bayarcash/return. There is no per-tenant URL.

The package matches each callback to its local record, resolves that tenant's secret, and verifies the checksum — rejecting with 403 (fail closed) when no record matches. This lookup is why multi-tenant mode requires BAYARCASH_STORE_RECORDS=true.

5. Reconciliation and portals per tenant

bayarcash:reconcile re-queries and cancels each payment with its own tenant's credentials — nothing extra to configure. To check a tenant's credentials, pass the tenant to the portal helpers:

use Bayarcash\Laravel\Facades\Bayarcash;

Bayarcash::hasPortal($portalKey, $tenantId); // true when the token owns that portal

The facade

For direct, lower-level access to the SDK:

use Bayarcash\Laravel\Facades\Bayarcash;

$portals      = Bayarcash::getPortals();
$transaction  = Bayarcash::getTransaction('transaction_id');
$intent       = Bayarcash::sdk()->getPaymentIntent('payment_intent_id');

The facade proxies to the underlying Bayarcash\Bayarcash client (pinned to API v3).

Portals

use Bayarcash\Laravel\Facades\Bayarcash;

$portals = Bayarcash::portals();              // Collection of PortalResource, every page
$portals = Bayarcash::portals($tenantId);     // with a tenant's credentials

Bayarcash::hasPortal('your_portal_key');            // bool, one lookup by portal key
Bayarcash::hasPortal('your_portal_key', $tenantId);

hasPortal() suits a "test connection" button: false means the token works but does not own that portal, while a rejected token or an unreachable gateway throws (see Error handling).

Error handling

SDK calls throw typed exceptions you can catch around charge() / enrollDirectDebit():

use Bayarcash\Exceptions\ValidationException;
use Bayarcash\Exceptions\FailedActionException;

try {
    $intent = $order->charge([...]);
} catch (ValidationException $e) {
    $errors = $e->errors(); // 422
} catch (FailedActionException $e) {
    report($e); // 400
}

Testing

composer install
vendor/bin/phpunit

License

The MIT License (MIT).