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.

Maintainers

Package info

github.com/AndreyMashukov/lnd-client-php

pkg:composer/amashukov/lnd-client-php

Transparency log

Statistics

Installs: 71

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.4 2026-07-20 13:15 UTC

This package is auto-updated.

Last update: 2026-07-20 13:15:29 UTC


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.

CI PHPStan L8 Latest Version PHP License

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 moneyMsat 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.
  • A timeout is not a failuresendPayment() 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.
  • 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 objectsLnInvoice, 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.
  • 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/router streaming endpoints wrap their payload in a result envelope, which is unwrapped transparently.
  • Zero framework coupling — no Symfony, no Laravel, no App\ namespace. PHPStan level 8 across src and tests.

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.