Skip to content

SolDapper/solana-php

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Solana PHP

Solana PHP

A framework-agnostic PHP library for building Solana transactions, instructions, and integrating Solana payments into PHP applications.

Status: All planned features are implemented, every wire format is byte-for-byte validated against the canonical JavaScript and Rust reference implementations (@solana/web3.js, @solana/spl-token, @solana/pay, borsh-rs), and the full transaction pipeline is end-to-end validated against Solana devnet: transactions built by this library have been signed, submitted, and confirmed on chain at both confirmed and finalized commitment levels, including USDC transferChecked with on-the-fly associated-token-account creation, priority-fee estimation against a live validator, compute-unit simulation, and Solana Pay URL generation. 348 unit tests, 1121 assertions.

Caveats:

  • The Solana Pay URLs emitted by this library have not yet been tested against a real mobile wallet app (Phantom, Solflare, Backpack). The URL format conforms byte-for-byte to @solana/pay, so wallet compatibility is expected, but not field-proven here.
  • Mainnet has only been exercised via the RPC read paths; all write-path validation has been on devnet. Mainnet writes are expected to work identically since the wire format is network-agnostic.
  • Bug reports from real-world integration are very welcome.

Requirements

  • PHP 8.0 or higher
  • ext-sodium (included in PHP by default; provides Ed25519 signing)
  • ext-mbstring
  • ext-gmp strongly recommended (required for PDA derivation, preferred for Base58)
  • ext-bcmath as an optional Base58 fallback when GMP is unavailable

Installation

With Composer (recommended)

composer require solana-php/solana-sdk

Without Composer

Solana PHP uses PSR-4 autoloading but doesn't actually require Composer at runtime - you can drop the src/ directory into your project and wire up a minimal autoloader yourself. This is useful for shared hosting without shell access, bundled CMS extensions that ship self-contained, or locked-down enterprise environments.

  1. Download the source (git clone, tarball from releases, or copy the src/ directory out of an existing install).

  2. Register an autoloader that maps the SolanaPhpSdk\ namespace to wherever you placed src/:

    spl_autoload_register(function ($class) {
        $prefix = 'SolanaPhpSdk\\';
        $baseDir = __DIR__ . '/path/to/solana-php/src/';
        if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
            return;
        }
        $relative = substr($class, strlen($prefix));
        $file = $baseDir . str_replace('\\', '/', $relative) . '.php';
        if (is_file($file)) {
            require $file;
        }
    });
  3. Use the library normally.

Caveat: the Psr18HttpClient adapter requires the psr/http-client and psr/http-factory packages. Without them, that specific class can't be instantiated (you'll get a TypeError on its constructor). Stick to the built-in CurlHttpClient (which is the default for RpcClient) and you don't need PSR packages at all.

Versioning

The current version is 0.1.1. The authoritative version string lives in SolanaPhpSdk\SolanaPhpSdk::VERSION and is sent in the User-Agent header on every RPC request, so it shows up in your provider's logs as solana-php/solana-sdk 0.1.1.

This library follows semantic versioning with one nuance: while on 0.x, minor version bumps (0.1 to 0.2) may include breaking changes, and patch bumps (0.1.0 to 0.1.1) are strictly non-breaking fixes. This is the standard 0.x convention and it gives the library room to refine its API based on feedback from early integrations without forcing a premature 2.0.

Once the library stabilizes after real-world use (probably driven by the OpenCart, Magento, and WooCommerce integrations landing), a 1.0.0 tag commits to full semver: major bumps for breaking changes, minor for features, patch for fixes.

When pinning the library in your composer.json, we recommend:

"require": {
    "solana-php/solana-sdk": "^0.1"
}

The ^0.1 constraint means "0.1.x but not 0.2.x" - this protects you from breaking changes within 0.x while still picking up non-breaking fixes. Review the changelog before upgrading across a minor boundary.

You can check which version is running at any time:

echo SolanaPhpSdk\SolanaPhpSdk::VERSION;
// "0.1.1"

What's Implemented

Utilities (SolanaPhpSdk\Util)

  • Base58: Bitcoin-alphabet Base58 with auto-selected GMP or BCMath backend.
  • ByteBuffer: Sequential read/write buffer for binary serialization with full u64 range support (values beyond PHP_INT_MAX are handled as numeric strings).
  • Ed25519: Native Ed25519 curve point validation. PHP's libsodium bindings do not expose sodium_crypto_core_ed25519_is_valid_point, so we implement RFC 8032 point decompression directly via GMP.
  • CompactU16: Solana's 1-3 byte variable-length integer encoding (distinct from Borsh's fixed u32 prefix). Used in transaction wire format.

Keys (SolanaPhpSdk\Keypair)

  • PublicKey: Immutable 32-byte public key value object. Construct from Base58, raw bytes, integer arrays, or other PublicKey instances. Includes PDA derivation (createProgramAddress, findProgramAddress).
  • Keypair: Ed25519 keypair via libsodium. Supports generation, seed-based derivation, and import from the Solana CLI JSON array format ([174, 47, ...]).

Borsh (SolanaPhpSdk\Borsh)

  • Static facade Borsh with fluent type constructors:
    $schema = Borsh::struct([
        'instruction' => Borsh::u8(),
        'amount'      => Borsh::u64(),
        'memo'        => Borsh::option(Borsh::string()),
        'recipient'   => Borsh::publicKey(),
    ]);
    $bytes = Borsh::encode($schema, $value);
    $back  = Borsh::decode($schema, $bytes);
  • Primitive types: u8, u16, u32, u64, u128, u256, i8, i16, i32, i64, f32, f64, bool, string, unit.
  • Composite types: Option, Vec, fixed-size array, struct, enum (with unit and struct variants), HashMap.
  • Solana-specific: Borsh::publicKey(): decodes directly to a PublicKey instance.
  • HashMap sorting: matches the canonical borsh-rs behavior (sort by logical key value), NOT borsh-js (which doesn't sort). This is what on-chain Solana programs expect.

Transactions (SolanaPhpSdk\Transaction)

  • AccountMeta: (pubkey, isSigner, isWritable) with factory shortcuts: signerWritable(), signerReadonly(), writable(), readonly().
  • TransactionInstruction: (programId, accounts, data).
  • Message: legacy-format message with the account dedup-and-order compilation algorithm. Handles the four-category ordering (writable signers, readonly signers, writable non-signers, readonly non-signers) and fee-payer-first invariant.
  • Transaction: legacy signed transaction. Signing (full and partial), serialization, deserialization, and signature verification. Supports multi-sig flows.
  • MessageV0: versioned (v0) message with full Address Lookup Table support. The compile algorithm matches @solana/web3.js byte-for-byte, including the four-category static-key ordering, the writable-then-readonly ALT drain, and combined-list account indexing for instructions.
  • VersionedTransaction: v0 signed transaction envelope with sign(), partialSign(), verifySignatures(), serialize(), deserialize(), and a peekVersion() classifier for routing incoming wire bytes to the right class.
  • AddressLookupTableAccount: value object for a (key, addresses) pair. Use with MessageV0::compile() to produce compact transactions.
  • SignedTransaction: shared interface so RpcClient::sendTransaction() and simulateTransaction() accept either legacy or v0 transactions transparently.

RPC client (SolanaPhpSdk\Rpc)

  • RpcClient: speaks standard Solana JSON-RPC 2.0. Covers the core payment-workflow methods: getBalance, getAccountInfo, getMinimumBalanceForRentExemption, getLatestBlockhash, getBlockHeight, sendTransaction, sendRawTransaction, simulateTransaction, getSignatureStatuses, getRecentPrioritizationFees, plus a generic call() escape hatch for any other JSON-RPC method.
  • Commitment: constants for PROCESSED / CONFIRMED / FINALIZED.
  • HTTP transport is pluggable via the HttpClient interface:
    • CurlHttpClient: zero-dependency default using PHP's cURL extension.
    • Psr18HttpClient: adapter for any PSR-18 client (Guzzle, Symfony HttpClient, etc.).
    • Users can implement HttpClient themselves for mocks, custom auth, proxies, etc.

Priority fee estimation (SolanaPhpSdk\Rpc\Fee)

Different RPC providers expose fee-market data in incompatible ways. Solana PHP abstracts this behind a single FeeEstimator interface with provider-specific implementations. Application code targets FeeEstimator and never branches on provider.

  • PriorityLevel: five provider-agnostic buckets: MIN / LOW / MEDIUM / HIGH / VERY_HIGH.
  • FeeEstimate: value object with all five bucket values (in micro-lamports per compute unit) plus the source estimator name.
  • StandardFeeEstimator: works with any provider. Uses getRecentPrioritizationFees and computes percentiles client-side. Supports a floor value and customizable percentile-to-bucket mapping.
  • HeliusFeeEstimator: uses Helius's native getPriorityFeeEstimate method. One RPC call returns all five buckets.
  • TritonFeeEstimator: uses Triton One's percentile-extended getRecentPrioritizationFees. One call per bucket (5 total) but uses server-side percentile computation across the full slot window.

Compute unit estimation (SolanaPhpSdk\Rpc\ComputeUnitEstimator)

The priority fee a transaction pays is compute_unit_price * compute_unit_limit. A naive default like 200,000 CU works for most payment transactions but has two real problems: it's too low for heavier flows (DEX swaps, NFT mints) and causes on-chain failures, while being ~400x too high for simple transfers, leaving real money on the table when the chain is congested.

ComputeUnitEstimator uses simulateTransaction to measure actual CU consumption, then applies a safety multiplier so slot-to-slot variation does not cause on-chain failures. Matches the canonical web3.js recipe (simulate with replaceRecentBlockhash: true, sigVerify: false, read unitsConsumed, multiply by ~1.1).

  • ComputeUnitEstimator: estimateLegacy() and estimateV0() methods that take instructions + fee payer + blockhash and return a ComputeUnitEstimate. Works with any transaction built via primitives.
  • ComputeUnitEstimate: value object with unitsConsumed, recommendedLimit, multiplier, simulationLogs, simulationSucceeded, and raw simulation error. Lets you inspect what happened during simulation, not just the number.
  • High-level shortcut: PaymentBuilder::withSimulatedComputeUnitLimit($multiplier, $floor) wraps the primitive for the common payment flow. One fluent method call, one RPC request, no guesswork.

Transaction confirmation (SolanaPhpSdk\Rpc\TransactionConfirmer)

Submitting a transaction is the easy part. The hard part is knowing it actually landed: validators sometimes silently drop transactions under load, blockhashes expire after 150 slots (~60 seconds), and Solana's optimistic-confirmation model means there are three different "confirmed" states with different latency / safety tradeoffs.

TransactionConfirmer polls getSignatureStatuses with exponential-ish backoff until the transaction reaches the requested commitment level, with three production-grade additions: optional blockhash-expiry detection (abort early when the chain passes lastValidBlockHeight rather than waiting for the timeout), optional automatic rebroadcast (re-submit the same wire bytes every few seconds during the wait, the canonical fix for silent validator drops), and batched multi-signature waits (one getSignatureStatuses call per round regardless of how many signatures are pending).

  • Commitment levels: processed (~400ms, may be skipped), confirmed (~1-2s on mainnet, supermajority voted), finalized (~13s, mathematically irreversible).
  • ConfirmationOptions: factories ::confirmed() (default for ecom), ::finalized() (high-value), ::processed() (UI hints only). Fluent withRebroadcast($wireBytes, $every) and withBlockhashExpiry($lastValidBlockHeight) for opting in.
  • ConfirmationResult: terminal outcome (OUTCOME_CONFIRMED, OUTCOME_FINALIZED, OUTCOME_FAILED, OUTCOME_EXPIRED, OUTCOME_TIMEOUT) plus diagnostic fields (slot, on-chain error, poll count, elapsed seconds, rebroadcast count). isSuccess() covers the common branch.
  • High-level shortcut: PaymentBuilder::buildSignAndSubmit($options?, ...$cosigners) builds, signs, submits, and waits in one call. Auto-layers rebroadcast and blockhash-expiry tracking onto user-supplied options.

The right default for ecommerce is confirmed. Mainnet rollback of a confirmed transaction has not been observed since the network stabilized in 2021, so the 12-second wait until finalized is generally not worth the UX cost. For high-value transfers or audit logs, the recommended pattern is two-stage: declare the order paid at confirmed for fast checkout, then asynchronously verify finalized in a background job for the audit record. See the Confirming transactions example below.

Program instructions (SolanaPhpSdk\Programs)

Ready-to-use instruction builders for the Solana native and SPL programs that matter for payment flows. Every builder returns a TransactionInstruction ready to drop into Transaction::new([...]).

  • ComputeBudgetProgram: setComputeUnitLimit, setComputeUnitPrice, requestHeapFrame, setLoadedAccountsDataSizeLimit. Critical for landing transactions under network contention.
  • SystemProgram: transfer (SOL), createAccount, assign, allocate.
  • TokenProgram: transfer and transferChecked for SPL tokens (USDC, USDT, PYUSD, etc.). Token-2022 supported via program ID override parameter.
  • AssociatedTokenProgram: findAssociatedTokenAddress (pure off-chain PDA derivation), create, and createIdempotent (preferred for payment flows).
  • MemoProgram: attach UTF-8 memos to transactions. Supports the V2 program with optional signer verification; standard for order-ID correlation in ecommerce flows.
  • PaymentBuilder: high-level helper that bundles the compute-budget setup, ATA derivation, optional createIdempotent, transferChecked, Solana Pay references, memo attachment, blockhash fetch, and fee estimation into a fluent builder. Use this for the common ecommerce case; drop down to the primitives when you need byte-level control. See the example below.

Solana Pay (SolanaPhpSdk\SolanaPay)

Construct and parse Solana Pay URLs - the standard URL format wallets use to compose payments from a QR code or deep link.

  • TransferRequest: typed representation of a solana:<recipient>?amount=...&spl-token=...&reference=...&label=...&message=...&memo=... URL. All validation is at construction time.
  • TransferRequestBuilder: fluent builder for clean call sites in merchant code.
  • TransactionRequest: typed representation of a solana:<httpsLink> URL pointing to a merchant endpoint that returns a pre-built transaction.
  • Url: static encodeTransfer(), encodeTransaction(), and parse() with auto-detection between the two URL shapes.
  • PaymentFinder: find an on-chain transaction by its reference account (the merchant's order-correlation mechanism) via getSignaturesForAddress. Returns the signature and transaction payload for verification.

Exceptions (SolanaPhpSdk\Exception)

  • SolanaException: Root exception class.
  • InvalidArgumentException: Input validation failures.
  • BorshException: Borsh wire-format errors.
  • RpcException: RPC / HTTP failures with optional HTTP status, JSON-RPC error code, and error data.
  • SolanaPayException: Solana Pay URL / spec validation failures.

Validation Against Reference Implementations

  • Ed25519 curve check / PDA derivation: 50 golden curve-membership vectors and 3 full ATA derivations verified against @solana/web3.js.
  • Borsh encoding: 23 golden wire-format vectors verified against borsh-js.
  • Borsh HashMap sorting: verified against borsh-rs 1.5 (canonical Rust implementation, matches on-chain behavior).
  • Transaction wire format: 3 golden transaction vectors verified against @solana/web3.js: byte-for-byte identical message, signature, and transaction bytes.
  • VersionedTransaction (v0) wire format: 3 golden v0 vectors verified against @solana/web3.js covering no-ALT baseline, ALT with two readonly lookups, and ALT with mixed writable+readonly lookups. The compile algorithm (key collection, drain-into-ALT, four-category static ordering, combined-list instruction indexing) matches the reference byte-for-byte.
  • Program instructions: 15+ golden instruction-data vectors verified against @solana/web3.js and @solana/spl-token covering ComputeBudget, System, Token, and Associated Token Account encodings.
  • Solana Pay URLs: 10 golden URL vectors verified against @solana/pay covering amount formatting, special-character encoding (including UTF-8 labels), multi-reference ordering, and both conditional encodings of transaction requests.
  • RPC client: tests use an in-memory MockHttpClient to exercise every method's request shape, response parsing, and error handling without a network.
  • End-to-end: a full realistic payment transaction (ComputeBudget + createIdempotent + transferChecked + Memo) compiles, signs, serializes, round-trips, and verifies. A separate test demonstrates v0+ALT producing ~70% smaller transactions than the equivalent legacy form when touching 20 readonly accounts.

Choosing an RPC provider and fee estimator

Solana PHP works with any Solana RPC provider - you only need to point RpcClient at a URL. Priority-fee estimation is a separate concern: different providers expose different fee-estimation APIs, so you pick an estimator class that matches your provider. These two choices are independent.

Pointing at a provider

Pass the RPC endpoint URL to RpcClient:

use SolanaPhpSdk\Rpc\RpcClient;

// Public mainnet (rate-limited, fine for development)
$rpc = new RpcClient('https://api.mainnet-beta.solana.com');

// Helius
$rpc = new RpcClient('https://mainnet.helius-rpc.com/?api-key=YOUR_KEY');

// Triton One
$rpc = new RpcClient('https://YOUR_NAMESPACE.rpcpool.com/YOUR_KEY');

// QuickNode, Alchemy, Ankr, Chainstack, etc. - all use their own URL format
$rpc = new RpcClient('https://your-endpoint.example.com/KEY');

// Local validator
$rpc = new RpcClient('http://127.0.0.1:8899');

Every provider implements the standard Solana JSON-RPC spec, so RpcClient just works everywhere. The URL is the only difference.

If your provider authenticates via a header rather than a query-string key, pass a pre-configured CurlHttpClient:

use SolanaPhpSdk\Rpc\Http\CurlHttpClient;

$http = new CurlHttpClient(
    timeoutSeconds: 30,
    defaultHeaders: ['Authorization' => 'Bearer YOUR_TOKEN']
);
$rpc = new RpcClient('https://provider.example.com/rpc', $http);

Picking a fee estimator

The SDK doesn't auto-detect which estimation method your provider supports - that would require a probe RPC call with uncertain failure modes. You pick explicitly:

use SolanaPhpSdk\Rpc\Fee\{StandardFeeEstimator, HeliusFeeEstimator, TritonFeeEstimator};

// Works with ANY provider. Uses the standard getRecentPrioritizationFees
// method and computes percentiles client-side. Portable, slightly less accurate.
$fees = new StandardFeeEstimator($rpc);

// Helius's native getPriorityFeeEstimate method. One RPC call returns all five
// buckets with Helius's server-side estimator. Only works against Helius.
$fees = new HeliusFeeEstimator($rpc);

// Triton One's percentile-extended getRecentPrioritizationFees. Five RPC calls
// (one per bucket), server-side percentile math across the full slot window.
// Only works against Triton.
$fees = new TritonFeeEstimator($rpc);

All three implement the same FeeEstimator interface, so downstream code doesn't care which one it has:

use SolanaPhpSdk\Rpc\Fee\PriorityLevel;

$microLamportsPerCU = $fees->estimateLevel($writableAccounts, PriorityLevel::MEDIUM);

Quick guidance

Use case Provider Estimator
Prototyping / development Public api.mainnet-beta.solana.com StandardFeeEstimator
Production ecom, modest volume Helius free/starter tier HeliusFeeEstimator
High-volume or latency-sensitive Triton dedicated RPC TritonFeeEstimator
Using QuickNode, Alchemy, Ankr, etc. Any provider URL StandardFeeEstimator

The estimator isn't locked in at compile time - in a merchant-facing extension you typically read the provider choice from config and wire up the right estimator at boot:

$rpc = new RpcClient($config['rpc_url']);

$fees = match ($config['fee_provider']) {
    'helius'   => new HeliusFeeEstimator($rpc),
    'triton'   => new TritonFeeEstimator($rpc),
    default    => new StandardFeeEstimator($rpc),
};

Adding support for a new provider-native estimator (QuickNode, Alchemy, Ankr) later is a ~100-line file implementing FeeEstimator plus one more match arm - no changes elsewhere.

Example: building a USDC payment

The high-level path - use PaymentBuilder for the common case:

use SolanaPhpSdk\Keypair\Keypair;
use SolanaPhpSdk\Keypair\PublicKey;
use SolanaPhpSdk\Programs\PaymentBuilder;
use SolanaPhpSdk\Rpc\Fee\HeliusFeeEstimator;
use SolanaPhpSdk\Rpc\Fee\PriorityLevel;
use SolanaPhpSdk\Rpc\RpcClient;

$rpc = new RpcClient('https://mainnet.helius-rpc.com/?api-key=...');
$fees = new HeliusFeeEstimator($rpc);

$customer = Keypair::fromSecretKey(...);
$merchant = new PublicKey('MERCHANT_WALLET...');
$usdc = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');

$tx = PaymentBuilder::splToken($rpc, $usdc, 6)
    ->from($customer)
    ->to($merchant)
    ->amount(10_000_000)                          // 10 USDC (6 decimals)
    ->ensureRecipientAta()                        // auto-create merchant ATA if missing
    ->withFeeEstimate($fees, PriorityLevel::MEDIUM)
    ->withSimulatedComputeUnitLimit(1.1)          // measure actual CU usage + 10% headroom
    ->memo('order_ref:OC-2025-00042')
    ->withFreshBlockhash()
    ->buildAndSign();

$signature = $rpc->sendTransaction($tx);

The low-level path - assemble the same transaction from primitives for full control:

use SolanaPhpSdk\Programs\AssociatedTokenProgram;
use SolanaPhpSdk\Programs\ComputeBudgetProgram;
use SolanaPhpSdk\Programs\MemoProgram;
use SolanaPhpSdk\Programs\TokenProgram;
use SolanaPhpSdk\Rpc\ComputeUnitEstimator;
use SolanaPhpSdk\Transaction\Transaction;

[$customerAta, ] = AssociatedTokenProgram::findAssociatedTokenAddress($customer->getPublicKey(), $usdc);
[$merchantAta, ] = AssociatedTokenProgram::findAssociatedTokenAddress($merchant, $usdc);
$price = $fees->estimateLevel([$customerAta, $merchantAta], PriorityLevel::MEDIUM);
$blockhash = $rpc->getLatestBlockhash()['blockhash'];

// Measure actual CU usage before building the final transaction.
$businessIxs = [
    AssociatedTokenProgram::createIdempotent($customer->getPublicKey(), $merchantAta, $merchant, $usdc),
    TokenProgram::transferChecked($customerAta, $usdc, $merchantAta, $customer->getPublicKey(), 10_000_000, 6),
    MemoProgram::create('order_ref:OC-2025-00042'),
];
$estimator = new ComputeUnitEstimator($rpc);
$estimate = $estimator->estimateLegacy($businessIxs, $customer->getPublicKey(), $blockhash, 1.1);

$tx = Transaction::new(
    [
        ComputeBudgetProgram::setComputeUnitLimit($estimate->recommendedLimit),
        ComputeBudgetProgram::setComputeUnitPrice($price),
        ...$businessIxs,
    ],
    $customer->getPublicKey(),
    $blockhash
);
$tx->sign($customer);
$signature = $rpc->sendTransaction($tx);

Example: Solana Pay checkout

use SolanaPhpSdk\Keypair\Keypair;
use SolanaPhpSdk\Keypair\PublicKey;
use SolanaPhpSdk\Rpc\RpcClient;
use SolanaPhpSdk\SolanaPay\PaymentFinder;
use SolanaPhpSdk\SolanaPay\TransferRequest;
use SolanaPhpSdk\SolanaPay\Url;

// 1. Merchant creates a unique reference per order (no private key needed).
$orderReference = Keypair::generate()->getPublicKey();

// 2. Build the payment URL - render this as a QR code.
$request = TransferRequest::builder(new PublicKey('MERCHANT_WALLET...'))
    ->amount('29.99')
    ->splToken(new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')) // USDC
    ->addReference($orderReference)
    ->label('Acme Store')
    ->message('Thanks for your order!')
    ->memo('order:OC-2025-00042')
    ->build();
$url = Url::encodeTransfer($request);   // solana:MERCHANT_WALLET...?amount=29.99&...

// 3. After the customer pays, verify with the reference pubkey server-side.
$rpc = new RpcClient('https://api.mainnet-beta.solana.com');
$finder = new PaymentFinder($rpc);
$signature = $finder->findByReference($orderReference);
if ($signature !== null) {
    // Transaction found - fetch it and validate amount/recipient match.
    $tx = $finder->getTransaction($signature);
    // Release the order...
}

Confirming transactions

Submitting is one thing. Knowing the transaction actually landed is another. The TransactionConfirmer handles the polling, backoff, blockhash-expiry detection, and optional rebroadcast in one place.

The simplest pattern, when you want to build, sign, submit, and wait all in one call:

use SolanaPhpSdk\Programs\PaymentBuilder;
use SolanaPhpSdk\Rpc\ConfirmationResult;
use SolanaPhpSdk\Rpc\RpcClient;

$rpc = new RpcClient('https://api.mainnet-beta.solana.com');

$result = PaymentBuilder::sol($rpc)
    ->from($payerKeypair)
    ->to($recipientPubkey)
    ->amount(1_000_000)         // lamports
    ->withFreshBlockhash()
    ->withSimulatedComputeUnitLimit()
    ->buildSignAndSubmit();      // returns ConfirmationResult

if ($result->isSuccess()) {
    // Transaction is on chain at `confirmed` commitment (default).
    // Slot, signature, and elapsed time available for logging.
    error_log("Paid: {$result->signature} in {$result->elapsedSeconds}s");
} else {
    // Inspect $result->outcome: OUTCOME_FAILED, OUTCOME_EXPIRED, OUTCOME_TIMEOUT.
    // $result->error holds the on-chain error if outcome is FAILED.
}

buildSignAndSubmit() defaults to confirmed commitment with rebroadcast enabled and (if withFreshBlockhash() was used) blockhash-expiry tracking enabled automatically. For most ecommerce flows this is what you want.

Choosing a commitment level

Solana's confirmation model has three levels:

  • processed: ~400ms. The validator has seen the transaction but the slot may be skipped. Use only for UI hints, never for payment verification.
  • confirmed: ~1-2 seconds on mainnet. A supermajority of validators have voted on the block. Theoretically rollback-able, but mainnet rollback of a confirmed transaction has not been observed since the network stabilized in 2021. The right default for ecommerce.
  • finalized: ~13 seconds. Committed permanently and mathematically impossible to reverse. Use for high-value transactions or settlement-grade records.

For high-value flows where you cannot accept even theoretical rollback risk:

use SolanaPhpSdk\Rpc\ConfirmationOptions;

$result = PaymentBuilder::sol($rpc)
    ->from($payerKeypair)
    ->to($recipientPubkey)
    ->amount(50_000_000_000)        // 50 SOL
    ->withFreshBlockhash()
    ->buildSignAndSubmit(ConfirmationOptions::finalized());

The two-stage pattern (recommended for high-value ecommerce)

Confirm immediately for fast UX, then verify finalized in a background job for the audit record. This gives the customer a near-instant checkout confirmation without compromising on the eventual settlement guarantee.

use SolanaPhpSdk\Rpc\ConfirmationOptions;
use SolanaPhpSdk\Rpc\TransactionConfirmer;

// Foreground: checkout request, return ASAP.
$result = PaymentBuilder::sol($rpc)
    ->from($payerKeypair)
    ->to($recipientPubkey)
    ->amount($lamports)
    ->withFreshBlockhash()
    ->buildSignAndSubmit();      // ~1-2s

if ($result->isSuccess()) {
    $orderRepo->markPaid($orderId, $result->signature);
    // Customer sees "Payment confirmed!" immediately.
    // Queue a background job for the finalized check.
    $jobs->dispatch(new VerifyFinalizedJob($result->signature, $orderId));
}

// Background job: VerifyFinalizedJob::handle()
$confirmer = new TransactionConfirmer($rpc);
$finalized = $confirmer->awaitConfirmation(
    $signature,
    ConfirmationOptions::finalized()
);

if ($finalized->isSuccess()) {
    $orderRepo->markFinalized($orderId);   // audit log: irreversibly settled
} else {
    // Extremely rare: the confirmed tx didn't reach finalized.
    // Investigate and probably refund.
    $alerts->raise("Order {$orderId} confirmed but not finalized", $finalized);
}

Lower-level confirmation (decoupled submission and waiting)

If you want to submit and wait separately (e.g. submit synchronously, return the signature to the client, and let the client poll):

use SolanaPhpSdk\Rpc\ConfirmationOptions;
use SolanaPhpSdk\Rpc\TransactionConfirmer;

// Submit only.
$tx = PaymentBuilder::sol($rpc)->from($payer)->to($recipient)->amount($lamports)
    ->withFreshBlockhash()
    ->buildAndSign();
$signature = $rpc->sendTransaction($tx);

// Later (different request, different process, doesn't matter):
$confirmer = new TransactionConfirmer($rpc);
$result = $confirmer->awaitConfirmation(
    $signature,
    (new ConfirmationOptions())
        ->withRebroadcast($tx->serialize(), 5)
        ->withBlockhashExpiry($lastValidBlockHeight)
);

Waiting on multiple signatures

Useful when you've batched several payouts and want to know which ones landed:

$results = $confirmer->awaitMultiple([$sig1, $sig2, $sig3]);

foreach ($results as $sig => $result) {
    if ($result->isSuccess()) {
        echo "{$sig}: success\n";
    } else {
        echo "{$sig}: {$result->outcome}\n";
    }
}

Each round uses a single batched getSignatureStatuses call regardless of how many signatures are pending, so RPC load stays linear in poll-count, not signature-count.

Framework Integrations

Solana PHP is deliberately framework-agnostic. It has zero runtime dependencies beyond PHP extensions and makes no assumptions about your application framework, routing, or persistence layer.

Framework-specific integrations (OpenCart, Magento, WooCommerce, Laravel, etc.) live in separate packages that depend on Solana PHP. This keeps the core library lean and lets each integration track its framework's release cadence independently.

Testing

Unit tests

composer install
composer test-unit

Current suite: 348 tests, 1121 assertions. Every wire format is validated byte-for-byte against @solana/web3.js, @solana/spl-token, @solana/pay, and borsh-rs.

Live smoke test

tests/Live/devnet_smoke.php is a standalone script that exercises the library end-to-end against a real RPC endpoint. It builds real transactions, submits them, and waits for chain confirmation. Use this before shipping any integration to production.

# 1. Get a funded devnet keypair:
solana-keygen new --outfile ~/.config/solana/devnet-test.json
solana airdrop 2 --keypair ~/.config/solana/devnet-test.json \
    --url https://api.devnet.solana.com

# 2. (Optional) Grab some devnet USDC from https://faucet.circle.com
#    to exercise the SPL token path. Skipping this only skips two sub-tests.

# 3. Run the smoke test:
SOLANA_PHP_KEYPAIR_FILE=~/.config/solana/devnet-test.json \
  php tests/Live/devnet_smoke.php

The script covers seven steps: RPC connectivity, SOL transfer with confirmed commitment, priority-fee estimation against the live chain, Solana Pay URL encoding with round-trip parsing, USDC ATA existence check, USDC transferChecked with confirmation, and finalized confirmation via buildSignAndSubmit(ConfirmationOptions::finalized()). Each step prints PASS/FAIL/SKIP with a clear reason and timing diagnostics (commitmentLevel, elapsedSeconds, pollCount), and the overall script exits non-zero if anything fails.

Environment variables:

  • SOLANA_PHP_KEYPAIR_FILE - path to a solana-keygen JSON keypair (required)
  • SOLANA_PHP_KEYPAIR_JSON - alternative: the JSON array as a string (useful in CI)
  • SOLANA_PHP_RPC_URL - default: https://api.devnet.solana.com
  • SOLANA_PHP_MERCHANT - default: ephemeral generated pubkey

Roadmap

  1. ✅ Utilities (Base58, ByteBuffer, Ed25519, CompactU16)
  2. ✅ Keypair & PublicKey with PDA derivation
  3. ✅ Borsh serialization layer
  4. ✅ Transaction / Message / Instruction types (legacy format)
  5. ✅ RPC client with provider-agnostic fee estimation (Standard / Helius / Triton)
  6. ✅ Program instruction builders (ComputeBudget, System, Token, Associated Token, Memo)
  7. ✅ Solana Pay URL encode/decode + payment verification helpers
  8. ✅ VersionedTransaction (v0) with Address Lookup Tables
  9. ✅ Compute unit estimation via simulateTransaction
  10. ✅ Transaction confirmation with rebroadcast and blockhash-expiry detection

License

MIT - see LICENSE.

About

A framework-agnostic PHP library for building Solana transactions, instructions, and integrating Solana payments into PHP applications.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Contributors

Languages