lunixi / laravel
Laravel integration for the Lunixi PHP SDK — configuration, container bindings, webhook route, checkout form component and payment reconciliation.
Requires
- php: ^8.2
- illuminate/bus: ^12.0 || ^13.0
- illuminate/cache: ^12.0 || ^13.0
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/events: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/queue: ^12.0 || ^13.0
- illuminate/routing: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- illuminate/view: ^12.0 || ^13.0
- lunixi/php-sdk: ^1.0.1
Requires (Dev)
- larastan/larastan: ^3.0
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Version 1.0.0 changes all money to canonical minor-unit strings. Read the migration guide.
Lunixi for Laravel
Documentation · Changelog · Issues · Security
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.
Laravel adapter 1.0.0 requires PHP SDK ^1.0.1 and the canonical money-string API. Both the adapter and PHP SDK 1.0.1 are published on Packagist. Composer resolves the SDK dependency; normal installation needs no VCS override or local SDK source.
Installation
composer require "lunixi/laravel:^1.0"
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
The existing Blade component renders the Lunixi form on your own website and is the default integration path. Enabled methods, installments and multi-card eligibility remain controlled by the Lunixi form. There is no package admin UI for selecting embedded or hosted mode.
-
Create the payment intent when the buyer submits the order. Amounts are integers in the currency's minor unit (kuruş for TRY). Authorize the customer against the order and protect the checkout POST with Laravel's native CSRF middleware. Reserve the attempt and its original request/key durably before calling Lunixi. Reuse that key after a timeout; changing the card, refreshing the page or switching presentation does not establish a safe new attempt. Resolve uncertain or failed outcomes before permitting another attempt.
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.Render only the gateway script received by your authenticated server-side SDK call; never accept markup from a browser parameter. Enforce ownership before reading the saved script, encrypt its token-bearing model attribute at rest, and send
Cache-Control: private, no-storeandReferrer-Policy: no-referrer. The component renders HTML; it does not add authorization, CSRF or durable payment orchestration to your application. -
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.
Optional server-side redirect
With PHP SDK ^1.0.1 and a backend that returns paymentPageUrl, an application
can explicitly offer hosted checkout instead of rendering the component. Use
the same saved intent and original attempt; the backend URL may carry a token
different from the embedded token. The SDK validates its exact HTTPS payment
origin and path. Do not construct a URL from token().
use Illuminate\Support\Facades\Gate; // In your authenticated, CSRF-protected checkout POST; define the order policy. Gate::authorize('pay', $order); $url = $intent->paymentPageUrl(); // same authenticated create-intent response abort_if($url === '', 409, 'Payment session requires reconciliation.'); return redirect()->away($url, 303, [ 'Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer', ]);
If the URL is absent, preserve the attempt and reconcile; do not create another intent or key just to obtain a redirect. The embedded form can still be used when its original session is valid. Encrypt a saved URL and never log it. A browser return remains informational in either mode. Payment links and subscription portal redirects are separate products.
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