laravel maintained by lunixi
Lunixi for Laravel
Laravel integration for the Lunixi PHP SDK (lunixi/php-sdk).
The SDK does the work — request signing, tokens, retries, idempotency, every
Lunixi API. This package connects it to Laravel:
- configuration from
.env, one or more connections (API keys) Lunixifacade andLunixiClientinjection- requests through the Laravel HTTP client, so
Http::fake()covers them - bearer token in your cache, encrypted
- webhook route with signature verification, redelivery handling and a Laravel event, handled in the request or on your queue
<x-lunixi::checkout-form>Blade component for the checkout formlunixi:reconcileto read pending payments when a webhook is lostlunixi:install,lunixi:check,lunixi:generate-key- a test helper that posts signed webhooks
Requirements: PHP 8.2+, Laravel 12 or 13, ext-sodium, ext-curl, ext-json.
Installation
composer require lunixi/laravel
php artisan lunixi:install
php artisan lunixi:generate-key
lunixi:generate-key writes the private key to storage/lunixi/private.pem
(owner read/write only) and prints the public key. Register the public key in
the Lunixi dashboard, then set:
LUNIXI_ENVIRONMENT=TEST
LUNIXI_KEY_ID=mk_test_sk_...
LUNIXI_PRIVATE_KEY_PATH=/var/www/app/storage/lunixi/private.pem
LUNIXI_WEBHOOK_SECRET=whsec_...
php artisan lunixi:check
lunixi:check fetches a token with your key and prints the environment and
key id. It never prints the key or the token.
Keep the private key file out of version control and out of the public web
root. LUNIXI_PRIVATE_KEY (the key inline, \n for newlines) is for local use
only.
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}"
);
}
}
The method reference is in the SDK README. SDK exceptions
(Lunixi\Sdk\Exception\ApiException, AuthenticationException,
ConfigurationException …) reach your code unchanged.
More than one key
Add a connection in config/lunixi.php and select it by name. Wallet bulk
payouts, for example, are approved by a second key:
'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);
Taking a payment
-
Create the payment 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 the Lunixi checkout and mounts the form. If the script is empty the component shows a "payment could not be started" message (translatable:lunixi::checkout.unavailable).With a Content Security Policy, pass your nonce:
<x-lunixi::checkout-form :script="..." :nonce="Vite::cspNonce()" />. The script loads the checkout runtime from the Lunixi CDN; with'strict-dynamic'inscript-srcthat load is trusted through the nonce, otherwise allow the Lunixi CDN host inscript-src. -
The buyer returns to your
callbackUrlafter 3D Secure. The return itself does not prove the payment: show a "payment is being confirmed" page, and if you want to show the state, read it withLunixi::payments()->get($order->lunixi_payment_id). Do not mark the order paid here. -
The webhook decides the order state (next section).
-
Reconciliation covers a webhook that never arrives (next sections).
Capture, void and refund are SDK calls (payments()->capture(), ->void(),
->refund()), each with its own idempotency key.
Webhooks
The package registers POST /lunixi/webhook (route name lunixi.webhook).
Register its full URL 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.
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 and amounts as minor-unit
strings under data(). Other products' event names are constants in the SDK
(InsuranceEvents, SubscriptionEvents, KycEvents …).
Lunixi delivers each event at least once, so a listener must give the same
result when it receives the same event again. The package also remembers
processed event ids (for dedupe_ttl, default 7 days) and answers a repeat
with duplicate without calling your listeners.
| Answer | When | What Lunixi does |
|---|---|---|
200 ok |
Listeners ran | Done |
200 duplicate |
Event id already processed | Done |
200 queued |
Queue mode: event put on your queue | Done |
409 in_progress |
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 (or mode) not set |
Delivers again later |
A listener that throws leaves the event unprocessed, so the next delivery runs your listeners again.
Handling webhooks on the queue
LUNIXI_WEBHOOK_MODE=queue
LUNIXI_WEBHOOK_QUEUE=lunixi
The signature is verified in the request, the answer is 200 queued, and
ProcessWebhookJob runs your listeners on the queue. A failing listener fails
the job; retries follow webhooks.queue.tries and webhooks.queue.backoff,
then the job goes to failed_jobs (retry it with php artisan queue:retry).
Own route
Set LUNIXI_WEBHOOKS_ENABLED=false and register the route yourself, outside the
web middleware group:
Route::lunixiWebhooks('payments/lunixi/hook');
Cache
Processed ids and the lock that stops two parallel deliveries of one event
use the cache store in webhooks.cache_store (default store when null). Use a
store shared by all your servers (Redis, database, Memcached) in production.
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);
// routes/console.php
Schedule::command('lunixi:reconcile')->everyFiveMinutes()->withoutOverlapping();
Each payment read fires Lunixi\Laravel\Events\PaymentReconciled with the
payment ($event->payment->status()). 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']]),
]);
Post a correctly signed webhook to your listeners:
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);
}
}
Configuration
php artisan vendor:publish --tag=lunixi-config publishes config/lunixi.php.
Every option is described there. Other tags: lunixi-views, lunixi-lang.
| Variable | Default | |
|---|---|---|
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_WEBHOOK_SECRET |
— | Required for webhooks |
LUNIXI_WEBHOOK_MODE |
sync |
sync or queue |
LUNIXI_WEBHOOK_PATH |
lunixi/webhook |
|
LUNIXI_TOKEN_CACHE_STORE |
default store | |
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.
License
MIT