nbobtc/bitcoind-php
A PHP client for the Bitcoin Core daemon’s JSON-RPC API, offering a clean object-oriented wrapper around bitcoind commands so applications can query blockchain data, manage wallets, and broadcast transactions with minimal setup.
1. Verify it even installs on your stack. The last tagged release is from May 2015 (PHP 5 / Guzzle 4–5 era). On Laravel 8+ you'll likely hit a Guzzle conflict (guzzlehttp/guzzle ^7 is required by the framework). Options, in order of preference:
"repositories": [{"type": "vcs", "url": "..."}] pointing at a Guzzle 7-compatible fork.ClientInterface on top of Laravel's Http facade.denpa/php-bitcoind or a hand-rolled JSON-RPC client — the RPC surface is small and stable.2. Point bitcoind at your app. In bitcoin.conf:
server=1
rpcuser=... # or rpcauth=
rpcpassword=...
rpcbind=127.0.0.1
rpcallowip=127.0.0.1
# testnet: rpcport=18332, regtest: rpcport=18443
3. Configure and bind it in Laravel.
// config/bitcoind.php
return [
'user' => env('BITCOIND_USER'),
'password' => env('BITCOIND_PASSWORD'),
'host' => env('BITCOIND_HOST', '127.0.0.1'),
'port' => env('BITCOIND_PORT', 8332),
'protocol' => env('BITCOIND_PROTOCOL', 'http'),
];
// app/Providers/BitcoinServiceProvider.php
public function register(): void
{
$this->app->singleton(Bitcoind::class, function ($app) {
$c = $app['config']->get('bitcoind');
$config = new Config($c['user'], $c['password'], $c['host'], $c['port']);
// $config->setProtocol($c['protocol']); // check the vendored Config for exact setters
return new Bitcoind(new Client($config));
});
}
4. First call — smoke test from Tinker.
$bitcoind = app(Nbobtc\Bitcoind\Bitcoind::class);
$bitcoind->getblockcount(); // int
$bitcoind->getblockhash(800000); // string
$bitcoind->getnewaddress('deposit'); // string
Every public method is a magic __call that resolves an RPC command, so method names match bitcoind's RPC names exactly, all lowercase, and arguments are passed positionally as JSON-RPC params. If getblockcount() returns, credentials, ports, and transport are correct.
Wrap it in a domain service. Don't let Bitcoind leak into controllers or jobs — the magic __call gives you zero type safety and no IDE help.
final class Bitcoin
{
public function __construct(private Bitcoind $bitcoind) {}
public function height(): int
{
return (int) $this->bitcoind->getblockcount();
}
public function newDepositAddress(string $label): string
{
return $this->bitcoind->getnewaddress($label);
}
/** @return string txid */
public function send(string $address, string $amountBtc): string
{
// Pass the amount as a string to avoid float drift before it hits JSON.
return $this->bitcoind->sendtoaddress($address, $amountBtc);
}
public function confirmations(string $txid): int
{
$tx = $this->bitcoind->gettransaction($txid); // wallet tx
return (int) ($tx['confirmations'] ?? 0);
}
}
Bind both: $this->app->singleton(Bitcoin::class, fn ($app) => new Bitcoin($app->make(Bitcoind::class)));
Reads inline, writes on the queue. getblockcount, getnewaddress, getbalance, listtransactions are cheap and fine in a request or a Blade render. sendtoaddress should always be a queued job so a web timeout doesn't leave you unsure whether the send happened.
final class SendPayout implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public string $address,
public string $amount,
public int $payoutId,
) {}
public function handle(Bitcoin $bitcoin): void
{
// Guard against retrying an ambiguous prior attempt.
$payout = Payout::findOrFail($this->payoutId);
if ($payout->txid) {
return;
}
$txid = $bitcoin->send($this->address, $this->amount);
$payout->update(['txid' => $txid, 'sent_at' => now()]);
}
}
Set public bool $failOnTimeout = true and a short $timeout; a hung RPC ties up the worker.
Poll for deposits on a schedule, with a cursor. listtransactions takes (account, count, skip); persist skip so you don't rescan history every minute.
// app/Console/Commands/SyncWallet.php
$skip = Cache::get('btc.skip', 0);
$txs = $this->bitcoin->bitcoind->listtransactions('*', 100, $skip);
foreach ($txs as $tx) {
if (($tx['category'] ?? null) === 'receive' && ($tx['confirmations'] ?? 0) >= 3) {
Deposit::updateOrCreate(['txid' => $tx['txid']], ['amount' => $tx['amount'], 'address' => $tx['address']]);
}
}
Cache::put('btc.skip', $skip + count($txs), now()->addDay());
Register it in Kernel::schedule() with ->everyMinute()->withoutOverlapping().
Use a decorator around the transport for logging. Wrap the Client/ClientInterface you register in the container and log the command name, params, duration, and outcome (never the credentials). This is what you'll use to debug the "why is production slow" question.
Test with a fake client, not a node. Bitcoind only depends on ClientInterface, so bind a fake in TestCase that returns canned arrays. That's far faster than regtest and keeps CI node-free. Keep one regtest-only integration test for the send → confirm path; on modern Core use generatetoaddress 1 <addr> (the old generate RPC is gone).
Multi-wallet / multi-node. Don't overload the singleton. Build a small factory keyed by node name that constructs Config + Client + Bitcoind per endpoint; this is also the cleanest way to run testnet and regtest side by side.
ClientInterface yourself with Http::withBasicAuth(...)->post($url, $payload) and keep Bitcoind + the command classes. That preserves the API while eliminating the dead dependency. Alternatively fork and bump the Guzzle constraint.getblockcount, getblock, getrawtransaction, getnewaddress) before assuming it works, and prefer require-pinning if you vendor a fork.getinfo, getaccount*, listaccounts, sendfrom, move, getaddressesbyaccount, getreceivedbyaccount, setaccount. Expect Method not found on those. Replace with getblockchaininfo, getwalletinfo, getbalances, and label-based getnewaddress. If the package lacks a command class for a new RPC, write one extending the package's base Command (it's a short class exposing the RPC method name and params) — that's the intended extension point.getblock's verbosity argument is now an int/bool with different semantics, and getrawtransaction needs txindex=1 (or a block hash) to return arbitrary txids. If you get null or a parse error, log the exact params before blaming the wrapper.sendtoaddress takes a BTC amount; do all arithmetic in satoshis with bcmath/integers and convert to a fixed-8-decimal string at the last moment. Never compare $tx['amount'] === 0.01.gettransaction($txid) / listtransactions rather than blindly retrying. failOnTimeout plus an idempotency check is the minimum bar.-28 while warming up, -5 for invalid addresses, -6 for insufficient funds, -8 for bad params. Catch the package exception, inspect its code, retry only -28 and connection refused; treat the rest as permanent failures. Add a circuit breaker so a down node doesn't burn every queue worker.127.0.0.1 or a private network only. Use long random credentials or rpcauth. Never log the RPC URL with embedded creds, and never expose the port. For testnet/regtest this matters less, but keep the same habit.verify option pointed at the CA bundle (Config has a CA setter — check the vendored source). Don't disable verification.config/bitcoind.php and .env, remember php artisan config:cache in deploy; a stale cached config pointing at mainnet instead of testnet is an expensive mistake.Http instead of looping the wrapper.confirmations on every deposit row and re-check against a configurable threshold, plus a reorg-window (a tx can go from 3 confs back to 0).How can I help you explore Laravel packages today?