Overview
Need to integrate a payment gateway that isn’t officially supported? This guide will walk you through creating a custom gateway implementation that seamlessly integrates with Eufaturo Billing.All gateways implement
PaymentGatewayInterface, ensuring consistent behavior across all payment providers.When to Create a Custom Gateway
Create a custom gateway when you need to:- ✅ Integrate a regional payment gateway (e.g., Razorpay, Mercado Pago)
- ✅ Support a niche payment method
- ✅ Build internal payment processing
- ✅ Wrap a third-party service
- ✅ Create a test/mock gateway for development
Architecture
Gateway Components
A complete gateway integration consists of:- Gateway Class - Implements
PaymentGatewayInterface - Response DTOs - Structured responses (CheckoutResponse, PaymentResponse, etc.)
- Webhook Handler - Processes gateway webhook events
- Service Provider - Registers gateway with Laravel
- Configuration - API keys and settings
- Tests - Unit and integration tests
Step 1: Create Package Structure
Directory Structure
Copy
packages/billing-custom/
├── src/
│ ├── CustomGateway.php
│ ├── CustomWebhookHandler.php
│ ├── CustomServiceProvider.php
│ ├── Exceptions/
│ │ └── CustomGatewayException.php
│ └── Http/
│ └── Controllers/
│ └── CustomWebhookController.php
├── config/
│ └── billing-custom.php
├── routes/
│ └── web.php
├── tests/
│ ├── Feature/
│ └── Unit/
├── composer.json
└── README.md
Composer Configuration
Copy
{
"name": "your-vendor/billing-custom",
"description": "Custom payment gateway for Eufaturo Billing",
"type": "library",
"require": {
"php": "^8.4",
"eufaturo/billing": "^1.0"
},
"require-dev": {
"orchestra/testbench": "^9.0",
"pestphp/pest": "^3.0"
},
"autoload": {
"psr-4": {
"YourVendor\\BillingCustom\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"YourVendor\\BillingCustom\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"YourVendor\\BillingCustom\\CustomServiceProvider"
]
}
}
}
Step 2: Implement Gateway Class
Basic Gateway Implementation
Copy
<?php
namespace YourVendor\BillingCustom;
use Eufaturo\Billing\BillableInterface;
use Eufaturo\Billing\Orders\Models\Order;
use Eufaturo\Billing\PaymentGateways\Contracts\PaymentGatewayInterface;
use Eufaturo\Billing\PaymentGateways\Dto\CustomerDto;
use Eufaturo\Billing\PaymentGateways\Responses\CheckoutResponse;
use Eufaturo\Billing\PaymentGateways\Responses\PaymentResponse;
use Eufaturo\Billing\PaymentGateways\Responses\RefundResponse;
use Eufaturo\Billing\PaymentGateways\Responses\SubscriptionResponse;
use Eufaturo\Billing\ProductPlans\Models\ProductPlan;
class CustomGateway implements PaymentGatewayInterface
{
public function __construct(
private readonly CustomClient $client,
) {
}
// =========================================================================
// GATEWAY IDENTIFICATION
// =========================================================================
public function getIdentifier(): string
{
return 'custom';
}
public function getName(): string
{
return 'Custom Payment Gateway';
}
// =========================================================================
// CUSTOMER MANAGEMENT
// =========================================================================
public function createOrRetrieveCustomer(BillableInterface $billable): CustomerDto
{
// Check if customer already exists
$existingRecord = $billable->getGatewayRecordForGateway($this);
if ($existingRecord) {
// Retrieve existing customer
$customer = $this->client->customers->retrieve($existingRecord->external_id);
} else {
// Create new customer
$customer = $this->client->customers->create([
'email' => $billable->getBillableEmail(),
'name' => $billable->getBillableName(),
'metadata' => [
'billable_id' => $billable->getBillableId(),
],
]);
// Store gateway record
$billable->gatewayRecords()->create([
'payment_gateway_id' => $this->getGatewayModel()->id,
'external_id' => $customer->id,
]);
}
return new CustomerDto(
external_id: $customer->id,
email: $customer->email,
name: $customer->name,
);
}
// =========================================================================
// ONE-TIME PAYMENTS
// =========================================================================
public function createPaymentCheckout(
BillableInterface $billable,
Order $order,
string $successUrl,
string $cancelUrl,
array $metadata = [],
): CheckoutResponse {
// Ensure customer exists
$customer = $this->createOrRetrieveCustomer($billable);
// Create checkout session
$session = $this->client->checkout->create([
'customer_id' => $customer->external_id,
'amount' => $order->total,
'currency' => $order->currency->code,
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
'metadata' => array_merge($metadata, [
'order_id' => $order->id,
'billable_id' => $billable->getBillableId(),
]),
]);
return new CheckoutResponse(
url: $session->checkout_url,
id: $session->id,
status: 'pending',
);
}
public function retrievePaymentCheckout(string $sessionId): PaymentResponse
{
$session = $this->client->checkout->retrieve($sessionId);
return new PaymentResponse(
id: $session->payment_id,
amount: $session->amount,
currency: $session->currency,
status: $session->status, // 'paid', 'pending', 'failed'
metadata: $session->metadata,
);
}
public function refundPayment(string $paymentId, int $amount): RefundResponse
{
$refund = $this->client->refunds->create([
'payment_id' => $paymentId,
'amount' => $amount,
]);
return new RefundResponse(
id: $refund->id,
payment_id: $paymentId,
amount: $refund->amount,
status: $refund->status,
);
}
// =========================================================================
// SUBSCRIPTION CHECKOUT
// =========================================================================
public function createSubscriptionCheckout(
BillableInterface $billable,
ProductPlan $plan,
string $successUrl,
string $cancelUrl,
array $metadata = [],
): CheckoutResponse {
// Ensure customer exists
$customer = $this->createOrRetrieveCustomer($billable);
// Get or create plan in gateway
$gatewayPlan = $this->getOrCreatePlan($plan);
// Create checkout session
$session = $this->client->checkout->createSubscription([
'customer_id' => $customer->external_id,
'plan_id' => $gatewayPlan->external_id,
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
'metadata' => array_merge($metadata, [
'plan_id' => $plan->id,
'billable_id' => $billable->getBillableId(),
]),
]);
return new CheckoutResponse(
url: $session->checkout_url,
id: $session->id,
status: 'pending',
);
}
public function retrieveSubscriptionCheckout(string $sessionId): SubscriptionResponse
{
$session = $this->client->checkout->retrieve($sessionId);
return new SubscriptionResponse(
id: $session->subscription_id,
status: $session->status,
current_period_end: $session->current_period_end,
metadata: $session->metadata,
paymentStatus: $session->payment_status,
);
}
// =========================================================================
// SUBSCRIPTION MANAGEMENT
// =========================================================================
public function retrieveSubscription(string $externalSubscriptionId): SubscriptionResponse
{
$subscription = $this->client->subscriptions->retrieve($externalSubscriptionId);
return new SubscriptionResponse(
id: $subscription->id,
status: $subscription->status,
current_period_end: $subscription->current_period_end,
cancel_at_period_end: $subscription->cancel_at_period_end,
);
}
public function cancelSubscription(
string $externalSubscriptionId,
bool $immediately = false
): bool {
$this->client->subscriptions->cancel($externalSubscriptionId, [
'immediately' => $immediately,
]);
return true;
}
public function resumeSubscription(string $externalSubscriptionId): bool
{
$this->client->subscriptions->resume($externalSubscriptionId);
return true;
}
public function updatePaymentMethod(
string $externalSubscriptionId,
string $paymentMethodId
): bool {
$this->client->subscriptions->update($externalSubscriptionId, [
'payment_method' => $paymentMethodId,
]);
return true;
}
public function updatePlan(
string $externalSubscriptionId,
ProductPlan $newPlan,
bool $prorate = true
): bool {
$gatewayPlan = $this->getOrCreatePlan($newPlan);
$this->client->subscriptions->update($externalSubscriptionId, [
'plan_id' => $gatewayPlan->external_id,
'prorate' => $prorate,
]);
return true;
}
// =========================================================================
// DIRECT API CREATION (Optional)
// =========================================================================
public function createSubscriptionDirect(
BillableInterface $billable,
ProductPlan $plan,
?string $paymentMethodId = null
): SubscriptionResponse {
// Most gateways should throw GatewayFeatureNotSupportedException
throw new GatewayFeatureNotSupportedException(
'Direct subscription creation not supported. Use checkout flow instead.'
);
}
// =========================================================================
// FEATURE DETECTION
// =========================================================================
public function supports(string $feature): bool
{
return match ($feature) {
'checkout' => true,
'one_time_payment' => true,
'refunds' => true,
'resume' => true,
'payment_method_update' => true,
'plan_update' => true,
'webhooks' => true,
'direct_creation' => false,
'pause' => false,
default => false,
};
}
// =========================================================================
// FRONTEND INTEGRATION
// =========================================================================
public function requiresJavaScript(?string $paymentMethodCode = null): bool
{
// Return true if your gateway needs client-side JavaScript
return false;
}
public function getJavaScriptSnippet(?string $paymentMethodCode = null): ?string
{
// Return script tag to load gateway SDK
return null;
}
public function getJavaScriptInitializer(?string $paymentMethodCode = null): ?string
{
// Return JS initialization code
return null;
}
public function getJavaScriptInitializationPayload(
string $paymentMethodCode,
string $currency,
int $amount
): ?array {
// Return data for frontend
return null;
}
// =========================================================================
// HELPER METHODS
// =========================================================================
private function getOrCreatePlan(ProductPlan $plan): object
{
// Check if plan already exists in gateway
$existingRecord = $plan->getGatewayRecordForGateway($this);
if ($existingRecord) {
return (object) ['external_id' => $existingRecord->external_id];
}
// Create plan in gateway
$gatewayPlan = $this->client->plans->create([
'amount' => $plan->price,
'currency' => $plan->currency->code,
'interval' => $plan->interval->value,
'interval_count' => $plan->interval_count,
'product_name' => $plan->product->name,
'metadata' => [
'plan_id' => $plan->id,
],
]);
// Store gateway record
$plan->gatewayRecords()->create([
'payment_gateway_id' => $this->getGatewayModel()->id,
'external_id' => $gatewayPlan->id,
]);
return $gatewayPlan;
}
private function getGatewayModel(): PaymentGateway
{
return PaymentGateway::firstOrCreate(
['slug' => $this->getIdentifier()],
['name' => $this->getName(), 'is_active' => true],
);
}
}
Step 3: Create Webhook Handler
Webhook Handler Implementation
Copy
<?php
namespace YourVendor\BillingCustom;
use Eufaturo\Billing\PaymentGateways\Contracts\WebhookHandlerInterface;
use Illuminate\Http\Request;
class CustomWebhookHandler implements WebhookHandlerInterface
{
public function __construct(
private readonly CustomClient $client,
) {
}
public function getGatewayIdentifier(): string
{
return 'custom';
}
public function handle(Request $request): void
{
// 1. Verify webhook signature
$this->verifySignature($request);
// 2. Parse event
$event = $this->parseEvent($request);
// 3. Handle based on event type
match ($event->type) {
'checkout.completed' => $this->handleCheckoutCompleted($event),
'subscription.created' => $this->handleSubscriptionCreated($event),
'subscription.updated' => $this->handleSubscriptionUpdated($event),
'subscription.cancelled' => $this->handleSubscriptionCancelled($event),
'payment.succeeded' => $this->handlePaymentSucceeded($event),
'payment.failed' => $this->handlePaymentFailed($event),
default => null, // Ignore unknown events
};
}
private function verifySignature(Request $request): void
{
$signature = $request->header('X-Custom-Signature');
$secret = config('billing-custom.webhook.secret');
$expectedSignature = hash_hmac('sha256', $request->getContent(), $secret);
if (!hash_equals($expectedSignature, $signature)) {
throw new WebhookVerificationException('Invalid webhook signature');
}
}
private function parseEvent(Request $request): object
{
return (object) $request->json()->all();
}
private function handleCheckoutCompleted(object $event): void
{
$sessionId = $event->data->id;
$orderId = $event->data->metadata->order_id ?? null;
if (!$orderId) {
return;
}
$order = Order::find($orderId);
if (!$order) {
return;
}
// Mark order as paid
$order->update([
'status' => OrderStatus::Paid,
'paid_at' => now(),
]);
// Create transaction record
$order->transactions()->create([
'external_id' => $event->data->payment_id,
'amount' => $event->data->amount,
'status' => 'succeeded',
'type' => 'payment',
]);
}
private function handleSubscriptionCreated(object $event): void
{
// Handle subscription creation
}
private function handlePaymentSucceeded(object $event): void
{
// Handle successful payment
}
// ... more handlers
}
Webhook Controller
Copy
<?php
namespace YourVendor\BillingCustom\Http\Controllers;
use Eufaturo\Billing\PaymentGateways\GatewayManager;
use Illuminate\Http\Request;
class CustomWebhookController
{
public function __invoke(Request $request, GatewayManager $manager)
{
$handler = $manager->getWebhookHandler('custom');
$handler->handle($request);
return response()->json(['status' => 'success']);
}
}
Step 4: Create Service Provider
Copy
<?php
namespace YourVendor\BillingCustom;
use Eufaturo\Billing\PaymentGateways\GatewayManager;
use Illuminate\Support\ServiceProvider;
class CustomServiceProvider extends ServiceProvider
{
public function register(): void
{
// Merge configuration
$this->mergeConfigFrom(
__DIR__ . '/../config/billing-custom.php',
'billing-custom'
);
// Bind CustomClient
$this->app->singleton(CustomClient::class, function ($app) {
return new CustomClient(
apiKey: config('billing-custom.api_key'),
testMode: config('billing-custom.test_mode', false),
);
});
}
public function boot(): void
{
// Register gateway
$gatewayManager = $this->app->make(GatewayManager::class);
$gatewayManager->register(CustomGateway::class);
$gatewayManager->registerWebhookHandler(CustomWebhookHandler::class);
// Publish configuration
$this->publishes([
__DIR__ . '/../config/billing-custom.php' => config_path('billing-custom.php'),
], 'billing-custom-config');
// Load routes
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
}
}
Step 5: Configuration File
Copy
<?php
// config/billing-custom.php
return [
// API credentials
'api_key' => env('CUSTOM_GATEWAY_API_KEY'),
'secret_key' => env('CUSTOM_GATEWAY_SECRET_KEY'),
// Webhook configuration
'webhook' => [
'secret' => env('CUSTOM_GATEWAY_WEBHOOK_SECRET'),
],
// Test mode
'test_mode' => env('CUSTOM_GATEWAY_TEST_MODE', false),
// Checkout URLs
'checkout' => [
'success_url' => env('CUSTOM_GATEWAY_SUCCESS_URL', '/checkout/success'),
'cancel_url' => env('CUSTOM_GATEWAY_CANCEL_URL', '/checkout/cancel'),
],
];
Step 6: Register Routes
Copy
<?php
// routes/web.php
use Illuminate\Support\Facades\Route;
use YourVendor\BillingCustom\Http\Controllers\CustomWebhookController;
Route::post('/webhooks/custom', CustomWebhookController::class)
->name('webhooks.custom');
Step 7: Testing
Unit Tests
Copy
<?php
use YourVendor\BillingCustom\CustomGateway;
test('gateway returns correct identifier', function () {
$gateway = app(CustomGateway::class);
expect($gateway->getIdentifier())->toBe('custom');
expect($gateway->getName())->toBe('Custom Payment Gateway');
});
test('can create customer', function () {
$gateway = app(CustomGateway::class);
$user = UserFactory::new()->create();
$customer = $gateway->createOrRetrieveCustomer($user);
expect($customer)->toBeInstanceOf(CustomerDto::class);
expect($customer->email)->toBe($user->email);
});
test('can create payment checkout', function () {
$gateway = app(CustomGateway::class);
$order = OrderFactory::new()->create();
$checkout = $gateway->createPaymentCheckout(
billable: $order->billable,
order: $order,
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel',
);
expect($checkout)->toBeInstanceOf(CheckoutResponse::class);
expect($checkout->url)->toBeString();
});
Feature Tests
Copy
test('webhook creates transaction', function () {
$order = OrderFactory::new()->create();
$this->post('/webhooks/custom', [
'type' => 'checkout.completed',
'data' => [
'id' => 'session_123',
'payment_id' => 'pay_123',
'amount' => 9900,
'metadata' => [
'order_id' => $order->id,
],
],
], [
'X-Custom-Signature' => 'valid_signature',
]);
expect($order->fresh()->status)->toBe(OrderStatus::Paid);
expect($order->transactions)->toHaveCount(1);
});
Best Practices
✅ Do
- Implement all interface methods - Even if throwing “not supported” exception
- Verify webhook signatures - Always authenticate incoming webhooks
- Use gateway records - Store external IDs for entities
- Handle errors gracefully - Throw appropriate exceptions
- Add logging - Log API requests/responses for debugging
- Support feature detection - Implement
supports()correctly - Write comprehensive tests - Unit and integration tests
- Document your gateway - README with setup instructions
❌ Don’t
- Don’t store sensitive data - Use environment variables
- Don’t skip error handling - Catch and wrap API exceptions
- Don’t forget idempotency - Make operations safe to retry
- Don’t hardcode URLs - Use configuration
- Don’t skip webhook verification - Security risk
Common Patterns
Error Handling
Copy
use Eufaturo\Billing\PaymentGateways\Exceptions\GatewayOperationFailedException;
public function createPaymentCheckout(...): CheckoutResponse
{
try {
$session = $this->client->checkout->create([...]);
} catch (CustomApiException $e) {
throw new GatewayOperationFailedException(
message: 'Failed to create checkout: ' . $e->getMessage(),
previous: $e,
);
}
return new CheckoutResponse(...);
}
Retry Logic
Copy
private function callApi(callable $callback, int $maxRetries = 3): mixed
{
$attempt = 0;
while ($attempt < $maxRetries) {
try {
return $callback();
} catch (TemporaryException $e) {
$attempt++;
if ($attempt >= $maxRetries) {
throw $e;
}
sleep($attempt * 2); // Exponential backoff
}
}
}
Idempotency
Copy
public function createPaymentCheckout(...): CheckoutResponse
{
// Use order ID as idempotency key
$idempotencyKey = "order_{$order->id}_" . $order->created_at->timestamp;
$session = $this->client->checkout->create([
'idempotency_key' => $idempotencyKey,
// ... other params
]);
return new CheckoutResponse(...);
}
Publishing Your Gateway
Package Distribution
-
Publish to Packagist
Copy
# Create account at packagist.org # Submit your package composer require your-vendor/billing-custom -
Open Source License
- Use MIT license for maximum adoption
- Add LICENSE file to repository
-
Documentation
- Write comprehensive README
- Include installation steps
- Provide usage examples
- Document configuration options
-
Maintenance
- Keep dependencies updated
- Respond to issues
- Accept pull requests
- Tag releases
Example: Complete Minimal Gateway
Here’s a minimal gateway that works with a fictional API:Copy
<?php
namespace YourVendor\BillingMinimal;
use Eufaturo\Billing\BillableInterface;
use Eufaturo\Billing\PaymentGateways\Contracts\PaymentGatewayInterface;
use Eufaturo\Billing\PaymentGateways\Responses\CheckoutResponse;
// ... other imports
class MinimalGateway implements PaymentGatewayInterface
{
public function getIdentifier(): string
{
return 'minimal';
}
public function getName(): string
{
return 'Minimal Gateway';
}
public function createPaymentCheckout(
BillableInterface $billable,
Order $order,
string $successUrl,
string $cancelUrl,
array $metadata = [],
): CheckoutResponse {
// Simple API call
$response = Http::post('https://api.minimal.com/checkout', [
'amount' => $order->total,
'currency' => $order->currency->code,
'customer_email' => $billable->getBillableEmail(),
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
]);
return new CheckoutResponse(
url: $response->json('checkout_url'),
id: $response->json('session_id'),
status: 'pending',
);
}
// Implement other required methods...
// Throw GatewayFeatureNotSupportedException for unsupported features
}
Next Steps
Gateway Overview
Learn about gateway system
Stripe Example
See real-world implementation
Architecture
Understand system design
Testing
Test your gateway
See Also
- PaymentGatewayInterface - Complete interface documentation
- Gateway-Agnostic Design - Architecture overview
- Webhooks - Webhook handling guide