Writing a custom gateway
Parakit ships five drivers (FIB, ZainCash, Nass Pay, Nass Wallet, FastPay). To add a provider it does not cover, implement the PaymentGateway contract and register it with Payment::extend().
// In a service provider's boot()
use Froshly\Parakit\Facades\Payment;
Payment::extend('acmepay', fn ($cfg, $container, $name) => new AcmePayGateway($name, $cfg));After that, a config entry with 'driver' => 'acmepay' charges through your class just like a built-in driver.
The contract
A gateway must implement three methods:
namespace Froshly\Parakit\Contracts;
use Illuminate\Http\Request;
use Froshly\Parakit\DTOs\PaymentRequest;
use Froshly\Parakit\DTOs\PaymentResponse;
use Froshly\Parakit\DTOs\WebhookPayload;
interface PaymentGateway
{
public function charge(PaymentRequest $request): PaymentResponse;
public function handleWebhook(Request $request): WebhookPayload;
public function name(): string;
}charge()takes the canonicalPaymentRequest(amount in minor units) and returns aPaymentResponse.handleWebhook()verifies an incoming gateway callback and returns aWebhookPayload. ThrowInvalidWebhookSignatureExceptionwhen verification fails — the webhook controller turns that into a 401.name()returns the gateway's identity string.
You can implement the contract directly, but most gateways should extend AbstractGateway instead.
Extending AbstractGateway
Froshly\Parakit\Gateways\AbstractGateway implements PaymentGateway and gives you the reliability layer for free. Its constructor signature is:
public function __construct(
protected readonly string $gatewayName,
protected readonly array $config,
) { /* ... */ }Extend it and you get:
- Idempotency —
charge()derives a key, checks a per-gateway cache, and returns the cachedPaymentResponseon a repeat call. - A write-ahead transaction — a
Pendingpayment_transactionsrow is persisted before the gateway call, so a crash or a racing webhook still has a row to land on. - Retries with backoff — transient failures (
GatewayUnavailableException) are retried perparakit.reliability.retry. - A circuit breaker — keyed by your gateway name;
charge()throwsGatewayUnavailableExceptionwhile the breaker is open. - The
PaymentInitiatedevent, fired exactly once percharge()call.
In exchange, you implement two abstract methods instead of charge() itself:
abstract protected function performCharge(PaymentRequest $request): PaymentResponse;
abstract public function handleWebhook(Request $request): WebhookPayload;AbstractGateway::charge() wraps performCharge() with everything above. name() is already implemented — it returns the name passed to the constructor.
WARNING
Throw GatewayUnavailableException from performCharge() only for genuinely transient failures (timeouts, 5xx, network errors). It is the one exception the retry loop catches. Any other Throwable marks the transaction failed and propagates immediately — no retry.
Skeleton
namespace App\Payments;
use DateTimeImmutable;
use Illuminate\Http\Request;
use Froshly\Parakit\DTOs\PaymentRequest;
use Froshly\Parakit\DTOs\PaymentResponse;
use Froshly\Parakit\DTOs\WebhookPayload;
use Froshly\Parakit\Enums\PaymentStatus;
use Froshly\Parakit\Exceptions\InvalidWebhookSignatureException;
use Froshly\Parakit\Gateways\AbstractGateway;
final class AcmePayGateway extends AbstractGateway
{
protected function performCharge(PaymentRequest $request): PaymentResponse
{
// 1. Map PaymentRequest onto AcmePay's API shape.
// 2. Call the provider. Throw GatewayUnavailableException on a
// transient failure so the base class retries it.
// 3. Map the provider response onto a PaymentResponse.
return new PaymentResponse(
success: true,
gateway: $this->name(), // the config key
gatewayTransactionId: $raw['id'],
reference: $request->reference,
status: PaymentStatus::Pending,
amount: $request->amount,
currency: $request->currency,
correlationId: $this->correlationId(),
redirectUrl: $raw['checkout_url'] ?? null,
raw: $raw,
);
}
public function handleWebhook(Request $request): WebhookPayload
{
// Verify the signature first; throw on failure.
if (! $this->signatureValid($request)) {
throw new InvalidWebhookSignatureException('AcmePay signature mismatch');
}
return new WebhookPayload(
gateway: $this->name(),
gatewayTransactionId: (string) $request->input('id'),
reference: (string) $request->input('order_id'),
status: PaymentStatus::Paid,
amount: (int) $request->input('amount'),
currency: $request->currency,
eventId: (string) $request->input('event_id'),
occurredAt: new DateTimeImmutable(),
raw: $request->all(),
);
}
}Use $this->config for credentials, $this->name() for the identity string, and $this->correlationId() for the current request's correlation id. FibGateway (src/Gateways/Fib/FibGateway.php) is a complete worked reference.
Optional capabilities
Implement these only when the provider supports the feature. Parakit checks for the interface with instanceof before calling, so a gateway that does not implement one simply does not offer that operation.
| Interface | Adds |
|---|---|
SupportsRefund | refund(RefundRequest): RefundResponse |
SupportsCancel | cancel(string $gatewayTransactionId): PaymentResponse |
SupportsStatusCheck | status(string $gatewayTransactionId): PaymentResponse — used by parakit:transactions:sweep-pending to recover stuck payments |
SupportsTokenization | tokenize(PaymentRequest): string and chargeToken(string, int, Currency): PaymentResponse |
use Froshly\Parakit\Contracts\SupportsRefund;
use Froshly\Parakit\Contracts\SupportsStatusCheck;
final class AcmePayGateway extends AbstractGateway implements SupportsRefund, SupportsStatusCheck
{
// ... performCharge(), handleWebhook(), plus refund() and status()
}Registering the driver
Payment::extend() maps a driver type to a factory closure:
public function extend(string $driver, Closure $creator): voidThe closure receives three arguments, in this order:
array $cfg— the resolved config array for the named config being built.Illuminate\Contracts\Container\Container $container— the app container, for resolving dependencies.string $name— the config key (the merchant identity), which is what you pass as the gateway name.
use Froshly\Parakit\Facades\Payment;
Payment::extend('acmepay', function (array $cfg, $container, string $name) {
return new AcmePayGateway($name, $cfg);
});WARNING
Pass $name — not 'acmepay' — as the gateway name. The driver type is shared by every config that uses it; the name is the per-merchant identity that keys the circuit breaker, idempotency cache, and webhook route. Hard-coding the driver type collapses every merchant onto one identity. See Multi-tenant merchants.
Then add a config entry that points driver at your registered type:
// config/parakit.php
'gateways' => [
'acmepay' => [
'driver' => 'acmepay',
'base_url' => env('ACMEPAY_BASE_URL'),
'api_key' => env('ACMEPAY_API_KEY'),
],
],Charge with Payment::driver('acmepay'). A custom driver type has no built-in config check, so parakit:doctor reports it as unverified and asks you to confirm the config by hand.
Error mapping
The provider returns its own error codes; Parakit's retry logic and your localised messages both depend on those codes being mapped to a Froshly\Parakit\Enums\PaymentErrorCode. The mapping decides two things:
- Whether a failure retries. A code you treat as transient (mapped to a
GatewayUnavailableException) is retried with backoff; anything else is not. - What the customer sees.
PaymentErrorCodedrives the localised (en/ar/ckb)PaymentErrormessage.
src/Gateways/Fib/FibErrorMap.php and src/Gateways/ZainCash/ZainCashErrorMap.php show the shipped pattern — a dedicated map class keyed by the provider's raw codes.
DANGER
When a code is ambiguous, map it to a permanent error. A missed retry is safer than retrying a request that already charged the customer.