Skip to main content

Developer Bytes

Integrate Algerian payment methods with laravel using SlickPay

If you want to implement an online payment method that accepts CIB and Edahabia cards, SlickPay provides an easy way to do that. In this tutorial we’re going to cover how to use SlickPay to generate and receive one-time payments.

# The checkout flow

  1. The browser sends a checkout request to our server.
  2. Our server generates a checkout invoice, saves the corresponding order to the database, and redirects to the checkout page.
  3. When the user pays, a webhook will be sent to our server, and the user will be redirected to the success page.
  4. Our server will handle the webhook and change the order’s status to paid.

# Our plan of action

This tutorial will be divided into 2 parts:

  • Part 1: creating the basic example to test the slickpay integration.
  • Part 2: the integration of the Slickpay 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 slick-pay

laravel new inertia vue project

# Creating the initial project

To demonstrate how to use SlickPay, I will have a single product form page where I will display the product name, description, and the payment form, 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');
    $table->datetime('paid_at')->nullable();
    $table->string('customer_name');
    $table->string('customer_email')->nullable();
    $table->string('customer_phone')->nullable();
    $table->timestamps();
});

Order Model (app/Models/Order.php):

// ...
    protected $fillable = [
        'checkout_id',
        'amount',
        'status',
        'paid_at',
        'customer_name',
        'customer_email',
        'customer_phone',
    ];
// ...

Run migrations

php artisan migrate

Next, let’s create the routes and controllers for the orders and the product form:

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">Customer</th>
                    <th class="p-2 border-b text-left">Email</th>
                    <th class="p-2 border-b text-left">Phone</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.customer_name }}</td>
                        <td class="p-2 border-b">{{ order.customer_email }}</td>
                        <td class="p-2 border-b">{{ order.customer_phone }}</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="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-12">
        <div class="flex items-center justify-center h-64 w-64" v-if="loading">
            <div class="w-12 h-12 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
        </div>

        <div class="w-full max-w-lg bg-white rounded-lg shadow p-6 space-y-5" v-else>
            <div>
                <h1 class="text-xl font-medium text-gray-800">{{ product.name }}</h1>
                <p class="text-sm text-gray-500 mt-1">{{ 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="text-red-600 mb-4">
                    {{ form.errors.default }}
                </div>
                <p v-if="form.errors.amount" class="mt-1 text-xs text-red-600">{{ form.errors.amount }}</p>

                <div class="flex gap-3">
                    <div class="flex-1">
                        <label class="block text-sm text-gray-700 mb-1">First name</label>
                        <input
                        v-model="form.first_name"
                        type="text"
                        autocomplete="given-name"
                        class="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400 text-black"
                        placeholder="John"
                        />
                        <p v-if="form.errors.first_name" class="mt-1 text-xs text-red-600">{{ form.errors.first_name }}</p>
                    </div>

                    <div class="flex-1">
                        <label class="block text-sm text-gray-700 mb-1">Last name</label>
                        <input
                        v-model="form.last_name"
                        type="text"
                        autocomplete="family-name"
                        class="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400 text-black"
                        placeholder="Doe"
                        />
                        <p v-if="form.errors.last_name" class="mt-1 text-xs text-red-600">{{ form.errors.last_name }}</p>
                    </div>
                </div>

                <div>
                    <label class="block text-sm text-gray-700 mb-1">Email</label>
                    <input
                        v-model="form.email"
                        type="email"
                        autocomplete="email"
                        class="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400 text-black"
                        placeholder="[email protected]"
                    />
                    <p v-if="form.errors.email" class="mt-1 text-xs text-red-600">{{ form.errors.email }}</p>
                </div>

                <div>
                    <label class="block text-sm text-gray-700 mb-1">Phone</label>
                    <input
                        v-model="form.phone"
                        type="tel"
                        autocomplete="tel"
                        class="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400 text-black"
                        placeholder="+213 6 XX XX XX XX"
                    />
                    <p v-if="form.errors.phone" class="mt-1 text-xs text-red-600">{{ form.errors.phone }}</p>
                </div>

                <div>
                    <label class="block text-sm text-gray-700 mb-1">Address</label>
                    <textarea
                        v-model="form.address"
                        rows="3"
                        class="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400 text-black resize-none"
                        placeholder="Street, City, Postal code"
                    ></textarea>
                    <p v-if="form.errors.address" class="mt-1 text-xs text-red-600">{{ form.errors.address }}</p>
                </div>

                <div>
                    <button
                        type="submit"
                        :disabled="loading"
                        class="w-full py-2 bg-indigo-600 text-white rounded text-sm font-medium disabled:opacity-60 cursor-pointer disabled:cursor-not-allowed"
                    >
                        <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({
    first_name: '',
    last_name: '',
    email: '',
    phone: '',
    address: '',
    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:

product form page

And finally it’s time to add the checkout controller:

php artisan make:controller CheckoutController

Checkout Controller (app/Http/Controllers/CheckoutController.php):

	public function checkout(Request $request)
    {
        $data = $request->validate([
        	'product_name' => ['required', 'string'],
            'first_name' => ['required', 'string', 'max:255'],
            'last_name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email'],
            'phone' => ['required', 'string'],
            'address' => ['required', 'string'],
            'amount' => ['required', 'numeric', 'min:100'],
        ]);

        dd($data);
        // we will implement this later after setting up SlickPay.
    }

    public function success()
    {
        return '<h1>Success</h1>';
    }

routes/web.php:

use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\OrderController;
use App\Http\Controllers\ProductController;

// ...
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::middleware(['auth', 'verified'])->group(function () {
    Route::get('dashboard', function () {
        return Inertia::render('Dashboard');
    })->name('dashboard');

    Route::get('orders', [OrderController::class, 'index'])->name('orders.index');
});
// ...

# Part 2: SlickPay Integration

Our workflow is going to be something like this:

  • The client fills in their info and clicks pay.
  • We generate an invoice using Slickpay’s API.
  • We create a corresponding order with the status of “created” in our database.
  • We redirect the user to the checkout URL of that invoice.
  • We handle the success of the payment in our system by setting the order status to paid and also setting its paid at to the paid date.

Let’s start by setting the necessary env variables: .env:

SLICKPAY_SANDBOX=true # set to false on production
SLICKPAY_PUBLIC_API_KEY=YOUR_PUBLIC_KEY

Note: For testing, SlickPay provides a public_key without even having to log in or have an account; you’ll find it in their documentation page: Here.

slick pay test key place

And for production, you can get your PUBLIC_KEY from the dashboard after logging into your SlickPay account.

Then create a slickPay config file in the config directory.

config/slickpay.php:

<?php

return [
    'sandbox' => env('SLICKPAY_SANDBOX', true),
    'public_key' => env('SLICKPAY_PUBLIC_API_KEY'),
];

Now every time the client fills their info and clicks pay, we will generate a new SlickPay invoice, See the docs. So let’s create a Slickpay service to handle this:

app/Services/SlickpayService.php:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;

class SlickpayService
{
    protected string $url;

    protected string $api_key;

    public function __construct()
    {
        $this->api_key = config('slickpay.public_key');
        $is_sandbox = config('slickpay.sandbox');

        if ($is_sandbox) {
            $this->url = 'https://devapi.slick-pay.com/api/v2';
        } else {
            $this->url = 'https://prodapi.slick-pay.com/api/v2';
        }
    }

    public function generateInvoice(array $data): mixed
    {
        $response = Http::withToken($this->api_key)
            ->acceptJson()
            ->withHeaders(['Content-Type' => 'application/json'])
            ->timeout(30)
            ->post("{$this->url}/users/invoices", $data);

        $response->throw();

        $invoice = $response->json();

        $success = $invoice['success'] ?? false;
        if (! $success) {
            $errors = $invoice['message'] ?? ['Something went wrong, please try again!'];
            throw ValidationException::withMessages([
                'default' => $errors,
            ]);
        }

        return $invoice;
    }
}

app/Http/Controllers/CheckoutController.php:


use App\Models\Order;
use App\Services\SlickpayService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;

// ...
    public function __construct(private SlickpayService $slickpayService) {}

    public function checkout(Request $request)
    {
        $data = $request->validate([
            'product_name' => ['required', 'string'],
            'first_name' => ['required', 'string', 'max:255'],
            'last_name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email'],
            'phone' => ['required', 'string'],
            'address' => ['required', 'string'],
            'amount' => ['required', 'numeric', 'min:100'],
        ]);

        $invoice_data = [
            'amount' => $data['amount'],
            'firstname' => $data['first_name'],
            'lastname' => $data['last_name'],
            'email' => $data['email'],
            'phone' => $data['phone'],
            'address' => $data['address'],
            'items' => [
                [
                    'name' => $data['product_name'],
                    'price' => $data['amount'],
                    'quantity' => 1,
                ],
            ],
            'url' => route('success'),
        ];

        try {
            // generate an invoice from slickpay
            $invoice = $this->slickpayService->generateInvoice($invoice_data);
        } catch (\Throwable $th) {
            Log::error($th);
            throw ValidationException::withMessages([
                'default' => $th->getMessage(),
            ]);
        }

        // create a new unpaid order with the invoice id
        Order::create([
            'checkout_id' => $invoice['id'],
            'amount' => $data['amount'],
            'status' => 'created',
            'customer_name' => $data['first_name'].' '.$data['last_name'],
            'customer_email' => $data['email'],
            'customer_phone' => $data['phone'],
        ]);

        // redirect to the slick pay checkout page
        return inertia()->location($invoice['url']);
    }
// ...

Note: I’m only using the ValidationException here for convenience; in a real-world application you should handle errors properly.

Now when you fill in your info and click pay, you should be redirected to the SlickPay checkout page:

slickpay payment page

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

order with created status

And after you fill in the test info, you should be redirected to the success page, and in the URL you should also see the invoice_id query parameter.

Now we verify the payment and update the associated order’s status on success (this should be done using a webhook, as we’ll see later):

app/Http/Controllers/CheckoutController.php:

// ...
	public function success(Request $request)
    {
        $invoice_id = $request->invoice_id;
        $order = Order::where('checkout_id', '=', $invoice_id)->firstOrFail();

        if ($order->status === 'paid') {
            return '<h1>Success!</h1>';
        }

        try {
            $invoice = $this->slickpayService->getInvoice($invoice_id);
        } catch (\Throwable $th) {
            Log::error($th);

            return '<h1 style="color: red;">Something went wrong!</h1>';
        }

        if (! isset($invoice['completed']) || ! $invoice['completed']) {
            return '<h1 style="color: red;">Order has not been paid yet!</h1>';
        }

        $order->update([
            'status' => 'paid',
            'paid_at' => $invoice['data']['date'] ?? now(),
        ]);

        return '<h1>Success!</h1>';
    }
// ...

app/Services/SlickpayService.php:

// ...
	public function getInvoice(string $invoice_id): mixed
    {
        $response = Http::acceptJson()
            ->withToken($this->api_key)
            ->timeout(30)
            ->get("{$this->url}/users/invoices/{$invoice_id}");

        $response->throw();

        return $response->json();
    }
// ...

Now after a successful payment you should see the order status change to paid and the paid at date is filled in the orders page:

orders index page

# Adding webhooks

Now imagine if the client paid but something went wrong and they weren’t redirected to the success page, or they closed the page before the redirect happened. 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 SlickPay to send us a request directly to our server whenever the client pays.

Okay, this is how it’s going to work: after the client pays, Slickpay will send us a request to the webhooks/payment that contains information about the invoice that was paid. All we need in our case is the id of the invoice, which we’ll use to find the order in our database and update its status to paid; after that, we will return a request with a status of 200, or a status of 500 if something went wrong.

routes/web.php:

// ...
Route::post('checkout', [CheckoutController::class, 'checkout'])
    ->name('checkout');

Route::post('webhooks/payment', [CheckoutController::class, 'webhook'])
    ->name('webhooks.payment');

Route::get('success', [CheckoutController::class, 'success'])
    ->name('success');
// ...

app/Http/Controllers/CheckoutController.php:

// ...
	public function webhook(Request $request)
    {
        try {
            $result = $this->slickpayService->processWebhook($request->toArray());

            if (! $result['success']) {
                Log::warning($result['error'], $result['payload'] ?? '');
            }

            return response()->noContent(200);
        } catch (\Throwable $th) {
            Log::error('Slick pay webhook processing failed', [
                'error' => $th,
                'payload' => $request->getContent(),
            ]);

            return response()->noContent(500);
        }
    }
// ...

app/Services/SlickpayService.php:

// ...
	public function processWebhook(array $data): array
    {
        $invoice_id = $data['id'];
        $order = Order::where('checkout_id', '=', $invoice_id)->first();

        // this part should be moved to a queued job because
        // most webhook providers expect a quick response
        if (! $order) {
            return [
                'success' => false,
                'error' => 'Order not found for Slick pay webhook',
                'payload' => [
                    'invoice_id' => $invoice_id,
                ],
            ];
        }

        if ($order->status === 'paid') {
            return ['success' => true];
        }

        $invoice = $this->getInvoice($invoice_id);

        if (! isset($invoice['completed']) || ! $invoice['completed']) {
            return [
                'success' => false,
                'error' => 'Payment failed',
                'payload' => [
                    'order_id' => $order->id,
                    'invoice_id' => $invoice_id,
                ],
            ];
        }

        Order::where('id', $order->id)
            ->where('status', '!=', 'paid')
            ->update([
                'status' => 'paid',
                'paid_at' => $invoice['data']['date'] ?? now(),
            ]);

        return ['success' => true];
    }
// ...

Note: webhook providers expect a quick response, so you should put the logic for fetching an invoice and updating the order status in a queued job.

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([
        'webhooks/payment',
    ]);
    // ...
})

## 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.

Now all that’s left is to add the webhook_url to the checkout method in the CheckoutController:

// ...
	$invoice_data = [
	    'amount' => $data['amount'],
	    'firstname' => $data['first_name'],
	    'lastname' => $data['last_name'],
	    'email' => $data['email'],
	    'phone' => $data['phone'],
	    'address' => $data['address'],
	    'items' => [
	        [
	            'name' => $data['product_name'],
	            'price' => $data['amount'],
	            'quantity' => 1,
	        ],
	    ],
	    'url' => route('success'),
	    'webhook_url' => route('webhooks.payment'),
	];
// ...

And don’t forget to remove the code that updates the order status in the success method:

// ...
	public function success(Request $request)
    {
        $invoice_id = $request->invoice_id;
        $order = Order::where('checkout_id', '=', $invoice_id)->firstOrFail();

        if ($order->status === 'paid') {
            return '<h1>Success!</h1>';
        }

        return '<h1 style="color: red;">Order has not been paid yet!</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 set up a webhook signature so that you make sure the requests are actually coming from Slickpay. In this case SlickPay expects a webhook_signature in the payload you send when generating an invoice, and it returns a header with that signature with each request, which you should verify in the webhooks method.
  • Webhooks should be Idempotent; even if the exact same webhook event is sent multiple times, there should be no duplicate actions in your code. In this example I’m kind of doing this by only updating the order if its status is not paid so that it only updates 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 CheckoutTest

tests/Feature/CheckoutTest.php:

<?php

use App\Models\Order;
use App\Services\SlickpayService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

// testing the checkout post route
test('clients can initiate checkout', function () {
    $invoice_id = random_int(1000, 100000);
    $invoice_url = 'https://example.com/checkout';

    /*
     * Mocking the SlickpayService class so that we don't make an http request to the real service.
     *
     *  - $this->mock(SlickpayService::class): creates a mock bound into the container for the test's lifecycle.
     *  - shouldReceive('generateInvoice'): sets up the expected method to be called in the CheckoutController.
     *  - once(): we expect the method to be called exactly once. If it's called more or less, the mock framework will fail the test.
     *  - andReturns([...]): returns the given array when generateInvoice is called in the CheckoutController.
    */
    $this->mock(SlickpayService::class)
        ->shouldReceive('generateInvoice')
        ->once()
        ->andReturns([
            'success' => 1,
            'message' => 'Facture créée avec succès.',
            'id' => $invoice_id,
            'url' => $invoice_url,
        ]);

    $data = getFormData();
    $response = $this->post(route('checkout'), $data);

    // assert the order was created
    $this->assertDatabaseHas('orders', [
        'checkout_id' => $invoice_id,
        'status' => 'created',
        'customer_name' => $data['first_name'].' '.$data['last_name'],
        'customer_email' => $data['email'],
    ]);

    // assert Inertia location redirect response
    $response->assertStatus(302);
    $response->assertRedirect($invoice_url);
});

// testing the success page
test('display success page and marks order paid when invoice is completed', function () {
    $invoice_id = random_int(1000, 100000);
    $data = getFormData();

    $order = createOrderWithInvoiceId($invoice_id);
    $date_paid = now();

    mockGetInvoiceMethod($invoice_id, $date_paid);

    $response = $this->get(route('success', [
        'invoice_id' => $invoice_id,
    ]));
    $response->assertStatus(200);
    $response->assertSee('Success!');

    // verify DB reflects the PAID status and paid_at timestamp
    $this->assertDatabaseHas('orders', [
        'id' => $order->id,
        'checkout_id' => $invoice_id,
        'amount' => $data['amount'],
        'status' => 'paid',
        'paid_at' => $date_paid,
    ]);
});

// testing the webhook
test('processes payment webhook and marks order as PAID', function () {
    $invoice_id = random_int(1000, 100000);
    $data = getFormData();

    $order = createOrderWithInvoiceId($invoice_id);
    $date_paid = now();

    mockGetInvoiceMethod($invoice_id, $date_paid);

    // trigger the webhook with fake data
    $webhook_data = ['id' => $invoice_id];
    $response = $this->post(route('webhooks.payment'), $webhook_data);
    $response->assertStatus(200);

    // verify DB reflects the PAID status and paid_at timestamp
    $this->assertDatabaseHas('orders', [
        'id' => $order->id,
        'checkout_id' => $invoice_id,
        'amount' => $data['amount'],
        'status' => 'paid',
        'paid_at' => $date_paid,
    ]);
});

function getFormData(): array
{
    return [
        'first_name' => 'First name',
        'last_name' => 'Last name',
        'email' => '[email protected]',
        'phone' => '0611223344',
        'address' => 'test Address',
        'product_name' => 'Product name',
        'amount' => 3000,
    ];
}

function createOrderWithInvoiceId($invoice_id): Order
{
    $data = getFormData();

    return Order::create([
        'checkout_id' => $invoice_id,
        'amount' => $data['amount'],
        'status' => 'created',
        'customer_name' => $data['first_name'].' '.$data['last_name'],
        'customer_email' => $data['email'],
    ]);
}

/**
 * mockGetInvoiceMethod
 *
 * Mocks the SlickpayService::getInvoice method to return a fake completed invoice payload.
 *
 * Important details:
 *  - makePartial(): makes a partial mock of the SlickpayService class. This means:
 *       - Methods explicitly set in shouldReceive will return the mocked values.
 *       - Any other methods will behave as the real implementation.
 *       - We're doing this because we only want to mock the getInvoice method and leave processWebhook method as is.
 *  - shouldReceive('getInvoice')->once()->andReturns([...]):
 *       - shouldReceive: choose which method to intercept.
 *       - once(): assert that it was called exactly once in this test run.
 *       - andReturns: provide the payload to return when the method is called.
 */
function mockGetInvoiceMethod($invoice_id, $date_paid): void
{
    test()->mock(SlickpayService::class)
        ->makePartial()
        ->shouldReceive('getInvoice')
        ->once()
        ->andReturns([
            'success' => 1,
            'completed' => 1,
            'data' => [
                'id' => $invoice_id,
                'completed' => 1,
                'date' => $date_paid,
            ],
        ]);
}

After running:

php artisan test --filter=CheckoutTest

You should see all tests pass:

tests execution image

## Important

I’m only testing the happy path now to give you an example of how to test this; for a real-world application, you should add more tests. For example:

  • Validation & input errors: Missing/invalid fields, invalid email, phone format, negative/zero amounts
  • External API failure handling: generateInvoice() fails, times out, or returns an error or empty payload.
  • Malformed Webhook data: Test the webhook with an empty array or an invalid invoice_id.
  • etc…

Have feedback or questions about this post? Feel free to reach out at [email protected]