Looking to hire Laravel developers? Try LaraJobs
This package is not available.

laravel maintained by lunixi

Description
Laravel integration for the Lunixi PHP SDK — configuration, container bindings, webhook route, checkout form component and payment reconciliation.
Author
Last update
2026/10/25 22:37 (dev-main)
License
Downloads
0

Comments
comments powered by Disqus

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)
  • Lunixi facade and LunixiClient injection
  • 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 form
  • lunixi:reconcile to read pending payments when a webhook is lost
  • lunixi: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

  1. 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',
    ]);
    
  2. 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' in script-src that load is trusted through the nonce, otherwise allow the Lunixi CDN host in script-src.

  3. The buyer returns to your callbackUrl after 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 with Lunixi::payments()->get($order->lunixi_payment_id). Do not mark the order paid here.

  4. The webhook decides the order state (next section).

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