nokimaro/liontech-laravel
Laravel integration for the FusionPayments (LionTech) payment gateway. Auto-discovered service provider, static facade, DI-registered API client singletons, publishable env-based config, webhook signature verification, and RSA card encryption. PHP 8.3+, Laravel 11–13.
Requirements check first. The package requires PHP 8.3+ and Laravel 11–13. If your app is still on PHP 8.2 or Laravel 10, stop here — this will not install. Verify with php -v and composer show laravel/framework before you run the installer.
Install. No provider registration is needed thanks to package auto-discovery:
composer require nokimaro/liontech-laravel
Configure via .env. This is the only mandatory setup step:
LIONTECH_ACCESS_TOKEN=your_access_token_here
LIONTECH_REFRESH_TOKEN=your_refresh_token_here
# Optional: sandbox mode
LIONTECH_SANDBOX=true
LIONTECH_BASE_URL=https://api.sandbox.fusionpayments.io
LIONTECH_SECURE_URL=https://secure.sandbox.fusionpayments.io
Publish the config file only if you need to inspect or override structure:
php artisan vendor:publish --tag=liontech-config
Where to look first, in this order:
config/liontech.php — the contract between your env and the SDK. Read the key names before touching anything else.Nokimaro\LionTech\Laravel\Config\LionTechConfig — the guardrail helper. Use LionTechConfig::isConfigured() and LionTechConfig::isSandbox() before attempting any API call.nokimaro/liontech-php-sdk) — the actual request/response DTOs (CreateOrderRequest, CreateRefundRequest, Money, Currency, WebhookPayload) live there, not in this wrapper. This Laravel package is mostly wiring; nearly all real documentation is in the SDK.auth(), orders(), payments(), refunds(), payouts(), tokens(), balances(), transfers(), signature(), encryptionKey().First use case — a checkout handoff. The canonical first integration is "create an order, redirect the customer." Do this in a controller action and store the order reference against your own orders row before redirecting:
use Nokimaro\LionTech\Laravel\Facades\LionTech;
use Nokimaro\LionTech\Requests\CreateOrderRequest;
use Nokimaro\LionTech\ValueObjects\Currency;
use Nokimaro\LionTech\ValueObjects\Money;
$order = LionTech::orders()->create(new CreateOrderRequest(
amount: new Money('100.00', Currency::USD),
description: 'Order #1234',
successUrl: route('checkout.success'),
declineUrl: route('checkout.decline'),
webhookUrl: route('webhooks.liontech'),
));
// persist $order->id against your local order, then redirect to the payment page
Sanity-check the wiring with php artisan tinker and LionTechConfig::isConfigured() before writing any UI.
Prefer constructor injection over the facade in application code; use the facade for tinker, one-off Artisan commands, and seeders. The facade works, but injected clients are easier to swap in tests and easier to trace in a large codebase.
use Nokimaro\LionTech\Client;
use Nokimaro\LionTech\Clients\PaymentsClient;
class PaymentController extends Controller
{
public function __construct(private readonly Client $liontech) {}
public function show(string $paymentId, PaymentsClient $payments)
{
return $payments->get($paymentId);
}
}
Wrap the SDK in your own domain service. Because the DTOs come from the SDK, keep them out of controllers and out of your domain events:
final class CheckoutService
{
public function __construct(private readonly Client $liontech) {}
public function startOrder(Order $order): string
{
$result = $this->liontech->orders()->create(new CreateOrderRequest(
amount: new Money($order->amount, Currency::USD),
description: "Order #{$order->id}",
successUrl: route('checkout.success', $order),
declineUrl: route('checkout.decline', $order),
webhookUrl: route('webhooks.liontech'),
));
$order->update(['gateway_order_id' => $result->id]);
return $result->paymentUrl;
}
}
This single seam is where you later add idempotency keys, retries, and logging, and where you swap in a fake for tests.
Webhook handling: verify first, parse second, queue third. The verifier needs the raw body, not the parsed payload, and it is registered as a singleton for DI:
use Nokimaro\LionTech\Security\WebhookSignatureVerifier;
use Nokimaro\LionTech\Webhooks\WebhookPayload;
class LionTechWebhookController extends Controller
{
public function __invoke(Request $request, WebhookSignatureVerifier $verifier)
{
if (! $verifier->verify($request->headers->all(), $request->getContent())) {
abort(403, 'Invalid webhook signature');
}
$payload = WebhookPayload::fromJson($request->getContent());
if ($payload->isSuccessful()) {
ProcessSuccessfulPayment::dispatch($payload);
} elseif ($payload->isDeclined()) {
ProcessDeclinedPayment::dispatch($payload);
}
return response()->json(['status' => 'ok']);
}
}
Return 200 immediately and do the work in a queued job. Gateways retry aggressively on non-2xx and on timeouts, so slow webhook handlers turn into duplicate events.
Idempotency at the handler, not the gateway. Deduplicate on the payment/order identifier from the payload before dispatching, or make the job idempotent (updateOrCreate on your payment record keyed by gateway id). You will receive the same event more than once in production.
Card encryption: encrypt server-side only as a fallback. CardEncryptor::encryptForPayment() is the escape hatch for when you cannot tokenize client-side. If you use it:
public function __construct(private readonly CardEncryptor $encryptor) {}
$encrypted = $this->encryptor->encryptForPayment([
'pan' => '4405639704015096',
'cvv' => '123',
'exp_month' => 12,
'exp_year' => 2030,
'cardHolder' => 'John Doe',
]);
Treat this endpoint like a vault door: no request logging, no dd() in that path, no queue payloads containing the plaintext array, and no session flash of the form input. The encryption only protects the wire — plaintext PAN touching your server already expands PCI scope.
Multi-tenant: bypass the container singleton deliberately. The provider registers exactly one Client built from config('liontech.*'). For per-tenant credentials, construct the SDK client directly per request:
$client = new Client(
accessToken: $tenant->liontech_access_token,
refreshToken: $tenant->liontech_refresh_token,
baseUrl: config('liontech.base_url'),
secureUrl: config('liontech.secure_url'),
);
Don't fight the container for this. Instead, introduce a small factory of your own (LionTechClientFactory::forTenant($tenant)) and inject that everywhere; it keeps the "when do I get the singleton vs. a tenant client" decision in one place instead of scattered across controllers.
Testing pattern. There is no built-in fake or LionTech::fake(). Two viable approaches:
$this->app->instance(Client::class, $fakeClient); inside a test service provider, and make your CheckoutService depend on your own interface instead of Client.For webhook controller tests, build a request with $this->call('POST', '/webhook', [], [], [], $server, $rawJson) so the raw content is preserved.
Reconciliation and operations. Two routine jobs worth having from day one: a nightly balances() snapshot for finance, and a poller over payments()->get($id) for any order stuck pending past N minutes — webhooks get missed, and the API is the source of truth.
It is unofficial, single-maintainer, zero dependents. That's not a reason to avoid it, but it is a reason to (a) pin the version in composer.json rather than floating ^1.3, (b) keep the SDK behind your own service/factory seam so a fork or replacement is a one-file change, and (c) read the CHANGELOG on every dependency update. CardEncryptor has already shipped a bug that used the wrong key endpoint.
Pin to SDK ^1.1 or later. Version 1.1.0 fixed CardEncryptor's fallback: it was calling signature()->getPublicKey() (the webhook signature key) instead of encryptionKey()->getPublicKey() (the card encryption key). If you were relying on auto-fetch of the card key on 1.0.x, your encryption was silently using the wrong public key. The EncryptionKeyClient singleton and LionTech::encryptionKey() facade method only exist from 1.1.0.
Environment values are read at config-build time. The Client singleton is constructed from config('liontech.*'). If you run php artisan config:cache (as you should in production), env() is not consulted at runtime — changing LIONTECH_ACCESS_TOKEN in .env does nothing until you re-run config:cache. This is the single most common "my credentials are correct but it's failing" report.
Mutating the singleton at runtime does not work. Because it's a singleton, config(['liontech.access_token' => ...]) mid-request will not rebuild the client. For per-tenant or per-request credentials, instantiate new Client(...) yourself (see the multi-tenant pattern).
Empty-string env values are treated as null from 1.1.3 onward. LIONTECH_WEBHOOK_PUBLIC_KEY= triggers the API auto-fetch fallback rather than passing an empty string to the verifier/encryptor. This is the behavior you want — but confirm your installed version, because on older releases an empty value propagated into the verifier and produced confusing signature failures.
Webhook verification requires the raw body. $request->getContent(), always. Any middleware that re-encodes the JSON (some logging or "clean request" middleware do), or any proxy/API gateway that rewrites the payload, will break the signature. Also exclude the webhook route from CSRF — in Laravel 11+ that means putting it in routes/api.php or excluding it in bootstrap/app.php's validateCsrfTokens(except: [...]); in Laravel 10-style apps, the VerifyCsrfToken $except array. Symptom of a CSRF miss: a 419 instead of your 403.
Header handling. $request->headers->all() yields arrays of values per header; the verifier expects that shape, so don't pass $request->header('X-Signature') (a string) instead. If you're behind a load balancer, make sure your trusted-proxy config is right so header casing/normalization doesn't drift.
The 1.3.0 domain migration. Default API URLs moved from the old LionTech hosts to api.fusionpayments.io / secure.fusionpayments.io. If you hardcoded old hostnames in LIONTECH_BASE_URL or in allowed-outbound-hosts firewall rules, you're keeping the old hosts alive and won't benefit from the migration — audit those. Conversely, if your firewall or proxy allowlist is host-based, add the new domains before upgrading.
Sandbox vs. explicit URLs. LIONTECH_SANDBOX and explicit LIONTECH_BASE_URL/LIONTECH_SECURE_URL both exist; when both are set, understand which wins in your published config before debugging a "why is it hitting sandbox in production" incident. The safe production posture is: no LIONTECH_SANDBOX, explicit URLs only if you need them, and a LionTechConfig::isSandbox() assertion in a boot-time check or health endpoint.
Public key config accepts either a path or PEM content. Paths are safer (no multiline quoting in .env). If you inline PEM content, quote it properly and be aware that config:cache will bake it in — rotating the key then requires a config rebuild. Prefer a mounted file path and let the SDK read it.
No retry, timeout, or circuit-breaker layer is provided. The wrapper exposes SDK clients; resilience is your job. Wrap outbound calls in try/catch around SDK exceptions, set sane timeouts, and never call the gateway synchronously inside a web request that a customer is waiting on — queue it and show a pending state.
Debugging playbook. In order: LionTechConfig::isConfigured() (false means your env/config never landed), then LionTechConfig::isSandbox() (wrong-environment surprises), then php artisan config:clear && php artisan config:cache, then a tinker call to LionTech::balances() as the cheapest authenticated endpoint to prove connectivity and credentials before blaming a specific request DTO. A 401 points at tokens; a 403 on webhooks points at the signature key, not the access token — those are two different key materials and the pre-1.1.0 CardEncryptor bug is a good reminder of how easy that confusion is.
Extension points worth knowing. (1) app(config/liontech.php) — republish and edit for custom key names or defaults. (2) Container overrides in AppServiceProvider::register() — $this->app->bind(Client::class, fn () => ...) if you need a decorated client. (3) The Client constructor itself — the escape hatch for multi-account and for tests. (4) The SDK repo — if you need a client method this wrapper doesn't surface, it's almost certainly already in the SDK and reachable through LionTech::.
How can I help you explore Laravel packages today?