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.
Msat is a bcmath numeric-string value object. No floats anywhere; 2100000000000000000 msat survives a round trip that IEEE-754 would mangle. Sub-satoshi remainders are rejected loudly instead of being rounded away.sendPayment() raises LndUnavailableException when the node is unreachable or the call times out, and LnPaymentState treats every unrecognised or unknown state as unresolved. Only SUCCEEDED and FAILED are terminal, so a caller can never mistake "I don't know yet" for "it failed" and pay twice.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 never null.v2/router streaming endpoints wrap their payload in a result envelope, which is unwrapped transparently.App\ namespace. PHPStan level 8 across src and tests.composer require amashukov/lnd-client-php
Requires PHP >= 8.3 and ext-bcmath.
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
}
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.
$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);
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.
| 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. |
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().
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.
How can I help you explore Laravel packages today?