tightenco/solana-php-sdk
A PHP SDK for building on Solana. Create and manage keypairs, build, sign, and send transactions, query accounts and program data, and interact with on-chain programs through a clean, framework-friendly API that fits naturally into Laravel and plain PHP projects.
Composer install (pinned). This is a plain Composer library — not a Laravel package — so there is no service provider to discover. Pin the exact version; pre-1.0 caret ranges (^0.3) will silently upgrade you across breaking changes.
composer require tightenco/solana-php-sdk:0.3.2
Check the environment before writing code. Two prerequisites fail late and loudly only at runtime:
ext-sodium — required for ed25519 keypair generation and loading. It fails at keypair construction, not at composer install. Verify it in local, CI, queue workers, and deploy targets.php -m | grep sodium # must print "sodium"
Where to look first. In order:
SolanaRpcClient — the JSON-RPC wrapper. Its call() method is the escape hatch for any RPC method the SDK doesn't wrap explicitly, and (as of v0.3.2) it accepts custom headers.Keypair / public-key utilities — loading a signer from a secret, deriving an address.First use case: devnet, read-only. Start with getBalance or getAccountInfo against devnet. Reads are reversible, require no signing, no custody story, and prove the wiring (endpoint, HTTP client, sodium) end to end.
use Tightenco\SolanaPhpSdk\Connection; // confirm exact class in vendor/ after install
$connection = new Connection('https://api.devnet.solana.com');
$balance = $connection->call('getBalance', [
'9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
]);
Bind that client as a singleton in a service provider and pull the endpoint from config/services.php / .env. Do not instantiate it per request, and do not hardcode URLs or API keys at call sites.
Only after a read round-trip works should you attempt a devnet SOL transfer with a throwaway keypair.
Wrap the SDK behind an internal interface. Roughly a day of work; it converts an upstream bump from an incident into a one-file edit.
interface SolanaGateway
{
public function balance(string $address): int;
public function ownsToken(string $wallet, string $mint, int $min = 1): bool;
public function transferSol(string $from, string $to, int $lamports): string; // signature
}
Everything else in the app — controllers, jobs, Livewire components — talks to SolanaGateway, never to the SDK classes.
Configuration shape. Externalize endpoint, commitment level, and provider auth headers:
// config/services.php
'solana' => [
'endpoint' => env('SOLANA_ENDPOINT'), // e.g. Helius/QuickNode URL
'commitment' => env('SOLANA_COMMITMENT', 'confirmed'),
'headers' => [
'Authorization' => 'Bearer ' . env('SOLANA_RPC_TOKEN'),
],
],
The headers array is the direct beneficiary of v0.3.2's call() header support — it makes authenticated commercial RPC providers (Helius, QuickNode, Triton) and internal egress proxies a config change rather than a fork. Rotate tokens here; never at call sites.
Read pattern — token/NFT ownership gate. This is the highest-value day-one workflow: "prove you hold X."
public function ownsToken(string $wallet, string $mint, int $min = 1): bool
{
$res = $this->rpc->call('getTokenAccountsByOwner', [
$wallet,
['mint' => $mint],
['encoding' => 'jsonParsed', 'commitment' => config('services.solana.commitment')],
]);
$held = collect($res['result']['value'] ?? [])
->sum(fn ($acct) => (int) ($acct['account']['data']['parsed']['info']['tokenAmount']['amount'] ?? 0));
return $held >= $min;
}
Return a boolean to the domain layer and cache it briefly — these are hot on page loads.
Write pattern — build, sign with ALL signers at once, submit, then poll. v0.3.2 corrected the signing path so every signer must be supplied in a single call. Build your transfer, collect signers into one array, sign once.
$tx = /* build transfer instruction(s) via SDK helpers */;
$tx->sign($payerKeypair, ...$additionalSigners); // all at once, every time
$signature = $connection->sendTransaction($tx, ['encoding' => 'base64']);
Treat confirmation as your job. The SDK submits; it does not reliably wait for finality. Poll getSignatureStatuses with a bounded retry/backoff and handle the three failure modes explicitly: expired blockhash, dropped transaction, insufficient fee.
Route every write through a queued job. Controllers should enqueue and return a pending state; the job performs build→sign→send→confirm and records the outcome. This gives you Laravel's retry/backoff, keeps long RPC round-trips off the request, and centralizes signing in one spot. It is also the seam where a future KMS/HSM signer drops in.
Load keypairs from a single service. One KeypairProvider that reads from .env (devnet) or a secret store, returns a Keypair, and is the only code that touches raw secret bytes. Every signing path resolves its signer there. This is not custody — it's the boundary you'll replace when custody becomes real.
Testing. Stand up solana-test-validator in CI and point the gateway at http://localhost:8899. Assert on-chain outcomes, not just "no exception thrown" — a transaction can fail and still return cleanly from sendTransaction.
ext-sodium failures appear at runtime, not install. A green composer install says nothing. Add a startup assertion or a build-time check in every image including queue workers.sendTransaction in try/catch.processed is fast and unreliable; confirmed is the sane default; finalized is slow. Set it once in config and pass it on every call that accepts it..env are fine for devnet and unacceptable for a production treasury. Do not pitch this to stakeholders as "included."debug level behind a flag. When a call misbehaves, the difference between an SDK bug and a bad parameter is usually visible in the raw body, not the exception.How can I help you explore Laravel packages today?