Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Liontech Php Sdk Laravel Package

nokimaro/liontech-php-sdk

A type-safe PHP 8.3 SDK for the FusionPayments (formerly LionTech) gateway, covering orders, payments, refunds, payouts, tokens, transfers, and balances. PSR-7/17/18 compatible with webhook verification, RSA card encryption, and automatic token refresh.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install (Laravel path first). The standalone SDK is framework-agnostic and needs a PSR-18 client. In a Laravel app you have two options:

# Option A — recommended: the Laravel wrapper (service provider, facade, config file)
composer require nokimaro/liontech-laravel

# Option B — raw SDK + Guzzle
composer require nokimaro/liontech-php-sdk guzzlehttp/guzzle

Option A is almost always what you want day-to-day; it removes the boilerplate below. Option B is what you fall back to when you need to control the container binding yourself (multi-tenant credentials, multiple merchant accounts, custom Guzzle middleware).

2. Verify the runtime. PHP 8.3+ is a hard requirement — enums, readonly classes, typed properties. If your app is on 8.2, stop here; this is not a package you can shim.

3. Configure credentials. For the raw SDK, put these in config/services.php and .env:

// config/services.php
'fusionpayments' => [
    'access_token'   => env('FUSIONPAYMENTS_ACCESS_TOKEN'),
    'refresh_token'  => env('FUSIONPAYMENTS_REFRESH_TOKEN'),
    'base_url'       => env('FUSIONPAYMENTS_BASE_URL', 'https://api.sandbox.fusionpayments.io'),
    'secure_url'     => env('FUSIONPAYMENTS_SECURE_URL', 'https://secure.sandbox.fusionpayments.io'),
],

4. Bind the client as a singleton in a service provider. Don't new Client(...) inside controllers — the client holds token state that refreshAndApply() mutates, and you want one instance per request/worker:

$this->app->singleton(Client::class, fn () => Client::builder()
    ->accessToken(config('services.fusionpayments.access_token'))
    ->sandbox() // or ->baseUrl(...)->secureUrl(...)
    ->build());

5. First use case: an order + hosted redirect. This is the lowest-risk integration and the one to build first. It never touches card data on your server:

$order = $client->orders()->create(new CreateOrderRequest(
    amount: new Money('100.00', Currency::USD),
    customer: new CustomerData(email: $user->email, fullName: $user->name, ip: $request->ip()),
    autoApprove: true,
    successUrl: route('checkout.success'),
    declineUrl: route('checkout.decline'),
    webhookUrl: route('webhooks.fusionpayments'),
    description: 'Order #'.$cart->id, // required, despite the spec
));

return redirect()->away($order->payUrl);

Where to look first, in order: the README Quick Start → the examples/ directory (especially 04_webhook_verification.php, which shows the typed webhook path) → changelog/ (it documents real API behaviour the OpenAPI spec gets wrong). The changelog is unusually load-bearing for this package; read 1.1.0 and 1.2.0 before writing any integration code.

Implementation Patterns

Wrap the client behind your own interface. With 0 stars and no official vendor support, you do not want Nokimaro\LionTech\* types leaking into 30 controllers. Define a thin PaymentGateway contract returning your own DTOs, and keep the SDK as one implementation. This is also the only practical way to test — the SDK ships no fake/mock client.

interface PaymentGateway {
    public function startCheckout(Cart $cart): string; // returns pay URL
    public function refund(string $paymentId, string $amount): string;
}

Webhook controller — the canonical shape. Verify the raw body, parse into the typed DTO, hand off to a queued job, respond 200 immediately:

public function __invoke(Request $request, Client $client)
{
    $raw = $request->getContent();

    if (! $client->webhookVerifier()->verify($request->headers->all(), $raw)) {
        abort(401);
    }

    $webhook = WebhookPayload::fromJson($raw);
    $payment = $webhook->payment;

    if ($payment->isSuccessful()) {
        ProcessSuccessfulPayment::dispatch($payment->orderId, $payment->txnId);
    } elseif ($payment->isDeclined()) {
        ReportDeclinedPayment::dispatch($payment->paymentId, $webhook->error?->description);
    }

    return response()->noContent();
}

Idempotency via createWithId. Any write you might retry — payments, refunds, payouts — should go through createWithId('your-deterministic-key', $request) using a key derived from your own domain (e.g. refund:{order}:{refund_number}). This is the only retry-safety mechanism the SDK exposes; there is no Idempotency-Key header abstraction.

Token refresh as a retry policy, not a cron job. refreshAndApply() mutates the singleton in place, so the natural pattern is catch-refresh-retry:

try {
    $payment = $client->payments()->create($request);
} catch (TokenExpiredException) {
    $client->auth()->refreshAndApply(new RefreshTokenRequest($storedRefreshToken));
    $payment = $client->payments()->create($request);
}

Persist the new refreshToken returned by the refresh call — treat it as rotating state in your database, not just in memory.

Exception → HTTP mapping in bootstrap/app.php / the exception handler. The SDK's exceptions do not extend Laravel's HttpException, so map them explicitly:

SDK exception Your response
ValidationException 422, surface $e->getErrors()
ResourceNotFoundException 404
AuthenticationException / TokenExpiredException 502/500 (log loudly, don't leak)
ConflictException 409
RateLimitException 429 + backoff/retry
TransportException 503, queue retry

Queue jobs with retry on transport only. Retry TransportException and RateLimitException; never retry ValidationException or ConflictException — that's a bug or an already-applied write. Pair with createWithId so a retried job can't double-charge.

Cache the key fetches. Both webhookVerifier() and cardEncryptor() auto-fetch PEM keys when constructed without an argument. In production, fetch once, cache the PEM (long TTL), and inject it:

$verifier = $client->webhookVerifier(Cache::rememberForever('fp:pubkey', fn () => $client->signature()->getPublicKey()));

This removes a blocking HTTP call from every webhook and every payment request. On a busy webhook endpoint, this is the difference between p95 40ms and p95 400ms.

Card payments: keep encryption server-side and stateless. Encrypt immediately before the call, never log the plaintext, never persist either form:

$enc = $client->cardEncryptor($cachedPem)->encryptForPayment([
    'pan' => $pan, 'cvv' => $cvv, 'exp_month' => $month,
    'exp_year' => $year, 'cardHolder' => $holder,
]);

$payment = $client->payments()->create(new CreatePaymentRequest(
    amount: new Money('50.00', Currency::USD),
    paymentData: PaymentData::card(new EncryptedCardData($enc['encryptedCardData'], $enc['cardHolder'])),
    customer: new CustomerData(email: $email, ip: $request->ip(), fingerprint: $fingerprint),
    orderId: $order->orderId,
    autoApprove: true,
    backLink: route('payment.result'),
));

if ($payment->requiresRedirect()) {
    return redirect()->away($payment->getRedirectUrl()); // 3DS
}

Prefer the redirect flow over raw card data wherever you can. The order payUrl / PaymentData::sbp() / hosted flows keep you out of PCI scope; the encrypted-card path is there for server-to-server/merchant-initiated cases.

Testing strategy. There is no built-in fake. Use three layers: (1) your own PaymentGateway interface with an in-memory fake for feature tests; (2) the sandbox + published test cards (5522 0427 0506 6736 → 3DS, OTP 123456; 4405 6397 0401 5096 → no 3DS) in a nightly/manual suite; (3) webhookVerifier($pem) with a test keypair to unit-test your webhook controller without the network.

For observability and tenancy, inject a Guzzle client with logging middleware via Transport. That's your extension point for request IDs, timing, and per-merchant credentials without forking the SDK.

Gotchas and Tips

Required fields the OpenAPI spec calls optional. CreateOrderRequest::$description and CreateRefundRequest::$webhookUrl are required constructor arguments as of 1.1.0 — the API 400s without them. Don't try to pass null.

Verify the raw body, never $request->all(). Signature verification is over the exact bytes. Re-encoding with json_encode() reorders keys and changes escaping, and verification will fail intermittently and confusingly. Use $request->getContent().

Header shape matters. The README uses getallheaders(), which doesn't exist under PHP-FPM in Laravel's typical setup. $request->headers->all() returns arrays-of-values with lowercase keys — check what the verifier expects and normalise deliberately. This is a common silent 401.

The singleton holds token state — and that state is process-local. refreshAndApply() updates the client object in memory only. Under Octane, Horizon workers, or plain multi-process FPM, each worker starts with whatever token your config provides. Always rehydrate accessToken/refreshToken from the database (or a cache lock) on boot, and use Cache::lock() around refreshes so ten concurrent workers don't each burn a refresh.

A rotated refresh token that isn't persisted is a lost integration. Store the new refresh token from RefreshTokenResponse immediately; if the provider invalidates the old one, an in-memory-only refresh silently bricks your account until manual re-auth.

Domain migration is a real trap. 1.2.0 changed defaults from *.liontechnology.ai to *.fusionpayments.io; the old domain was decommissioned. If you pinned baseUrl/secureUrl in .env before that, you're pointing at a dead host. Grep for liontechnology and upgrade to ^1.2.

Pin >= 1.1.1 at minimum. It fixes PaymentData::sbp() serialising object as [] instead of {} (which caused gateway-side unmarshal errors), fixes Transport::get() silently dropping auth headers, and fixes ApiExceptionMapper unwrapping the {type, object, error} envelope. Those three bugs are individually enough to waste a day.

SbpData no longer exists. Since 1.1.1 it's PaymentData::sbp() with no argument. Any tutorial or snippet you find passing new SbpData(...) is stale.

Debugging an opaque TransportException. It can mean network failure, a 5xx, or an unparseable response. Log the exception message (since 1.1.1 error details are appended, e.g. "Invalid input parameters. Payment method is not enabled for this merchant site.") plus your own request ID from Guzzle middleware. Without the middleware you have no request ID to give anyone.

requiresRedirect() before isSuccessful(). A payment needing 3DS is not a failure — checking isSuccessful() first will make you report false declines on legitimately pending payments. Order the branch checks: redirect → successful → declined.

Money takes a string, and minor-unit semantics aren't documented. new Money('100.00', Currency::USD) reads as major units, but verify against the sandbox before trusting it in production for zero-decimal currencies (JPY) and for refunds where an off-by-100 is a real financial bug. Write a test asserting a refund amount round-trips as expected.

No retry, no backoff, no circuit breaker. RateLimitException is thrown and that's the whole story. Put retry/backoff in your queue job config or a Guzzle middleware, and treat the SDK as transport only.

Key fetching is a hidden network call. Both webhookVerifier() and cardEncryptor() without arguments hit the API. In a webhook handler that's unacceptable — cache the PEM and pass it in. Also handle key rotation: don't rememberForever without a way to bust the cache.

Never let PAN, CVV, or encryptedCardData reach your logs, error tracker, or queue payloads. Laravel's exception handler will happily serialise request payloads into Sentry. Add these fields to your dontFlash/scrubber config on day one.

Vendor-risk mitigation. Zero stars, zero dependents, one maintainer, unofficial SDK, and a fresh rebrand — this is a reasonable package technically (96% coverage, PHPStan, ECS in CI) but it is a single point of failure. Keep the PaymentGateway interface boundary, pin exact minor versions in composer.json, and make sure your team could swap to raw HTTP calls if the package goes stale. The domain-oriented, typed request objects make that boundary easy — which is exactly why you shouldn't skip it.

Rebrand note for naming. Package name and namespace stayed nokimaro/liontech-php-sdk / Nokimaro\LionTech through the FusionPayments rebrand. Search for "FusionPayments" and "LionTech" when onboarding — both appear, and they're the same thing.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table