Laravel
lunixi/laravel connects the PHP SDK (lunixi/php-sdk) to your Laravel application. Request signing, tokens, retries, idempotency and every Lunixi endpoint live in the SDK; the package adds:
- configuration from
.env, one or more connections (API keys) - the
Lunixifacade andLunixiClientinjection - requests through the Laravel HTTP client —
Http::fake()works in your tests - the bearer token kept in your cache, encrypted
- a webhook route with signature verification, redelivery handling and a Laravel event, handled in the request or on your queue
- the
<x-lunixi::checkout-form>Blade component for the Checkout Form lunixi:reconcileto read pending payments when a webhook is lost- the
lunixi:install,lunixi:checkandlunixi:generate-keycommands - a test helper that posts signed webhooks
Requirements
- PHP 8.2 or newer
- Laravel 12 or 13
ext-sodium,ext-curl,ext-json
Installation
composer require lunixi/laravel
php artisan lunixi:installPackage: Packagist (opens in a new tab) · Source code: GitHub (opens in a new tab)
The service provider and the Lunixi facade are registered automatically. lunixi:install publishes config/lunixi.php and lists the values still missing from .env for the default connection; it writes nothing to .env itself. When the webhook route is enabled it also prints the full webhook URL to register in the dashboard.
Keys and configuration
Generate the key pair on your application's server:
php artisan lunixi:generate-keyThe command writes the private key to storage/lunixi/private.pem (owner read/write only) and prints the public key. Use --path= for another location. It overwrites an existing key file only with --force; the old key stops working in Lunixi from then on.
Register the public key in the Lunixi dashboard, take the key id the dashboard shows and add it to .env:
LUNIXI_ENVIRONMENT=TEST
LUNIXI_KEY_ID=mk_test_sk_...
LUNIXI_PRIVATE_KEY_PATH=/var/www/app/storage/lunixi/private.pem
LUNIXI_WEBHOOK_SECRET=whsec_...LUNIXI_ENVIRONMENT defaults to LIVE; set it to TEST when you work with a test key. A relative LUNIXI_PRIVATE_KEY_PATH is resolved against the application root.
Keep the private key file out of version control and out of the public web root. LUNIXI_PRIVATE_KEY, which takes the key inline (\n for newlines), is for local development only.
Checking the connection
php artisan lunixi:checklunixi:check fetches a new token with your key and prints the connection name, environment, base URL and key id as a table. It never prints the key or the token. When LUNIXI_WEBHOOK_SECRET is empty it warns that webhooks will be answered with 503. Use --connection= to check another connection.
Calling Lunixi
Every SDK client is available through the facade or by injecting Lunixi\Sdk\LunixiClient:
use Lunixi\Laravel\Facades\Lunixi;
$payment = Lunixi::payments()->get($paymentId);use Lunixi\Sdk\LunixiClient;
final class RefundOrder
{
public function __construct(private readonly LunixiClient $lunixi) {}
public function __invoke(Order $order): void
{
$this->lunixi->payments()->refund(
$order->lunixi_payment_id,
$order->refund_amount_minor,
'Customer request',
"refund-{$order->id}-{$order->refund_attempt}"
);
}
}Clients available on the facade: payments(), paymentLinks(), subscriptions(), fraud(), kyc(), sanction(), marketplace(), identity(), wallet(), bankConnect(), reference(), insurance(). The method details are in the PHP SDK. SDK exceptions (Lunixi\Sdk\Exception\ApiException, AuthenticationException, ConfigurationException …) reach your code unchanged.
More than one key
Add another connection in config/lunixi.php and select it by name. Wallet bulk payouts, for example, are approved by a different key than the one that created them:
'connections' => [
'default' => [/* ... */],
'approver' => [
'base_url' => env('LUNIXI_BASE_URL', 'https://api-gateway.lunixi.com'),
'environment' => env('LUNIXI_ENVIRONMENT', 'LIVE'),
'key_id' => env('LUNIXI_APPROVER_KEY_ID'),
'private_key_path' => env('LUNIXI_APPROVER_PRIVATE_KEY_PATH'),
],
],$created = Lunixi::wallet()->bulkPayouts->create($payoutRequest, "payroll-{$period}");
$batchId = (string) $created['batch']['id'];
Lunixi::wallet()->bulkPayouts->submit($batchId);
$checker = Lunixi::connection('approver')->wallet()->bulkPayouts;
$checker->approve($batchId);
$checker->process($batchId);The facade and injection use the default connection (default), selected with LUNIXI_CONNECTION. → Bulk Payouts
Taking a payment
Create the payment intent
Create the intent when the buyer submits the order. Amounts are integers in the currency's minor unit (kuruş for TRY). Use a new idempotency key for each attempt — the order number alone is not enough, because the buyer may try again with another card.
use Lunixi\Laravel\Facades\Lunixi;
use Lunixi\Sdk\Payment\CreateIntentRequest;
$attempt = $order->attempts()->create();
$request = (new CreateIntentRequest($order->total_minor, 'TRY', $order->number))
->withCallbackUrl(route('checkout.return', $order));
$intent = Lunixi::payments()->createIntent($request, "order-{$order->id}-attempt-{$attempt->id}");
$order->update([
'lunixi_payment_id' => $intent->paymentId(),
'lunixi_checkout_script' => $intent->checkoutFormContent(),
'status' => 'pending',
]);Show the form
On your payment page:
<x-lunixi::checkout-form :script="$order->lunixi_checkout_script" />or straight from the intent: <x-lunixi::checkout-form :intent="$intent" />.
The gateway's script is rendered as is; it loads Lunixi Checkout and mounts the form. If the script is empty the component shows a "payment could not be started" message; change the text with the lunixi::checkout.unavailable translation.
With a Content Security Policy (CSP), pass your nonce:
<x-lunixi::checkout-form :script="$order->lunixi_checkout_script" :nonce="Vite::cspNonce()" />The script loads the Checkout runtime from the Lunixi CDN. With 'strict-dynamic' in script-src that load is trusted through the nonce; otherwise allow the Lunixi CDN host in script-src.
Handle the buyer's return
The buyer returns to your callbackUrl after 3D Secure. The return itself does not prove the payment: show a "payment is being confirmed" page. To show the state, read it with Lunixi::payments()->get($order->lunixi_payment_id). Do not mark the order paid here.
The webhook decides the order state
Your webhook listener updates the order. → Webhooks
Reconciliation covers a lost webhook
lunixi:reconcile stands in for a webhook that never arrives. → Reconciliation
Capture, void and refund
These are SDK calls, each with its own idempotency key. Without an amount the operation covers the full amount.
Lunixi::payments()->capture($order->lunixi_payment_id, null, "capture-{$order->id}");
Lunixi::payments()->void($order->lunixi_payment_id, "void-{$order->id}");
Lunixi::payments()->refund($order->lunixi_payment_id, 2500, 'Customer request', "refund-{$order->id}-{$order->refund_attempt}");Webhooks
The package registers POST /lunixi/webhook (route name lunixi.webhook). Register the route's full URL as an endpoint in the Lunixi dashboard and put the endpoint's secret in LUNIXI_WEBHOOK_SECRET. The route has no session or CSRF middleware; the signature is the authentication. → Webhook Setup
Writing a listener
Each verified webhook fires Lunixi\Laravel\Events\WebhookReceived:
use Lunixi\Laravel\Events\WebhookReceived;
final class UpdateOrderFromLunixi
{
public function handle(WebhookReceived $event): void
{
$webhook = $event->webhook;
if (!$webhook->matches('payment.*')) {
return;
}
$order = Order::where('lunixi_payment_id', $webhook->data()['intentId'] ?? null)->first();
if ($order === null) {
return;
}
match ($webhook->type()) {
'payment.captured', 'payment.authorized' => $order->markPaid(),
'payment.failed', 'payment.expired', 'payment.voided' => $order->markFailed(),
'payment.refunded', 'payment.partially_refunded' => $order->syncRefunds(),
default => null,
};
}
}Payment events carry the payment id as intentId in data() and amounts as minor-unit strings. → Payment webhooks. Other products' event names are constants in the SDK (InsuranceEvents, SubscriptionEvents, KycEvents …).
Lunixi delivers each event at least once. Your listener must give the same result when it receives the same event again. The package also remembers processed event ids (dedupe_ttl, default 7 days) and answers a repeat with duplicate without calling your listeners.
Response codes
| Answer | When | What Lunixi does |
|---|---|---|
200 ok | Listeners ran | Delivery done |
200 duplicate | Event id already processed | Delivery done |
200 queued | Queue mode: event put on your queue | Delivery done |
409 in_progress | The same event is being handled by another request | Delivers again later |
400 invalid_signature | Signature, timestamp or headers wrong | Delivers again later |
500 processing_failed | A listener threw | Delivers again later |
503 not_configured | LUNIXI_WEBHOOK_SECRET empty or mode invalid | Delivers again later |
A listener that throws leaves the event unprocessed; on the next delivery your listeners run again.
Handling webhooks on the queue
LUNIXI_WEBHOOK_MODE=queue
LUNIXI_WEBHOOK_QUEUE=lunixiThe signature is verified in the request, the answer is 200 queued, and ProcessWebhookJob runs your listeners on the queue. Select the queue connection with LUNIXI_WEBHOOK_QUEUE_CONNECTION. A failing listener fails the job; retries follow the webhooks.queue.tries and webhooks.queue.backoff settings, then the job goes to failed_jobs (retry it with php artisan queue:retry).
Your own route
Set LUNIXI_WEBHOOKS_ENABLED=false and register the route yourself, outside the web middleware group:
Route::lunixiWebhooks('payments/lunixi/hook');Cache
Processed event ids and the lock that stops two parallel deliveries of one event use the cache store in webhooks.cache_store (default store when null; LUNIXI_WEBHOOK_CACHE_STORE in .env). In production use a store shared by all your servers (Redis, database, Memcached).
Reconciliation
When a webhook is lost, lunixi:reconcile reads the payment from Lunixi. Implement PendingPaymentRepository for your orders and bind it:
use Lunixi\Laravel\Payments\PendingPaymentRepository;
final class PendingOrders implements PendingPaymentRepository
{
public function pendingPaymentIds(\DateTimeInterface $createdBefore, int $limit): iterable
{
return Order::where('status', 'pending')
->whereNotNull('lunixi_payment_id')
->where('created_at', '<', $createdBefore)
->oldest()
->limit($limit)
->pluck('lunixi_payment_id');
}
}
// AppServiceProvider::register()
$this->app->bind(PendingPaymentRepository::class, PendingOrders::class);Schedule the command:
// routes/console.php
Schedule::command('lunixi:reconcile')->everyFiveMinutes()->withoutOverlapping();By default the command reads at most 100 payments that have been pending for at least 15 minutes; change this with the --older-than= (minutes), --limit= and --connection= options or the reconcile.older_than_minutes and reconcile.limit settings. If a payment cannot be read, the command exits with a failure code.
Each payment read fires Lunixi\Laravel\Events\PaymentReconciled ($event->paymentId, $event->payment):
use Lunixi\Laravel\Events\PaymentReconciled;
use Lunixi\Sdk\Payment\PaymentStatus;
final class SettleReconciledOrder
{
public function handle(PaymentReconciled $event): void
{
$order = Order::where('lunixi_payment_id', $event->paymentId)->first();
if ($order === null) {
return;
}
$status = $event->payment->status();
if (in_array($status, [...PaymentStatus::CAPTURED_STATES, PaymentStatus::AUTHORIZED], true)) {
$order->markPaid();
} elseif (in_array($status, PaymentStatus::FINAL_FAILURE_STATES, true)) {
$order->markFailed();
}
}
}A payment can still be in progress or need reconciliation (PaymentStatus::NEEDS_RECONCILIATION). Mark the order paid only for a captured or authorized status, and leave it pending otherwise.
Testing your integration
Requests go through the Laravel HTTP client, so Http::fake() works:
Http::fake([
'*/api/v1/auth/token' => Http::response(['data' => ['access_token' => 'tok', 'expires_in' => 900]]),
'*/api/v1/payments/*' => Http::response(['data' => ['id' => 'pay_1', 'status' => 'CAPTURED']]),
]);The SignsLunixiWebhooks trait posts a webhook to your route signed the way Lunixi signs it, so your listeners run through real signature verification:
use Lunixi\Laravel\Testing\SignsLunixiWebhooks;
final class PaymentWebhookTest extends TestCase
{
use SignsLunixiWebhooks;
public function test_captured_payment_marks_order_paid(): void
{
config(['lunixi.webhooks.secret' => 'whsec_test']);
$order = Order::factory()->create(['lunixi_payment_id' => 'pay_1']);
$this->postLunixiWebhook('payment.captured', ['intentId' => 'pay_1', 'amount' => '10000', 'currency' => 'TRY'])
->assertOk();
$this->assertSame('paid', $order->fresh()->status);
}
}postLunixiWebhook($type, $data, $eventId, $secret, $uri): pass the same $eventId again to test redelivery; without $secret it uses lunixi.webhooks.secret; without $uri it posts to the lunixi.webhook route. Use the trait in a test class that extends Illuminate\Foundation\Testing\TestCase.
Configuration
php artisan vendor:publish --tag=lunixi-config publishes config/lunixi.php; every option is described in the file. The lunixi-views and lunixi-lang tags publish the view and the translations.
| Variable | Default | Description |
|---|---|---|
LUNIXI_BASE_URL | https://api-gateway.lunixi.com | |
LUNIXI_ENVIRONMENT | LIVE | LIVE or TEST |
LUNIXI_KEY_ID | — | Required |
LUNIXI_PRIVATE_KEY_PATH | — | Required (or LUNIXI_PRIVATE_KEY) |
LUNIXI_PRIVATE_KEY | — | Inline key, local development only |
LUNIXI_CONNECTION | default | Connection used by the facade and injection |
LUNIXI_WEBHOOK_SECRET | — | Required for webhooks |
LUNIXI_WEBHOOKS_ENABLED | true | false: you register the route yourself |
LUNIXI_WEBHOOK_PATH | lunixi/webhook | |
LUNIXI_WEBHOOK_MODE | sync | sync or queue |
LUNIXI_WEBHOOK_QUEUE_CONNECTION | default connection | Queue mode |
LUNIXI_WEBHOOK_QUEUE | default queue | Queue mode |
LUNIXI_WEBHOOK_CACHE_STORE | default store | Duplicate and lock records |
LUNIXI_TOKEN_CACHE_STORE | default store | Bearer token cache |
LUNIXI_HTTP_TRANSPORT | laravel | laravel or curl |
LUNIXI_LOG_CHANNEL | default channel |
Logs never contain request bodies, secrets, private keys or tokens.
Octane
Clients are built once per connection and hold no request state; the token is in the cache. They are safe to keep between Octane requests.
Related
- PHP SDK — installing the PHP SDK and what it covers
- Payment webhooks — event types and
datafields - Webhook Setup — endpoint and secret management
- Testing — working with a TEST key