Laravel Algerian Payment Gateway Integration Using Chargily Pay
If you want to implement an online payment method that accepts CIB and Edahabia cards, Chargily Pay provides an easy way to do that. In this tutorial we’re going to cover how to use Chargily Pay to generate and receive one-time payments.
#
The checkout flow
- The browser sends a checkout request to our server.
- Our server generates a checkout invoice, saves the corresponding order to the database, and redirects to the checkout page.
- When the user pays, a webhook will be sent to our server, and the user will be redirected to the success page.
- Our server will handle the webhook and change the order’s status accordingly.
#
Our plan of action
This tutorial will be divided into 2 parts:
- Part 1: creating the basic example to test the Chargily Pay integration.
- Part 2: the integration of the Chargily Pay payment gateway.
#
Part 1: Creating the example project
#
Setting up a new laravel project
In this tutorial I’m going to be using Laravel 12 with the Vue starter kit and all the default options:
laravel new chargily-pay

#
Creating the initial project
To demonstrate how to use Chargily Pay, I will have a single product page where I will display the product name, description, and the pay now button, and I will create an orders table that will track how many times that product is requested and paid for.
First, let’s create models, migrations, and factories:
php artisan make:model Order -m
Order Migration (database/migrations/2025_09_04_144520_create_orders_table.php):
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('checkout_id')->unique();
$table->decimal('amount', 10, 2);
$table->string('status')->default('created');
$table->datetime('paid_at')->nullable();
$table->timestamps();
});
Order Model (app/Models/Order.php):
// ...
protected $fillable = [
'checkout_id',
'amount',
'status',
'paid_at',
];
// ...
Run migrations
php artisan migrate
Next, let’s create the routes and controllers for the orders and the product page:
php artisan make:controller OrderController
Order Controller (app/Http/Controllers/OrderController.php):
use App\Models\Order;
use Illuminate\Http\Request;
// ...
public function index(Request $request)
{
$orders = Order::all();
return inertia('orders/Index', compact('orders'));
}
// ...
php artisan make:controller ProductController
Product Controller (app/Http/Controllers/ProductController.php):
//...
/*
* Display the payment form for the product
*/
public function display()
{
return inertia('products/Display');
}
// ...
routes/web.php:
use App\Http\Controllers\OrderController;
use App\Http\Controllers\ProductController;
// ...
Route::get('pay', [ProductController::class, 'display'])
->name('products.display');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', function () {
return Inertia::render('Dashboard');
})->name('dashboard');
Route::get('orders', [OrderController::class, 'index'])->name('orders.index');
});
// ...
###
Creating the views
resources/js/components/AppSidebar.vue:
// ...
const mainNavItems: NavItem[] = [
{
title: "Dashboard",
href: dashboard(),
icon: LayoutGrid,
},
{
title: "Orders",
href: "/orders",
icon: LayoutGrid,
},
];
// ...
resources/js/pages/orders/Index.vue:
<template>
<Head title="Orders" />
<AppLayout>
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4 overflow-x-auto">
<h1 class="text-2xl mb-4">Orders</h1>
<table class="w-full border-collapse">
<thead>
<tr>
<th class="p-2 border-b text-left">Checkout id</th>
<th class="p-2 border-b text-left">Amount</th>
<th class="p-2 border-b text-left">Status</th>
<th class="p-2 border-b text-left">Paid at</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders" :key="order.id">
<td class="p-2 border-b">{{ order.checkout_id }}</td>
<td class="p-2 border-b">{{ order.amount }}</td>
<td class="p-2 border-b">{{ order.status }}</td>
<td class="p-2 border-b">{{ fmtDate(order.paid_at) }}</td>
</tr>
</tbody>
</table>
</div>
</AppLayout>
</template>
<script setup>
import AppLayout from '@/layouts/AppLayout.vue';
import { Head } from '@inertiajs/vue3';
const props = defineProps({
orders: Object,
});
function fmtDate(s) {
if (!s) return '-';
const d = new Date(s);
return isNaN(d) ? s : d.toLocaleString();
}
</script>
resources/js/pages/products/Display.vue:
<template>
<div
class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12"
>
<div class="flex h-64 w-64 items-center justify-center" v-if="loading">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"
></div>
</div>
<div
class="w-full max-w-lg space-y-5 rounded-lg bg-white p-6 shadow"
v-else
>
<div>
<h1 class="text-xl font-medium text-gray-800">
{{ product.name }}
</h1>
<p class="mt-1 text-sm text-gray-500">
{{ product.description }}
</p>
</div>
<div class="text-2xl font-semibold text-gray-900">
{{ formatCurrency(product.amount) }}
</div>
<form @submit.prevent="checkout" class="space-y-4">
<div v-if="form.errors.default" class="mb-4 text-red-600">
{{ form.errors.default }}
</div>
<p v-if="form.errors.amount" class="mt-1 text-xs text-red-600">
{{ form.errors.amount }}
</p>
<div>
<button
type="submit"
:disabled="loading"
class="w-full cursor-pointer rounded bg-indigo-600 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
>
<span>Pay Now</span>
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
const product = {
name: 'Product Name',
description: 'Product Description',
amount: 3000,
};
const loading = ref(false);
const form = useForm({
amount: product.amount,
product_name: product.name,
});
function formatCurrency(amount) {
return new Intl.NumberFormat('fr-DZ', {
style: 'currency',
currency: 'DZD',
}).format(amount);
}
function checkout() {
loading.value = true;
form.post('/checkout', {
onError() {
loading.value = false;
},
preserveState: true,
});
}
</script>
Now when you go to the /pay route, you should see something like this:

And finally it’s time to add the checkout controller:
php artisan make:controller CheckoutController
app/Http/Controllers/CheckoutController.php:
// ...
public function checkout(Request $request)
{
$data = $request->validate([
'product_name' => ['required', 'string'],
'amount' => ['required', 'numeric', 'min:100'],
]);
dd($data);
// we will implement this later after setting up chargily pay.
}
public function success()
{
return '<h1>Success</h1>';
}
public function failure()
{
return '<h1 style="color: red;">failure</h1>';
}
// ...
routes/web.php:
// ...
Route::get('pay', [ProductController::class, 'display'])
->name('products.display');
Route::post('checkout', [CheckoutController::class, 'checkout'])
->name('checkout');
Route::get('success', [CheckoutController::class, 'success'])
->name('success');
Route::get('failure', [CheckoutController::class, 'failure'])
->name('failure');
// ...
#
Part 2: Chargily Pay Integration
Our workflow is going to be something like this:
- The client fills in their info and clicks pay.
- We generate a checkout using the Chargily Pay package for laravel.
- We create a corresponding order with the status of “created” in our database.
- We redirect the user to the checkout URL of that checkout.
- We handle payment success in our system by updating the order status to ‘paid’ and recording the payment date.
First you need a Chargily Pay account; you can register Here.
After that you can have access to your test api keys, for more info on how to do this Click Here.
Let’s start by setting the necessary env variables:
.env:
CHARGILY_PAY_MODE=test
CHARGILY_PAY_PUBLIC_KEY=
CHARGILY_PAY_SECRET_KEY=
Then create a chargily-pay config file in the config directory
config/chargily-pay.php:
<?php
return [
'mode' => env('CHARGILY_PAY_MODE', 'test'),
'public_key' => env('CHARGILY_PAY_PUBLIC_KEY'),
'secret_key' => env('CHARGILY_PAY_SECRET_KEY'),
];
add the chargily pay laravel package:
In this tutorial we’ll be using the official chargily pay package for laravel, you can find it Here.
Run this to install it:
composer require chargily/chargily-pay
add the checkout functionality:
When a user clicks pay, you’re going to generate a checkout and then redirect the user to the payment page.
app/Services/CheckoutService.php
<?php
namespace App\Services;
use App\Models\Order;
use Chargily\ChargilyPay\Auth\Credentials;
use Chargily\ChargilyPay\ChargilyPay;
use Chargily\ChargilyPay\Elements\CheckoutElement;
use Chargily\ChargilyPay\Elements\WebhookElement;
use Illuminate\Support\Facades\Log;
class CheckoutService
{
protected ChargilyPay $client;
public function __construct()
{
$this->client = new ChargilyPay(new Credentials([
'mode' => config('chargily-pay.mode'),
'public' => config('chargily-pay.public_key'),
'secret' => config('chargily-pay.secret_key'),
]));
}
/**
* Initiate a checkout session and create an order.
*
* @param array $data Checkout data.
* @return array{checkout: CheckoutElement, order: Order}
*
* @throws \Exception
*/
public function initiateCheckout(array $data): array
{
$checkout = $this->createCheckout($data);
if (! $checkout) {
throw new \Exception('Failed to create checkout session');
}
$order = Order::create([
'checkout_id' => $checkout->getId(),
'amount' => $data['amount'],
]);
return [
'checkout' => $checkout,
'order' => $order,
];
}
/**
* Create a new checkout.
*/
public function createCheckout(array $data): ?CheckoutElement
{
return $this->client->checkouts()->create($data);
}
/**
* Get a checkout by ID.
*/
public function getCheckout(string $checkoutId): ?CheckoutElement
{
return $this->client->checkouts()->get($checkoutId);
}
/**
* Get webhook data.
*/
public function getWebhook(): ?WebhookElement
{
return $this->client->webhook()->get();
}
}
and in the controller:
app/Http/Controllers/CheckoutController.php:
use App\Services\CheckoutService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
// ...
public function __construct(
protected CheckoutService $checkoutService
) {}
public function checkout(Request $request)
{
$data = $request->validate([
'product_name' => ['required', 'string'],
'amount' => ['required', 'numeric', 'min:100'],
]);
try {
$result = $this->checkoutService->initiateCheckout([
'amount' => $data['amount'],
'description' => $data['product_name'],
'locale' => 'ar',
'currency' => 'dzd',
'success_url' => route('success'),
'failure_url' => route('failure'),
]);
return inertia()->location($result['checkout']->getUrl());
} catch (\Exception $e) {
Log::error('Failed to initiate checkout', [
'error' => $e,
]);
throw ValidationException::withMessages([
'default' => 'Something went wrong!',
]);
}
}
// ...
Note: I’m only using the ValidationException here for convenience; in a real-world application you should handle errors properly.
Now when we click pay we should be redirected to the chargily pay’s payment page:

There should also be an order with the status of “created” in you database:

And after you fill in your info and click pay, you’re gonna see this page:

Now when the user clicks the return button, it’ll take them to the success page with the checkout id as a url param.
Then we verify and update the status of the order (this should be done using a webhook, as we’ll see later):
app/Http/Controllers/CheckoutController.php:
// ...
public function success(Request $request)
{
$checkoutId = $request->input('checkout_id');
if (! $checkoutId) {
return '<h1 style="color: red;">Invalid request!</h1>';
}
$order = Order::where('checkout_id', $checkoutId)->firstOrFail();
if ($order->status === 'paid') {
return '<h1>Success!</h1>';
}
try {
$checkout = $this->checkoutService->getCheckout($checkoutId);
if ($checkout?->getStatus() === 'paid') {
$order->update([
'status' => 'paid',
'paid_at' => $checkout->getUpdatedAt() ?? now(),
]);
return '<h1>Success!</h1>';
}
} catch (\Throwable $th) {
Log::error('Failed to verify checkout', [
'checkout_id' => $checkoutId,
'error' => $th->getMessage(),
]);
}
return '<h1 style="color: red;">Order not paid!</h1>';
}
// ...
#
Adding webhooks
Now imagine if the client paid but they closed the chargily success page without returning to our success page. Now even though the client paid, it still doesn’t show up on our database. To solve this problem we’ll be adding a webhook, which allows Chargily Pay to send us a request directly to our server whenever the client pays.
This is how it’s going to work: after the client pays, chargily pay will send us a request to the chargilypay/webhook that contains information about the checkout that was paid. All we need in our case is the id of the checkout, which we’ll use to find the order in our database and update its status accordingly; after that, we will return a request with a status of 200, or a status of 403 if something went wrong.
routes/web.php:
// ...
Route::get('failure', [CheckoutController::class, 'failure'])
->name('failure');
Route::post('chargilypay/webhook', [CheckoutController::class, 'webhook'])
->name('chargilypay.webhook');
// ...
app/Http/Controllers/CheckoutController.php:
// ...
public function webhook()
{
try {
$result = $this->checkoutService->processWebhook();
return response()->json([
'status' => $result['success'],
'message' => $result['message'],
], $result['status_code']);
} catch (\Exception $e) {
return response()->json([
'status' => false,
'message' => 'Invalid Webhook request',
], 403);
}
}
// ...
app/Services/CheckoutService.php
use Chargily\ChargilyPay\Elements\CheckoutElement;
use Illuminate\Support\Facades\Log;
// ...
/**
* Process webhook data and update order accordingly.
*
* @return array{success: bool, message: string, status_code: int}
*/
public function processWebhook(): array
{
$webhook = $this->getWebhook();
if (! $webhook) {
return [
'success' => false,
'message' => 'Invalid Webhook request',
'status_code' => 403,
];
}
$checkout = $webhook->getData();
if (empty($checkout) || ! ($checkout instanceof CheckoutElement)) {
return [
'success' => false,
'message' => 'Invalid Webhook request',
'status_code' => 403,
];
}
$checkoutId = $checkout->getId();
$checkoutStatus = $checkout->getStatus();
$order = Order::where('checkout_id', $checkoutId)->first();
if (! $order) {
return [
'success' => false,
'message' => 'Invalid Webhook request',
'status_code' => 403,
];
}
// Already processed
if ($order->status === 'paid') {
return [
'success' => true,
'message' => 'Payment has been completed',
'status_code' => 200,
];
}
// Update order based on checkout status
if ($checkoutStatus === 'paid') {
Order::where('checkout_id', $checkoutId)
->where('status', '!=', 'paid')
->update([
'status' => 'paid',
'paid_at' => $checkout->getUpdatedAt() ?? now(),
]);
return [
'success' => true,
'message' => 'Payment has been completed',
'status_code' => 200,
];
} elseif ($checkoutStatus === 'failed' || $checkoutStatus === 'canceled' || $checkoutStatus === 'expired') {
Order::where('checkout_id', $checkoutId)
->where('status', '!=', $checkoutStatus)
->update([
'status' => $checkoutStatus,
]);
return [
'success' => true,
'message' => 'Payment has been canceled',
'status_code' => 200,
];
} else {
Log::error('Unknown payment status received from webhook.', [
'checkout_id' => $checkoutId,
'order_id' => $order->id,
'checkout_status' => $checkoutStatus,
'webhook_data' => $checkout->toArray(),
]);
return [
'success' => true,
'message' => 'Unknown payment status',
'status_code' => 200,
];
}
}
// ...
Note: webhook providers expect a quick response, so if you have to do a lot of work in the processWebhook method you should do it in a queued job, and quickly return a response.
Finally, let’s disable CSRF token validation for the webhooks route; otherwise, the requests will not pass:
bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
// ...
$middleware->validateCsrfTokens([
'chargilypay/webhook',
]);
// ...
})
##
Installing Ngrok
In order to test this webhook, we’ll need to expose our server to the internet; we’re going to do this by using a tool called Ngrok. I have a tutorial of how to set this up with Laravel Here.
##
Register your webhook endpoint URL
There are two ways to register your webhook url:
- Register your webhook url on your Chargily Pay’s dashboard; for more info on how to do this Click Here.
- Providing a webhook_endpoint field when generating a checkout, you can do this in the checkout method
of the CheckoutController like this:
// ... $result = $this->checkoutService->initiateCheckout([ 'amount' => $data['amount'], 'description' => $data['product_name'], 'locale' => 'ar', 'currency' => 'dzd', 'success_url' => route('success'), 'failure_url' => route('failure'), 'webhook_endpoint' => route('chargilypay.webhook'), ]); // ...
I recommend using the first method, since it’s easier and simpler than including the webhook_endpoint everywhere you generate a checkout.
And don’t forget to remove the code that updates the order status in the success method:
// ...
public function success(Request $request)
{
$checkoutId = $request->input('checkout_id');
if (! $checkoutId) {
return '<h1 style="color: red;">Invalid request!</h1>';
}
$order = Order::where('checkout_id', $checkoutId)->firstOrFail();
if ($order->status === 'paid') {
return '<h1>Success!</h1>';
}
return '<h1 style="color: red;">Order not paid!</h1>';
}
// ...
Now after a successful payment, you should still see the success message, and the order status should be “paid” in the dashboard.
##
Important
- When using webhooks in production, you should always validate the webhook signature, in this case the Chargily Pay package for laravel already takes care of this for us in the webhook()->get() method, so you don’t have to do anything if you’re using it, but just keep this in mind if you’re not using the package.
- Webhooks should be idempotent; even if the exact same webhook event is sent multiple times, there should be no duplicate actions in your code. I am already kind of doing this with this condition: if ($order->status === ‘paid’), and also with this: ->where(‘status’, ‘!=’, ‘paid’) and ->where(‘status’, ‘!=’, $checkoutStatus), where i’m making sure to only update the order status if it’s not already updated, so it’s only updated once, but you should keep this in mind when dealing with more complex logic.
#
Testing
Now everything is working as it’s supposed to; all that’s left is to write automated tests. But first make sure to set a separate database for testing in the phpunit.xml file:
phpunit.xml:
// ...
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
// ...
I’ll only be writing tests for the checkout since it’s the most important in this case:
php artisan make:test CheckoutControllerTest
tests/Feature/CheckoutControllerTest.php:
<?php
use App\Models\Order;
use App\Services\CheckoutService;
use Chargily\ChargilyPay\Elements\CheckoutElement;
use Chargily\ChargilyPay\Elements\WebhookElement;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
/*
* Mockery::mock(CheckoutService::class)->makePartial(): Creates a "partial mock" of our service.
* A partial mock allows the original class methods to be called unless they are explicitly overridden
* This is useful for testing our controller's logic while mocking only the external API calls.
*/
$this->checkoutService = Mockery::mock(CheckoutService::class)->makePartial();
/*
* $this->app->instance(...): This binds our mocked service instance into Laravel's service container.
* When the CheckoutController is resolved, it will receive this mock instead of the real service.
*/
$this->app->instance(CheckoutService::class, $this->checkoutService);
});
describe('checkout method', function () {
test('successfully creates checkout and redirects to payment URL', function () {
$checkoutId = 'checkout_'.str()->random(5);
$redirect_url = 'https://pay.chargily.com/test-checkout-url';
$checkoutElementMock = Mockery::mock(CheckoutElement::class);
$checkoutElementMock->shouldReceive('getUrl')
->once()
->andReturn($redirect_url);
$checkoutElementMock->shouldReceive('getId')
->once()
->andReturn($checkoutId);
/*
* Mocking the CheckoutService class to avoid making a real HTTP request to the Chargily Pay API.
*
* - $this->checkoutService->shouldReceive('createCheckout'): Sets up the expected method to be called.
* - once(): We expect this method to be called exactly once. The test will fail if it's called more or fewer times.
* - andReturn($checkoutElementMock): Returns the mocked CheckoutElement instance when `createCheckout` is called in the controller,
* simulating a successful API response.
*/
$this->checkoutService
->shouldReceive('createCheckout')
->once()
->andReturn($checkoutElementMock);
$response = $this->post(route('checkout'), [
'product_name' => 'Test Product',
'amount' => 5000,
]);
$response->assertRedirect();
$response->assertRedirectContains($redirect_url);
// Verify order was created in database
$this->assertDatabaseHas('orders', [
'checkout_id' => $checkoutId,
'amount' => 5000,
]);
});
test('throws validation exception when checkout creation fails', function () {
/*
* Mocking the CheckoutService to simulate a failure scenario from the Chargily Pay API.
*
* - shouldReceive('createCheckout'): Sets up the expected method to be called.
* - once(): We expect this method to be called exactly once.
* - andReturn(null): Simulates a failed API call by returning null, which the controller should handle as an error.
*/
$this->checkoutService
->shouldReceive('createCheckout')
->once()
->andReturn(null);
$this->post(route('checkout'), [
'product_name' => 'Test Product',
'amount' => 5000,
])->assertSessionHasErrors(['default']);
});
});
describe('webhook method', function () {
test('processes webhook successfully for paid status', function () {
$checkoutId = 'checkout_'.str()->random(5);
Order::create([
'checkout_id' => $checkoutId,
'amount' => 5000,
]);
$checkoutElementMock = Mockery::mock(CheckoutElement::class);
$checkoutElementMock->shouldReceive('getId')
->andReturn($checkoutId);
$checkoutElementMock->shouldReceive('getStatus')
->andReturn('paid');
$checkoutElementMock->shouldReceive('getUpdatedAt')
->andReturn(now());
$webhookElementMock = Mockery::mock(WebhookElement::class);
$webhookElementMock->shouldReceive('getData')
->once()
->andReturn($checkoutElementMock);
/*
* Mocking the CheckoutService to simulate receiving a valid webhook from Chargily Pay.
*
* - shouldReceive('getWebhook'): Sets up the expected method to be called.
* - once(): We expect the method to be called exactly once.
* - andReturn($webhookElementMock): Returns a mocked WebhookElement, simulating a valid, signed request from Chargily Pay.
*/
$this->checkoutService
->shouldReceive('getWebhook')
->once()
->andReturn($webhookElementMock);
$response = $this->post(route('chargilypay.webhook'));
$response->assertStatus(200);
$response->assertJson([
'status' => true,
'message' => 'Payment has been completed',
]);
// Verify order was updated in database
$this->assertDatabaseHas('orders', [
'checkout_id' => $checkoutId,
'status' => 'paid',
]);
});
test('returns 403 for invalid webhook request', function () {
/*
* Mocking the CheckoutService to simulate an invalid webhook request.
*
* - shouldReceive('getWebhook'): Sets up the expected method.
* - once(): We expect the method to be called exactly once.
* - andReturn(null): Simulates a failure in webhook validation, by making the method return null.
*/
$this->checkoutService
->shouldReceive('getWebhook')
->once()
->andReturn(null);
$response = $this->post(route('chargilypay.webhook'));
$response->assertStatus(403);
$response->assertJson([
'status' => false,
'message' => 'Invalid Webhook request',
]);
});
});
We’re using Mockery to create a partial mock of the CheckoutService, which allows us to simulate API responses from Chargily Pay without making real HTTP requests, ensuring our tests are fast, reliable, and don’t depend on external services.
For example, the createCheckout() method normally sends an HTTP request to Chargily Pay’s API to create a new checkout. Chargily Pay responds with data including a checkout URL and a checkout ID, and the chargily package encapsulates that data into a CheckoutElement object. In our tests, instead of making that real API call, we create a fake CheckoutElement with fake data, and we mock createCheckout() to immediately return these fake values. This way we avoid calling the external API while still testing that our code runs as expected.
Important: These tests are a good start, but in a real world application you should cover a lot more edge cases.
Now after running:
php artisan test --filter=CheckoutControllerTest
You should see all tests pass:
