amashukov / lnd-client-php
Typed LND (Lightning Network Daemon) REST client in pure PHP — invoices, payments, channel management and backups over any PSR-18 client, with millisatoshi amounts kept exact as bcmath numeric-strings.
Requires
- php: >=8.3
- ext-bcmath: *
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
Requires (Dev)
- amashukov/rector-php-rules: ^0.4
- friendsofphp/php-cs-fixer: ^3.50
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.0
- rector/rector: ^2.0
Suggests
- amashukov/http-client-php: PSR-18 client with retry, header-injection and timeout middleware — the macaroon header and retry policy belong there, not in this package.
README
Typed LND (Lightning Network Daemon) REST client in pure PHP — invoices, payments, channel management and backups over any PSR-18 client, with millisatoshi amounts kept exact as bcmath numeric-strings.
An LND client in pure PHP speaking the node's REST gateway, built on any PSR-18 HTTP client. It covers the surface a custodial service actually needs: mint an invoice, watch it settle, pay a BOLT11, track that payment to a verdict, read channel and wallet balances, and drive channel lifecycle (open, close, fee policy, static backup) from an operator panel rather than lncli over ssh.
Features
- Millisatoshi arithmetic that cannot silently lose money —
Msatis a bcmath numeric-string value object. No floats anywhere;2100000000000000000msat survives a round trip that IEEE-754 would mangle. Sub-satoshi remainders are rejected loudly instead of being rounded away. - A timeout is not a failure —
sendPayment()raisesLndUnavailableExceptionwhen the node is unreachable or the call times out, andLnPaymentStatetreats every unrecognised or unknown state as unresolved. OnlySUCCEEDEDandFAILEDare terminal, so a caller can never mistake "I don't know yet" for "it failed" and pay twice. - Transport-agnostic — bring your own PSR-18 client and PSR-17 factories. The macaroon header, TLS pinning, retries and timeouts are middleware concerns of the host application, deliberately not baked in.
- Typed value objects —
LnInvoice,LnPayment,LnChannel,LnChannelBalance,LnWalletBalance,LnNodeInfo,LnChannelPoint,LnChannelBackup. They are total functions over wire data: a missing invoice becomes an absent instance, never an exception and nevernull. - Wire quirks handled — LND returns payment hashes and funding txids base64-encoded in some responses and hex in others; the VOs normalise both to lowercase hex. The
v2/routerstreaming endpoints wrap their payload in aresultenvelope, which is unwrapped transparently. - Zero framework coupling — no Symfony, no Laravel, no
App\namespace. PHPStan level 8 acrosssrcandtests.
Installation
composer require amashukov/lnd-client-php
Requires PHP >= 8.3 and ext-bcmath.
Usage
use Amashukov\Lnd\LndClient; use Amashukov\Lnd\Msat; use Nyholm\Psr7\Factory\Psr17Factory; $psr17 = new Psr17Factory(); // The macaroon header and TLS options belong in your PSR-18 pipeline, not here. $client = new LndClient($http, $psr17, $psr17, 'https://lnd:8080'); $invoice = $client->addInvoice(Msat::fromBtc('0.0005'), 'order-7f3a91c2', 3600); echo $invoice->bolt11; // lnbc500u1p... echo $invoice->paymentHash; // lowercase hex, ready to persist // later, from a poller if ($client->lookupInvoice($invoice->paymentHash)->isSettled()) { // credit the order }
Paying an invoice, with a hard cap on routing fees:
$payment = $client->sendPayment($bolt11, feeLimit: Msat::fromString('150000'), timeoutSeconds: 60); if ($payment->isFailed()) { // terminal: no funds left the node }
Settling a payment safely
sendPayment() returning is not the end of the story, and neither is it throwing. The only
authority on whether money moved is trackPayment():
$state = $client->trackPayment($paymentHash); match (true) { $state->isSucceeded() => $order->finalize(), $state->isFailed() => $order->markFailed(), default => null, // still unresolved — poll again, do NOT re-send };
If sendPayment() throws LndUnavailableException, the HTLC may still be in flight. Treat that
as unresolved and let the next poll decide. Mapping it to failure and re-sending is how a
custodial service double-pays.
Channel operations
$client->connectPeer($pubkey, 'node.example:9735'); $point = $client->openChannel($pubkey, localFundingSat: '10000000', satPerVbyte: 8); foreach ($client->listChannels() as $channel) { // basis points, for liquidity alerting $channel->localShareBps(); $channel->inboundShareBps(); } $client->closeChannel($point, force: false, satPerVbyte: 8);
Backups
exportChannelBackup() returns the multi-channel static backup blob. It is the only artefact
that can recover funds from a lost node — and it is worthless if the recovered node is later
started from stale channel state. Store it off-box, encrypted and versioned; never run two
daemons against one state directory.
Value objects
| Class | Notes |
|---|---|
Msat |
Millisatoshi as a bcmath numeric-string. fromString / fromInt / fromSat / fromBtc / zero; add, sub (throws rather than going negative), mulBps, comparisons. toSat() throws on a sub-satoshi remainder. |
LnInvoice |
paymentHash (lowercase hex), bolt11, state, amountMsat, amountPaidMsat, expiresAt(), isExpiredAt(). LnInvoice::absent() for "no such invoice". |
LnPayment |
state, feeMsat (actual routing fee paid), failureReason, preimage. isInFlight() is true for anything not terminal, including unknown. |
LnChannel |
capacityMsat, localBalanceMsat, remoteBalanceMsat, active, pendingHtlcs, plus localShareBps() / inboundShareBps() for threshold alerts. |
LnChannelBalance, LnWalletBalance |
Off-chain and on-chain balances. |
LnNodeInfo |
alias, blockHeight, syncedToChain, isHealthy(). |
LnChannelPoint |
Funding txid + output index; renders as txid:index. |
LnChannelBackup |
The SCB blob and how many channels it covers. |
Exceptions
LndUnavailableException extends LndException, so one catch (LndException) covers both — but
the distinction matters: LndUnavailableException means transient, retry (transport failure or
HTTP 5xx), while a plain LndException carries an error the node itself reported, with
getLndCode().
Testing
composer test
composer stan
composer cs
Contract tests read recorded LND responses from tests/fixtures/*.json and assert the value
objects decode them exactly, so a change in the node's wire format fails a test rather than
surfacing as a wrong balance in production.