Skip to main content

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:
  1. Gateway Class - Implements PaymentGatewayInterface
  2. Response DTOs - Structured responses (CheckoutResponse, PaymentResponse, etc.)
  3. Webhook Handler - Processes gateway webhook events
  4. Service Provider - Registers gateway with Laravel
  5. Configuration - API keys and settings
  6. Tests - Unit and integration tests

Step 1: Create Package Structure

Directory Structure

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

{
    "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

<?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

<?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

<?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

<?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

<?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

<?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

<?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

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

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

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

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

  1. Publish to Packagist
    # Create account at packagist.org
    # Submit your package
    composer require your-vendor/billing-custom
    
  2. Open Source License
    • Use MIT license for maximum adoption
    • Add LICENSE file to repository
  3. Documentation
    • Write comprehensive README
    • Include installation steps
    • Provide usage examples
    • Document configuration options
  4. 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:
<?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


See Also