In this guide, we’ll walk through a production-grade Fonepay integration using the Strategy Pattern with services, DTOs, actions, and a clean architecture — exactly as implemented in a real-world Laravel project.
Fonepay is one of Nepal’s most widely used payment gateways, supporting mobile banking, internet banking, and QR-based payments. If you’re building a Laravel e-commerce application, integrating Fonepay is essential for reaching Nepali customers.
Prerequisites
- Laravel 10+ (we used Laravel 13 with PHP 8.3)
- A Fonepay merchant account (credentials: username, password, terminal ID)
- SSL certificate (required for production)
- Composer
Installing the Fonepay Package
We used the mantraideas/laravel-fonepay package, which wraps the Fonepay Third-Party Merchant API v2:
composer require mantraideas/laravel-fonepayThe package auto-registers its service provider and provides a Fonepay facade with methods for QR code generation, transaction verification, and more.
Environment Configuration
Add these variables to your .env file:
FONEPAYUSERNAME=yourmerchantusername
FONEPAYPASSWORD=yourmerchantpassword
FONEPAYTERMINALID=yourterminalid
FONEPAYBASEURL=https://thirdparty-merchantapi.fonepay.com
FONEPAYBASEPATH=/api/merchant/third-party/v2For development, Fonepay provides a sandbox environment:
FONEPAYBASEURL=https://dev-external-gateway-new.fonepay.com/merchantThirdparty
FONEPAYBASEPATH=/api/merchant/third-party/v2Publishing the Config File
php artisan vendor:publish --provider="MantraideasLaravelFonepayLaravelFonepayServiceProvider"config/fonepay.php — maps environment variables to a clean configuration array:
<?php
return [
/**
* Username of the merchant.
*/
'username' => env('FONEPAY_USERNAME', 'labasam'),
/**
* Password of the merchant
*/
'password' => env('FONEPAY_PASSWORD', 'F0nepay@123#'),
/**
* API Base URL
*/
'base_url' => env('FONEPAY_BASE_URL', 'https://dev-external-gateway-new.fonepay.com/merchantThirdparty'),
/**
* API BASE PATH
*/
'base_path' => env('FONEPAY_BASE_PATH', '/api/merchant/third-party/v2'),
/**
* Terminal ID
*/
'terminal_id' => env('FONEPAY_TERMINAL_ID', '4271423331147924'),
/**
* Private Key Path
*/
'private_key_path' => storage_path('keys/private.pem'),
];The private key path points to your RSA private key used for signing requests. Place the private.pem file in storage/keys/.
Database Setup: Payments Table
We use a polymorphic payments table so the same schema works for orders, subscriptions, or any payable entity:
<?php
use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->morphs('payable');
$table->string('method');
$table->string('internal_transaction_id')->nullable()->unique();
$table->string('external_transaction_id')->nullable();
$table->decimal('amount', 12, 2);
$table->string('status');
$table->string('currency')->default('NPR');
$table->json('meta')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('payments');
}
};Key fields:
- internaltransactionid — our own generated ID (e.g., PAY202604081230451)
- external transactionid — the transaction ID returned by Fonepay
- payable — morphs to Order or any other model
Enums: PaymentMethod & PaymentStatus
Laravel enums keeps our code type-safe and self-documenting:
<?php
namespace AppEnums;
enum PaymentMethod: string
{
case ESEWA = 'esewa';
case COD = 'cod';
case FONEPAY = 'fonepay';
case CONNECTIPS = 'connectips';
}
<?php
namespace AppEnums;
enum PaymentStatus: string
{
case INITIATED = 'initiated';
case FAILED = 'failed';
case SUCCESS = 'success';
}
The Payment Model
<?php
namespace AppModels;
use DatabaseFactoriesPaymentFactory;
use Dipesh79LaravelHelpersTraitsFlushesCache;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsMorphTo;
use IlluminateSupportCarbon;
class Payment extends Model
{
/** @use HasFactory<PaymentFactory> */
use FlushesCache, HasFactory;
protected static array $cacheTags = [
'order',
];
public function payable(): MorphTo
{
return $this->morphTo();
}
}The payable() relationship allows calling $payment->payable to get the associated Order (or any other model).
The Payment Service Interface
Every payment gateway implements the same contract — this is the core of the Strategy Pattern:
<?php
namespace AppServicesPaymentContracts;
use AppDTOsPaymentCreatePaymentDTO;
interface PaymentServiceInterface
{
/**
* Initiate payment for a DTO and return a response
* Could be URL, token, or any gateway-specific data
*/
public function initiate(CreatePaymentDTO $dto): array;
/**
* Optionally verify payment after callback
*/
public function verify(string $externalTransactionId): bool;
}
- initiate() — prepares the gateway-specific payload and returns data (QR code, form data, URL, etc.)
- verify() — optionally verifies a transaction after callback
Building the Fonepay Payment Service
Here’s the concrete implementation for Fonepay QR code payments:
<?php
namespace AppServicesPayment;
use AppDTOsPaymentCreatePaymentDTO;
use MantraideasLaravelFonepayDTOsGenerateQrCodeDTO;
use MantraideasLaravelFonepayExceptionsFonepayException;
use MantraideasLaravelFonepayFacadesFonepay;
class FonepayPaymentService implements ContractsPaymentServiceInterface
{
/**
* {@inheritDoc}
*
* @throws Exception
* @throws FonepayException
*/
public function initiate(CreatePaymentDTO $dto): array
{
$generateQrCodeDto = new GenerateQrCodeDTO(
amount: $dto->amount + $dto->taxAmount + $dto->deliveryCharge,
billId: $dto->orderID,
terminalId: config('fonepay.terminal_id'),
paymentMode: 'QR',
referenceLabel: $dto->orderID,
qrType: 'INTENT_QR',
);
return Fonepay::generateQrCode($generateQrCodeDto)->toArray();
}
/**
* {@inheritDoc}
*/
public function verify(string $externalTransactionId): bool
{
return true;
}
}What’s happening here?
- Create a GenerateQrCodeDTO with the total amount, our internal order ID, terminal ID, and QR mode settings.
- Call Fonepay::generateQrCode() which hits Fonepay’s API and returns QR code data (base64 image, transaction ID, etc.).
- Return the response as an array — the frontend uses this to display the QR code to the user.
The billId is set to our internaltransactionid (like PAY202604081230451), which allows us to match the callback back to our payment record.
The CreatePaymentDTO
A DTO carries all the data needed to initiate a payment:
<?php
namespace AppDTOsPayment;
use AppHttpRequestsV1OrderPaymentRequest;
use AppModelsOrder;
use Exception;
use IlluminateDatabaseEloquentModel;
class CreatePaymentDTO
{
public function __construct(
public string $payableType,
public int $payableId,
public string $method,
public float $amount,
public ?string $orderID = null,
public ?float $taxAmount = 0,
public ?float $serviceCharge = 0,
public ?float $deliveryCharge = 0,
) {}
/**
* @throws Exception
*/
public static function fromRequest(PaymentRequest $request, Model $model): self
{
if ($request->payableType == 'order') {
/** @var Order $model */
$payableType = Order::class;
$payableId = $model->id;
$total = $model->subtotal - $model->discount_total;
$taxTotal = $model->tax_total;
$deliveryCharge = $model->delivery_charge;
} else {
throw new Exception('Invalid Payable Type', 404);
}
return new self(
payableType: $payableType,
payableId: $payableId,
method: $request->validated('paymentMethod'),
amount: $total,
taxAmount: $taxTotal,
deliveryCharge: $deliveryCharge,
);
}
}
The CreatePayment Action (Strategy Pattern)
This is the orchestrator — it creates the Payment record, resolves the correct gateway service, delegates initiation, and stores the result:
<?php
namespace AppActionsPayment;
use AppDTOsPaymentCreatePaymentDTO;
use AppEnumsPaymentStatus;
use AppModelsPayment;
use AppServicesPaymentCodPaymentService;
use AppServicesPaymentConnectIpsPaymentService;
use AppServicesPaymentContractsPaymentServiceInterface;
use AppServicesPaymentEsewaPaymentService;
use AppServicesPaymentFonepayPaymentService;
use IlluminateSupportFacadesDB;
use Throwable;
class CreatePayment
{
protected array $services = [
'esewa' => EsewaPaymentService::class,
'fonepay' => FonepayPaymentService::class,
'cod' => CodPaymentService::class,
'connectips' => ConnectIpsPaymentService::class,
];
/**
* @throws Throwable
*/
public function execute(CreatePaymentDTO $dto): array
{
return DB::transaction(function () use ($dto) {
$payment = Payment::create([
'payable_type' => $dto->payableType,
'payable_id' => $dto->payableId,
'method' => $dto->method,
'amount' => $dto->amount + $dto->taxAmount + $dto->deliveryCharge,
'status' => PaymentStatus::INITIATED->value,
]);
$payment->internal_transaction_id = 'PAY'.now()->format('YmdHis').$payment->id;
$payment->save();
$serviceClass = $this->services[$dto->method];
/** @var PaymentServiceInterface $service */
$service = new $serviceClass;
$dto->orderID = $payment->internal_transaction_id;
$response = $service->initiate($dto);
$payment->external_transaction_id = $response['transaction_id'] ?? null;
$payment->save();
return $response;
});
}
}What makes this pattern powerful?
- Open/Closed Principle — adding a new gateway? Just add a new service class and register it in the $services map. No existing code changes.
- Single Responsibility — each service only knows how to talk to its gateway.
- Testability — each service can be unit-tested in isolation.
The API Endpoint
The controller wires everything together:
<?php
namespace AppHttpControllersApiV1;
use AppActionsOrderCheckout;
use AppActionsPaymentCreatePayment;
use AppDTOsOrderCheckoutDTO;
use AppDTOsOrderShowOrderDTO;
use AppDTOsPaymentCreatePaymentDTO;
use AppEnumsOrderStatus;
use AppEnumsPaymentType;
use AppExceptionsOrderOrderNotFoundException;
use AppHttpControllersController;
use AppHttpRequestsV1OrderCheckoutRequest;
use AppHttpRequestsV1OrderPaymentRequest;
use AppQueriesOrderQuery;
use IlluminateHttpJsonResponse;
use IlluminateSupportFacadesResponse;
use Throwable;
class CheckoutController extends Controller
{
public function __construct(
private readonly Checkout $checkoutAction,
private readonly OrderQuery $orderQuery,
private readonly CreatePayment $createPayment,
) {}
/**
* Checkout
*
* @throws Throwable
*/
public function checkout(CheckoutRequest $request): JsonResponse
{
$dto = CheckoutDTO::fromRequest($request);
$response = $this->checkoutAction->execute($dto);
return Response::success($response, 'Payment Initiated Successfully');
}
/**
* Payment
*
* @throws OrderNotFoundException
* @throws Throwable
*/
public function payment(PaymentRequest $request): JsonResponse
{
$createPaymentDto = null;
if ($request->payableType === PaymentType::ORDER->value) {
$order = $this->orderQuery->findOrFail(new ShowOrderDTO(
id: $request->payableId,
status: OrderStatus::PENDING,
));
$createPaymentDto = CreatePaymentDTO::fromRequest($request, $order);
}
$payment = $this->createPayment->execute($createPaymentDto);
return Response::success($payment, 'Payment Initiated Successfully');
}
}Routes in routes/web.php or api.php:
<?php
Route::post('/checkout', [CheckoutController::class, 'checkout']);
Route::post('/payment', [CheckoutController::class, 'payment']);What the frontend receives
For Fonepay QR payments, the response contains:
{
"data": {
"qrCode": "base64encodedimage…",
"transactionid": "FPTXN123456",
"amount": 1500.00,
"billId": "PAY202604081230451"
},
"message": "Payment Initiated Successfully"
}The frontend renders the QR code. The user scans it with their Fonepay app, completes the payment, and the system receives a callback.
Handling Payment Callbacks
The Routing
Fonepay can send callbacks via server-to-server webhook or redirect. Register routes in routes/web.php:
<?php
use AppHttpControllersFrontendPaymentFonepayController;
Route::group(['prefix' => 'fonepay'], function (): void {
Route::get('/success', [FonepayController::class, 'success'])->name('fonepay.success');
Route::get('/failure', [FonepayController::class, 'failure'])->name('fonepay.failure');
});The Callback Controller
Following the same pattern as other gateways (eSewa, ConnectIPS):
The FonepayDTO
<?php
namespace AppDTOsFonepay;
use IlluminateHttpRequest;
class FonepayDTO
{
public function __construct(
public string $transactionId,
) {}
public static function fromRequest(Request $request): self
{
return new self(
transactionId: $request->TXNID,
);
}
public static function fromArray(array $data): self
{
return new self(
transactionId: $data['TXNID'],
);
}
}The HandleSuccess Action
<?php
namespace AppActionsFonepay;
use AppDTOsFonepayFonepayDTO;
use AppEnumsOrderStatus;
use AppEnumsPaymentStatus;
use AppModelsOrder;
use AppNotificationsPaymentSuccess;
use AppQueriesPaymentQuery;
use IlluminateSupportFacadesDB;
use MantraideasLaravelFonepayFacadesFonepay;
readonly class HandleSuccess
{
public function __construct(
private PaymentQuery $paymentQuery,
) {}
/**
* @throws Throwable
*/
public function execute(FonepayDTO $dto): void
{
DB::transaction(function () use ($dto): void {
$payment = $this->paymentQuery->findByInternalTransactionId($dto->transactionId);
$paymentValidation = Fonepay::getPaymentStatus($dto->transactionId);
if ($paymentValidation->paymentStatus === 'success') {
$payment->status = PaymentStatus::SUCCESS->value;
$payment->external_transaction_id = (string) $paymentValidation->fonepayTraceId;
$payment->save();
/** @var ?Order $order */
$order = $payment->payable()->first();
if ($order) {
$order->status = OrderStatus::CONFIRMED;
$order->save();
$order->user->notify(new Success(
order: $order,
user: $order->user,
payment: $payment,
));
}
} else {
$payment->status = PaymentStatus::FAILED->value;
$payment->save();
}
});
}
}The HandleFailure Action (shared across all gateways)
<?php
namespace AppActionsPayment;
use AppDTOsOrderShowOrderDTO;
use AppEnumsPaymentStatus;
use AppExceptionsPaymentPaymentNotFoundException;
use AppNotificationsPaymentFailed;
use AppQueriesOrderQuery;
use AppQueriesPaymentQuery;
use IlluminateSupportFacadesDB;
use Throwable;
readonly class HandleFailure
{
public function __construct(
private OrderQuery $orderQuery,
private PaymentQuery $paymentQuery,
) {}
/**
* @throws PaymentNotFoundException
* @throws Throwable
*/
public function execute(?string $orderId = null): void
{
DB::transaction(function () use ($orderId): void {
if ($orderId) {
$payment = $this->paymentQuery->findByInternalTransactionId($orderId);
$payment->status = PaymentStatus::FAILED->value;
$payment->save();
$order = $this->orderQuery->findOrFail(new ShowOrderDTO(
id: $payment->payable_id
));
$order->user->notify(new Failed($order, $order->user, $payment));
}
});
}
}Conclusion
Integrating Fonepay in Laravel doesn’t have to be messy. By using the Strategy Pattern with a consistent PaymentServiceInterface, clean DTOs, focused Action classes, and a polymorphic Payment model, you get:
- Clean separation of concerns — each gateway lives in its own service class
- Easy to extend — add new gateways without touching existing code
- Testable — services and actions can be unit-tested with fakes
- Production-ready — this exact architecture handles thousands of transactions in a live e-commerce application
This guide is based on a production Laravel application using mantraideas/laravel-fonepay ^1.0 with Laravel 13 and PHP 8.3.