Development
Gateway development
Build a payment gateway package: files, manifest, settings, checkout and callbacks.
A gateway package connects Paybob to one payment provider. This guide builds one step by step. Every method, field type and helper is listed in the Gateway reference.
How a gateway fits in
When a customer picks your gateway at checkout, Paybob sends them to your package's checkout page. From there, your package decides what happens: redirect to the provider, show payment instructions, ask for a transaction ID. When the payment is confirmed, your package calls markAsCompleted(), and Paybob does the rest: updates the transaction, fires events, sends the merchant's webhook and settles the invoice.
Each installed gateway gets three public addresses, under the Brand's path:
| Route | Address | Your method |
|---|---|---|
| Checkout | /{brand}/gateways/{g_id}/{t_id} (GET and POST) | checkout() |
| Status | /{brand}/gateways/{g_id}/{t_id}/status (GET and POST) | status() |
| IPN | /{brand}/gateways/{g_id} (any method) | ipn() |
g_id identifies the installed gateway and t_id the transaction. Implement only what your provider needs: a route you don't implement returns "not found".
Package structure
gateways/my-gateway/
├── ProcessPayment.php ← required: your gateway class
├── assets/ ← required (can be empty): logo, CSS, JS, images
├── views/ ← optional: Blade views
│ ├── checkout.blade.php
│ └── success.blade.php
├── lang/ ← optional: translations
│ ├── en.php
│ └── bn.php
└── src/ ← optional: extra classesThe folder name is the gateway's slug: lowercase letters, numbers and single hyphens, such as my-gateway.
Step 1: The gateway class
ProcessPayment.php declares a class named ProcessPayment, in the namespace Gateways\ plus your slug in StudlyCase, extending Paybob's Gateway base class:
<?php
namespace Gateways\MyGateway;
use App\Support\Gateway\Gateway;
use App\Support\Gateway\GatewayMetadata;
class ProcessPayment extends Gateway
{
public static function metadata(): GatewayMetadata
{
return GatewayMetadata::make()
->name('My Gateway')
->logo('assets/logo.png')
->currency('USD')
->api()
->version('1.0.0');
}
}That's already a valid package: it appears in Gateways → Create New.
Metadata
| Method | Meaning |
|---|---|
name() | The package's name. Admins can give each installed gateway its own display name. |
logo() | The default logo, relative to your package. Admins can upload their own. |
currency() | The one currency your provider charges in. Payments in other currencies are converted for you. |
api(), manual($senderId) or custom() | How the gateway works (exactly one). |
version() | Your package's version, such as 1.0.0. Uploading a higher version updates installed copies. |
| Mode | Use for |
|---|---|
api() | A provider with an API: hosted checkout pages, payment intents, callbacks. |
manual('sender-id') | A wallet or account confirmed from payment SMS. The argument is the fixed sender ID an SMS filter must use. |
custom() | Anything else, such as blockchain verification. |
The mode sets expectations only: any gateway can implement any of the three routes.
Classes in src/ load automatically under the same namespace: src/Services/ApiClient.php is Gateways\MyGateway\Services\ApiClient.
Step 2: Configuration
Return a form from config() for the settings the admin fills in, such as API keys. It appears under Configuration on the gateway's settings page, after the standard settings every gateway has (display name, limits, schedule, fees, discounts).
use App\Support\Gateway\Forms\Field;
use App\Support\Gateway\Forms\Form;
public function config(): ?Form
{
return Form::make([
Field::make('mode')
->select(['live' => 'Live', 'test' => 'Test'])
->default('test')
->required(),
Field::make('api_key')
->label('API Key')
->required(),
Field::make('api_secret')
->label('API Secret')
->password()
->revealable()
->required(),
Field::make('webhook_url')
->label('Webhook URL')
->value($this->ipnUrl())
->suffixCopy()
->disabled()
->columnSpanFull()
->helperText('Copy this URL into your provider dashboard.'),
]);
}- Password fields are encrypted when saved. Make any other field encrypted with
->encrypted(). - A
->value()field shows a computed, read-only value, such as the callback URL to register with the provider. It's never saved. - Read a saved value with
$this->configValue('api_key'). Encrypted values come back decrypted.
Group fields into tabs, show fields only when another field has a certain value, and let admins enter lists. See Configuration forms.
Step 3: Checkout
checkout() runs when the customer opens your gateway. $this->payment() is the transaction, already converted into your currency, with your gateway's fees and discounts applied:
public function checkout(): mixed
{
$payment = $this->payment();
return $this->view('checkout', [
'amount' => $this->totalAmount($payment),
'currency' => $payment->currency,
'merchantName' => $this->merchantName(),
'merchantLogo' => $this->merchantLogoUrl(),
]);
}$this->view('checkout', [...]) renders views/checkout.blade.php. Charge exactly $this->totalAmount($payment): the amount after conversion, discounts and fees, rounded to two decimals.
Don't save the checkout payment
During checkout(), $this->payment() is a preview that isn't saved: the customer might still switch to another gateway. Never call save(), update() or fresh() on it. The transaction is written only when you call markAsCompleted() or markAsPending().
Only a transaction that hasn't been paid yet reaches checkout(). Once it's pending or completed, Paybob sends the customer to your status() page instead.
Use the merchant's branding
Show the Brand's own name and logo, never your provider's or Paybob's. $this->merchant() gives you everything from the Brand's General settings, ready to display: name, logo, favicon, address, support contacts, social links and SEO. See Merchant profile.
Step 4a: An API gateway
For a provider with a hosted payment page, create the provider's payment in checkout() and redirect the customer to it:
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
public function checkout(): mixed
{
$payment = $this->payment();
$reference = 'MG'.Str::upper(Str::random(20));
// Remember the provider reference, to match the callback later.
$this->updatePaymentLog(['provider_reference' => $reference]);
$response = Http::withToken($this->configValue('api_secret'))
->post('https://api.provider.example/payments', [
'reference' => $reference,
'amount' => $this->totalAmount($payment),
'currency' => $payment->currency,
'return_url' => $this->statusUrl($payment),
'callback_url' => $this->ipnUrl(),
]);
if (! $response->successful() || blank($response->json('checkout_url'))) {
return $this->view('error', ['message' => $this->lang('errors.provider_unavailable')]);
}
return new RedirectResponse($response->json('checkout_url'));
}Then confirm the payment when the customer comes back (status()) or when the provider calls you (ipn()):
public function ipn(): mixed
{
// ipn() has no transaction in its address: find it from the provider's data.
$reference = request()->input('reference');
// Never trust the callback itself. Ask the provider.
$check = Http::withToken($this->configValue('api_secret'))
->get("https://api.provider.example/payments/{$reference}");
if ($check->json('status') === 'paid') {
$tId = $this->findTransactionByReference($reference); // your own lookup
$result = $this->markAsCompleted(
payment: $tId,
transactionId: $check->json('id'),
);
}
return response('ok'); // acknowledge, even for a repeat delivery
}The rules for an API gateway:
- Keep the transaction unpaid while the customer is at the provider. Being redirected isn't payment: don't call
markAsPending()just to start a checkout. - Create a fresh provider reference on every attempt, and store it with
updatePaymentLog(). Never reuse an old provider checkout URL. - Treat the browser return and the IPN as untrusted. Before completing, verify with the provider server to server when its API allows it: the reference, the transaction ID, the amount, the currency and a successful status.
- Handle repeat deliveries. Providers resend callbacks.
markAsCompleted()won't complete a transaction twice; acknowledge the repeat anyway. - Show safe messages. Never show raw provider responses, exception messages or credentials to the customer. For a failed or cancelled payment, show a translated message and a link back to
$this->checkoutUrl()so the customer can choose another method.
Step 4b: A manual (SMS-verified) gateway
A manual gateway shows the customer where to send money and confirms the payment against the SMS the merchant received. Declare its fixed sender ID with ->manual('my-wallet'): the merchant creates an SMS filter with the same Sender ID.
The simplest flow asks for the transaction ID:
public function checkout(): mixed
{
$payment = $this->payment();
if (request()->isMethod('post')) {
$result = $this->verifyTransactionID(
transactionId: request()->input('transaction_id'),
expectedAmount: $this->totalAmount($payment),
);
if (! $result->verified()) {
return $this->view('checkout', [
'error' => $this->lang('errors.'.$result->reason()),
]);
}
$completion = $this->markAsCompleted(
payment: $payment->t_id,
transactionId: $result->transactionId(),
senderNumber: $result->senderNumber(),
);
if ($completion['completed']) {
return $this->redirectToStatusURL($payment);
}
return $this->view('checkout', ['error' => $this->lang('errors.'.$completion['reason'])]);
}
return $this->view('checkout', [
'number' => $this->configValue('wallet_number'),
'amount' => $this->totalAmount($payment),
]);
}The view posts the form back to $this->gatewayCheckoutUrl($payment).
verifyTransactionID() finds an approved SMS with that transaction ID, from your sender ID, in the right currency, for the right amount, received after the customer opened your checkout. On success it marks the SMS as used, so the same ID can't pay twice. On failure, reason() tells you why: not_found, amount_mismatch, currency_mismatch, already_used, duplicate_transaction_id and others.
Paybob also gives you fully automatic verification, without a form: by a reserved unique amount, or by the customer's sender number. The gateways that come with Paybob let the merchant choose. See Automatic verification.
Read a real package
The gateways that ship with Paybob are complete, working examples. gateways/bkash-personal/ shows every manual verification method, a payment window with a countdown, translations in four languages and a language switcher.
Step 5: The status page
status() is where customers land after paying. Show the result, never complete a payment here based on the address alone:
public function status(): mixed
{
$payment = $this->payment();
return $this->view('success', [
'completed' => $payment?->status?->value === 'completed',
'merchantName' => $this->merchantName(),
]);
}If the transaction has a return address, the status view receives it as $redirectUrl. Offer it as an optional "Back to site" button, never an automatic redirect.
Step 6: Translations
Customers can switch between English, Bangla, Hindi and Urdu, and their choice carries across Paybob's pages. Put your text in lang/{locale}.php files:
// lang/en.php
return [
'checkout' => ['title' => 'Pay with :method'],
'errors' => ['not_found' => 'We could not find this transaction.'],
];$this->lang('checkout.title', ['method' => 'My Wallet']);A missing translation falls back to English, then to the key. ?lang=bn on your checkout or status address switches language with no code from you. See Languages.
Step 7: Assets
Put your logo, CSS, JavaScript and images in assets/. Link them with $this->asset('css/checkout.css'). Paybob copies them to the public folder when an admin creates the gateway; only safe file types are published.
Step 8: Package and install
ZIP the folder so the ZIP contains one top-level folder named after the slug:
cd gateways && zip -r my-gateway.zip my-gatewayIn the panel, Gateways → Upload Gateway installs the files, then Create New adds it to a Brand. To release an update, raise version() and upload again: installed gateways keep their settings.
Checklist
- The namespace is
Gateways\plus the StudlyCase slug. metadata()declares a name, currency, version and exactly one mode.- Secrets are password or encrypted fields, and never logged or shown.
- Amounts come from
$this->totalAmount($payment). - Payments are confirmed only with
markAsCompleted()/markAsPending(), and you check their result. - API callbacks are verified with the provider before completing, and repeat callbacks are acknowledged.
- Customer pages use the merchant's name and logo, and translated text.
- The ZIP has one top-level folder named after the slug.