Search by

wbreeze / sol-pay-client

wbreeze

Server-side PHP client for the pay-on-chain metering program: PDA derivation, instruction building, account decoding, preflight, and error mapping. Mirrors wasm-client's core surface for servers with no Rust toolchain and no WASM runtime.

Package info

github.com/wbreeze/sol-pay-php

Homepage

pkg:composer/wbreeze/sol-pay-client

Statistics

Installs: 261

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-09-30 13:47 UTC

This package is auto-updated.

Last update: 2026-09-30 13:55:09 UTC


README

Prologue

This file is read in two repositories, and it is the same file.

Find package development in the php-client/ directory of the repository, wbreeze/sol-pay. Find a distribution only mirror at, wbreeze/sol-pay-php. Packagist publishes from the mirror. The mirror, wbreeze/sol-pay-php, is a projection, never a place to work. The script, bin/split-php-client in the wbreeze/sol-pay repository uses git split to synthesize history from this php-client directory. Any commit made within wbreeze/sol-pay-php would prevent the next split from fast-forwarding and will be erased, because the synthesized tree is the php-client directory contents, nothing else.

In short, make any and all changes in the php-client directory of the wbreeze/sol-pay repository.

See "Publishing" below.

Description

A server-side PHP port of wasm-client's core, for a site whose server has no Rust toolchain and no WASM runtime. Published on Packagist as wbreeze/sol-pay-client — composer require wbreeze/sol-pay-client. See wasm-client/SPEC.md for the design this mirrors, and wasm-client/README.md for the same crate in Rust/JS form.

What is here, and what is deliberately not

wasm-client/SPEC.md §3 splits an integration into two consumers: a browser, which holds the page's browser key, and a server, which signs as the site authority. The reader's wallet signs neither side's code: it signs in the wallet, by a Solana Pay scan, a transaction the server composed (SPEC §4.9). So the server row is every instruction builder, and this package covers it:

wasm-client/src/core src/Core
pda.rs Pda — siteAddress, fundAddress, fundTokenAccount, meterAddress
ix.rs, tx.rs Ix — initializeSite, meterAndSettle; openFund, deposit, openMeter, renewMeter, withdraw, closeFund, closeMeter; openFundAndDeposit, the ordered pair
state.rs Site, Fund, Meter, TokenAccount, Mint, ByteReader (internal)
preflight.rs Preflight, Blocked
error.rs PayError, TokenError, Cause, Shortfall
units.rs Units
program.rs Program
ids.rs Ids
proof.rs Proof — verifyKey, through ext-sodium
(nothing) Tx — compile, wire; see "Transaction assembly" below

The server composes the reader-signed setup — openFundAndDeposit, then openMeter or renewMeter — as the answer to the wallet's transaction request, and submits a key-signed closeMeter from the page as fee payer (SPEC §4.8). withdraw and closeFund belong to a management page rather than a site, and are here so that one can be built on either port. What is absent is the browser's half: generating the key and signing with it. Signing, RPC, and storage are out of scope here for the same reason wasm-client leaves them out — see SPEC §7.

Conventions

Every public pubkey is a base58 string, in and out — matching wasm-client's own JS boundary (wasm-client/README.md, "addresses cross its boundary as base58 strings, never as a Pubkey object"). There is no Pubkey type here at all; Base58::decode/encode are the only place raw address bytes exist, and only internally.

PHP has no unsigned 64-bit integer. This package's safe integer ceiling is PHP_INT_MAX (~9.2e18), not u64::MAX (~1.8e19) — ByteReader::u64(), Preflight, and Units each document this where it matters. Ordinary token amounts, prices, and limits never come close to either ceiling; the difference only matters at the extreme.

Where Rust splits an API into methods on a deployment handle plus free functions defaulting to the canonical one — avoiding Program::default() at every call site — this package just takes a Program explicitly (Ix, Cause::of) or an optional program id (Pda). Same coverage, one implementation: PHP doesn't have the call-site friction that split exists to avoid in Rust.

Building and testing

composer install
composer test
  • composer test: vendor/bin/phpunit --testdox works the same way

Requires PHP ^8.1. Not wired into bin/build-rust or bin/test-rust — this package has no Rust toolchain dependency and no reason to share theirs; see the repository root CLAUDE.md for why that isolation is deliberate rather than an oversight. It has its own entry points instead: bin/test-php and the php conformance workflow, both under "Drift control" below.

That ^8.1 is the library's floor, not the tooling's. PHPUnit 11 pulls a sebastian/* tree requiring php >=8.2, so a plain composer install cannot resolve on 8.1 at all. composer install --no-dev can, because this package has no runtime dependencies — that is how CI tests the floor, and how a consumer on 8.1 would install it, since consumers do not take dev dependencies either.

Drift control

Nothing here can call into the Rust core, so nothing here is provably right without a check against it. vectors-gen/ is that check: a small Rust binary, unpublished, that emits five things sourced from the real crate and program rather than transcribed by hand —

  • PDA derivation and one meter_and_settle instruction, from the published sol-pay-client crate on crates.io.
  • A genuine Anchor-serialized Site and Meter account, built from pay-on-chain::state::{Site,Meter}'s own #[account]-derived DISCRIMINATOR and AnchorSerialize.
  • The PayError code table, computed as PayError::<variant> as u32 + anchor_lang::error::ERROR_CODE_OFFSET against pay-on-chain's own enum.
  • The TokenError code table, read from spl_token::error::TokenError.
  • Three compiled legacy transaction messages and their wire bytes, from solana-message and solana-transaction, checked byte-for-byte against Tx::compile and Tx::wire — see "The order this has to happen in" below for what each case covers and why they were generated first.

tests/Core/PdaTest.php, IxTest.php, StateTest.php, and ErrorTest.php assert against those values as hardcoded literals, so a mismatch surfaces as a specific failing assertion pointing at the stale one, not a missing file. Regenerate and re-run after touching state.rs, errors.rs, pda.rs, or ix.rs on the Rust side:

../bin/test-php
composer test
  • ../bin/test-php: regenerate the vectors, then check src/Core against them
  • composer test: the five PHPUnit suites above, against their literals

Those are two different questions and neither replaces the other. Frozen literals name a local regression precisely and cannot notice the crate moving; freshly generated vectors notice the crate moving and would not tell you which of your own edits broke something.

conformance/vectors.php is what bin/test-php runs, and it checks this package rather than the spike: both PDA families with their bumps, the meter_and_settle data and every account's pubkey and flags, Site::decode, Meter::decode, and both error tables. The php conformance workflow runs it on every push that touches this directory or the program, on PHP 8.1 and 8.5. pda-spike/php/verify.php still exists and still works, but it checks the spike's standalone Pda/Base58 — not what ships.

Preflight and Units are pure arithmetic with no on-chain bytes to cross-check; their tests mirror wasm-client's own #[cfg(test)] modules test-for-test instead. For Units that is the right level of rigor — there is no encoding there to get wrong.

For Preflight it is not, and closing that took a second fixture with different provenance. wasm-client/SPEC.md §8 says why it matters: in pay-on-chain/tests every Rust preflight predicate is checked against actual LiteSVM behaviour, because a predicate that disagrees with the program is worse than having no predicate at all. These copies had no such check. Mirroring the Rust tests test-for-test would not have supplied one — that checks this port against the port's reading of the program, both readings from the same place. Nor could vectors-gen: preflight produces no chain-serialized bytes, which is why it emits none for it, and a verdict computed from the Rust client predicates would only prove PHP agrees with Rust, not with the program.

What supplies it is a recording. pay-on-chain/tests/src/test_preflight_fixture.rs drives the same live SVM the other tests drive and writes conformance/preflight-fixture.json: the three account records as raw bytes at each interesting instant, the predicate's verdict there, and what the program then actually did. conformance/preflight.php replays it. The boundary it crosses is one both sides already agree on — Site::decode, Meter::decode and TokenAccount::decode are themselves checked byte-for-byte on every conformance run — so there is no new schema to keep in step.

Three properties of that arrangement are worth stating, because each one was a choice:

  • The recording is gated by the assertions around it. Every case requires the program to agree before it is kept, and the file is written once at the end of a passing run. A program regression therefore fails bin/test-rust and leaves the committed fixture untouched, rather than quietly rewriting this port's expectations to match the regression.
  • It is committed, and its provenance is not vectors.json's. The vectors come from the published crate and are regenerated every run — an outside opinion. This comes from the local program, because the question is whether this port agrees with the program you are about to deploy. It moves only when bin/test-rust rewrites it, which makes a moved verdict a reviewable diff. The two files answer different questions and should not be merged.
  • Coverage is what the recorded cases touch, and no more. Pinned: charge, canMeter (including over), willSettle, itemsRemaining, limitFloor, and Shortfall::of. Not pinned: Blocked::Overflow, which no realistic item count reaches — and which would not mean the same thing on both sides anyway, since this package overflows at PHP_INT_MAX and the program at u64. Widening it means adding a case to the Rust recorder: one place, both ports.

The demonstrator is still the first thing running these predicates against a real devnet, and that remains the sharper test — a recording pins agreement at the states the harness reaches, not at every state a live site produces.

Transaction assembly (SolPay\Tx)

Decided and built 2026-09-04. The reasoning against the obvious shortcut is kept below because it is the part that would otherwise be lost. What was decided is that this package compiles the message itself; see "Decided: this, not the sidecar" for what that was weighed against.

A PHP site authority holding an Instruction from SolPay\Core\Ix cannot send it. Between the instruction and the wire there is a legacy transaction message to compile: compact-u16 (shortvec) length prefixes, account-key deduplication and ordering by signer/writable rank, the three header counts, the program-id index, the recent blockhash, and the signature array. This package stops at the instruction, and nothing in sol-pay goes further in any language — wasm-client/src/core/tx.rs pairs reader-signed instructions in the order the program requires, which is ordering constraints and not wire format. SPEC §7 records the same finding from the specification's side: "the integrator owns the connection" cost nothing for Rust or Node, and the sentence did not change, the population it applies to did.

One valid ordering, not the only one

Added 2026-09-12, after wasm-client/conformance/kit.mjs measured it against @solana/kit 8.3.0.

Tx::compile agrees with solana-message byte for byte, on all three vectors, and that stays the standard this package is held to. But it is worth knowing what that agreement is not: it is not a claim that those are the only correct bytes for that transaction.

Inside a header partition the account order is not canonical. solana-message builds its key list from a BTreeMap<Pubkey, _>, so it ascends by raw 32-byte value — which is the rule "The order this has to happen in" below spends its length on, because getting it wrong is invisible. @solana/kit ascends by the base58 string instead. The two agree wherever those orders agree and disagree on exactly one key in these vectors: the SPL Token program id, whose raw bytes sort early and whose base58 spelling sorts late. Same header, same account set, same signer and writable bit on every key, same resolved instructions — different bytes.

Both are valid; a validator checks the partition invariant and resolves instruction accounts by index, and each message is self-consistent. So the rule for anyone holding both halves: never compare a Tx::compile message with a browser-compiled one byte-for-byte — not as a "confirm what you are signing" check, not as a cache key, not to deduplicate. Compare the resolved transaction: header, account set, per-key flags, and each instruction's account list resolved back through its own key table. wasm-client/SPEC.md §8.2 has the measurement and wasm-client/conformance/kit.mjs does it that way.

Why not an existing PHP Solana SDK

There is prior art. Checking it is what settles the question rather than raising it — checked 2026-09-04:

  • tightenco/solana-php-sdk, the original — last release v0.3.2, March 2022; repository archived; Packagist lists it abandoned. 14,895 installs. Requires php ^7.4 || ~8.0, which does not even permit this package's 8.1 floor. Pulls guzzlehttp/guzzle, illuminate/http and illuminate/support.
  • attestto/solana-php-sdk, the living fork of verze-app, itself of tightenco — 42,464 installs, php ~8.2, Guzzle plus a sodium compat shim plus dotenv. Untagged. Packagist lists five versions for it and every one is a branch: dev-master (2024-11-08), dev-chore/dependabot-grouping (2026-08-06), and three feature branches from 2024. Packagist imports tags automatically, so five branches and no tag is the absence itself and not a summary of it — there has never been a release, and requiring this package means requiring a moving branch. That 2026 branch does mean the repository is not dormant, only untagged; "last commit 2024-11-08" is true of master and not of the repository.
  • Behind those, a fork tail: cryptothree, lyenon, iroge, jools, safebits, pantaovay, josephopanel, efrost-deltaplan. Nine or more forks of one abandoned package is what an unmaintained dependency looks like from the outside.

Three consequences, in order of how much they matter:

  1. None of it is checked against anything. These are hand-written ports of a wire format with no conformance vectors and no upstream to notice drift — which is the exact exposure "Drift control" above exists to answer, taken on as a dependency instead. An unverified message serializer fails the way the libsodium PDA shortcut would have: it builds a plausible transaction that does the wrong thing, and then someone signs it.
  2. The dependency cost is disproportionate. This package has zero runtime dependencies and "Building and testing" above makes that load-bearing — composer install --no-dev on 8.1 is how a consumer on the floor installs it. Reaching a message serializer through Guzzle and illuminate/* gives that up for a few hundred lines of encoding.
  3. The floors do not line up. Depending on the maintained fork would push this package's floor from 8.1 to 8.2, and php-conformance.yml tests 8.1 on purpose.

What it would be

Two pure functions of their arguments, which is the property src/Core is built on:

compile(Instruction[], feePayer, recentBlockhash) -> message bytes
wire(message, signatures)                         -> transaction bytes

The blockhash is passed in, never fetched. The signature is produced by the caller — sodium_crypto_sign_detached, the signature API, present in ext-sodium since 7.2; only the ed25519 core point API is missing, which is the separate finding in pda-spike/README.md. So every verb in SPEC §7 survives: still no signing, no sign-in message construction, no RPC, no retries, no storage, no routing, no rendering, no session management. The one verification it does is the key proof, Proof::verifyKey, over the site's own nonce (SPEC §6.6) -- a nonce the site must use once and expire within minutes, since a proof accepted twice lets whoever copied it read on the reader's fund.

SPEC §2's design rule puts this on the library's side rather than the site's: message compilation is encoding, ordering and exact byte layout; there is no legitimate site choice anywhere in it; and wrong costs a failed transaction and a real fee, which is §2's own criterion. It has no view on what limit to suggest, when to meter, how the site identifies a visitor, or what to do when a payment fails.

Sizing: shortvec, key ordering and header derivation are on the order of 150 lines with no dependencies, against the ~250 for the Fe/Ed25519 field arithmetic already accepted here.

The order this has to happen in

  1. Vectors before the encoder — done. vectors-gen emits the expected message and wire bytes for three fixed (instructions, fee payer, blockhash) cases, produced by solana-message and solana-transaction rather than transcribed by hand. Writing the compiler first would have meant hand-verifying wire bytes, which is the same trap the PDA shortcut was — plausible output, no error. No new surface in the shipped Rust client: the generator reaches for Solana's crates directly.

    The blockhash is sha256("blockhash-0") throughout, following the file's own seeding convention. Signatures are 64 fixed bytes each, seeded per case and position, and are not signatures over anything; they are there so the wire framing around the message is covered for more than one signer. Nothing in sol-pay signs, in any language, and this must not become the place that starts.

    Each vector records the message whole and in pieces — header counts, ordered account keys, compiled instruction indexes — beside the source_instructions that went into it. A byte-for-byte mismatch on the whole says only "differs"; the pieces say whether the ordering, the header or the shortvec framing is what went wrong, which is the same reason the meter_and_settle vector records flags and not just its data.

    The three cases, and what each one is for. One instruction signed and paid for by one key leaves most of compilation unexercised, so the set is chosen to reach every branch:

    • authority-pays — the shipping shape: the site authority signs meter_and_settle and pays. One signature. Nine keys, header 1/0/5.
    • separate-fee-payer — a relayer pays for someone else's instruction. Two signatures, and the authority stays readonly, which is the only way to put anything in the readonly-signer partition. The first case leaves that partition empty, so without this one an encoder could omit it entirely and still pass.
    • two-instructions — initialize_site and meter_and_settle together, paid by a third key. site is writable in the first and readonly in the second, treasury the reverse, so this pins the flag merge in both directions; both instructions call the same program, so its id must appear once; and two instructions make the instruction array's compact-u16 count something other than 1 for the first time.

    Two rules are worth knowing before writing compile, because both are invisible until a vector disagrees with you:

    • Within each partition the keys ascend by raw pubkey bytes, not by the order the instructions named them. In authority-pays the instruction positions come out 3,5,4 and 7,2,6,–,0 while the bytes ascend strictly. An encoder that preserves instruction order inside a partition builds a different message that still looks right.
    • The fee payer is pulled out and put first rather than sorted into place, and it is forced to a writable signer even when the instruction marked it readonly — it pays the fee. two-instructions is what discriminates this: it has a second writable signer, and sha256("fee-payer-0") sorts after sha256("authority-0"), so an encoder that sorts all the writable signers together passes the other two cases and fails that one.

    While there was no encoder, conformance/vectors.php checked what it could: that each vector was present, internally consistent, and in agreement with the instructions a different crate compiled. Those checks are gone now, replaced by the byte-for-byte comparison they were standing in for — the promise made when they were written, kept rather than left to accumulate.

  2. Then SolPay\Tx — done. compile and wire in src/Core/Tx.php, with tests/Core/TxTest.php asserting hardcoded literals for the same reason the other four suites do: the conformance run notices the crate moving, the literals name which local edit broke something.

    conformance/vectors.php now compares Tx's output to all three vectors byte-for-byte. The shape checks that stood in while there was no encoder are gone, replaced by one diagnostic — a mismatch reports the byte and the section it falls in ("byte 4, in account key 0"), because a hex diff of 348 bytes says only "differs".

  3. Prove it against devnet in the demonstrator — done, 2026-09-07. The conformance run proves Tx agrees byte-for-byte with solana-message and solana-transaction on three fixed cases. It does not prove a validator accepts what comes out, and those are different claims: no signature there is real, no blockhash there was ever current, and nothing has paid a fee.

    One now has. The demonstrator's bin/devnet-smoke compiles, signs and sends a System transfer through Tx::compile and Tx::wire, and devnet accepted it — header 1/0/1, a 150-byte message, signature jueKu9Tq...AiZbpw2. The transfer is chosen rather than convenient: the fee payer signs and is written, the recipient is written and does not sign, and the System program is readonly and neither, so three of the four header partitions and the fee-payer-prepended rule are all on the wire.

    Then initialize_site, the same day. The demonstrator's first-run setup called it against the deployed program on devnet: site account 7X4hDbm4...m1dSCVt, signature 25eDQUqu...cUpZaSWi. That is this package's own instruction rather than the encoder alone, and four things passed at once that a transfer could not reach — the Anchor discriminator sha256("global:initialize_site")[..8], the five-account list with its signer and writable flags, three borsh u64 arguments, and Pda::siteAddress, which the program re-derives from its own seeds and would have rejected as a seeds-constraint failure had it disagreed.

    Then meter_and_settle, on 2026-09-07. The demonstrator metered a reader's page view: signature 4M6NhLY5...tkstxRi, the instruction built by Ix::meterAndSettle, compiled by Tx, signed by the site authority. This is the one that moves compilation off the setup path and onto the path a reader takes — the demonstrator now compiles a message on every metered request that is not already covered by a view grant.

    And then one settled, the same day. Signature 2N6VoKtP...pkG23ASS, 0.15 DEMO into the treasury. That is the call that carries a cross-program invocation, a delegate and a transfer, and it is the one SPEC §7's condition was written for — an accepted metering call and a settling one are different claims, since a call below the collection threshold increments used and moves nothing.

    The citation had to be read back off the chain, because the demonstrator deliberately keeps no log of its metering transactions: its §10.4 enumerates its stores and a per-wallet record of these would be the reading history that whole design avoids. bin/last-settle there walks the treasury's transactions and reports each one's token-balance delta, which is how the signature above was found — and which incidentally showed the settle firing on alternate advances, exactly as that document's §7.4 predicted.

  4. Then amend SPEC §7's sentence — done, 2026-09-07. It was held until step 3 finished: §7 describes what the library does, and "compiles the message that carries them" is a claim better made after a validator has accepted a metering transaction rather than merely a transaction, and better still after one has settled. §7 now reads "it builds instructions and the message that carries them, and decodes bytes", and records all four devnet results with the condition each one did and did not discharge.

The scope boundary, stated so it is not a surprise later

This package would be shipping a small piece of general Solana plumbing, and people will ask for v0 messages, address lookup tables, compute-budget instructions and durable nonces. The honest boundary is legacy message only, and it holds: a priority-fee or compute-budget instruction is just another Instruction in the array, so it costs nothing to support and nothing to refuse. v0 and lookup tables are a real decision, and for meter_and_settle — eight accounts, one signer — the answer is no.

Decided: this, not the sidecar

Decided 2026-09-04. SPEC §3.1's sidecar was decided the same day, and it absorbs assembly and signing for every unserved language at once — including this one. PHP does not take that route. A sidecar is the answer for a language with no package; PHP has a package, and one whose zero runtime dependencies and 8.1 floor are the whole reason it installs where it has to. Asking a PHP shop for a deployment unit and a trust boundary in place of a composer require gives that up, and the fragmentation that carries the sidecar — Ruby, Java, Scala, ASP.NET, Python, no CMS mass to aim a port at — was never an argument about PHP.

What that buys is bought with drift, and the bill is not disputed here. SPEC §8.1's objection stands exactly as written: this package is a second implementation, SolPay\Tx widens it from derive/encode/decode to derive/encode/decode/compile, and the sidecar would have avoided the widening because Rust gets message compilation from Solana's own crates for nothing. The cost is accepted, not answered. That is what makes step 1 above load-bearing rather than merely prudent: vectors first is the price of this decision, and an encoder written before them would take on the drift without buying the check.

Relationship to pda-spike

pda-spike/php/Curve25519.php and Pda.php were where this package's field arithmetic and PDA derivation were first proven — see pda-spike/README.md for that experiment's own record, including a finding worth knowing before touching Fe/Ed25519: PHP's sodium extension exposes no ed25519 core API on any build tested so far, so the natural-looking shortcut (sodium_crypto_core_ed25519_is_valid_point) doesn't exist, and a stricter substitute would silently derive wrong addresses on roughly half of all inputs.

src/Core/Fe.php and Ed25519.php are promoted copies, namespaced and pruned to what this package actually needs — the spike's own copies, and its libsodium-comparison code, stay frozen as the record of that dated run. Fix a bug in the arithmetic here; don't expect it to also need fixing there, since the spike is a record, not a dependency.

Publishing

First published 2026-09-05; the current release is v0.1.2. It stayed packaged-but-unpublished until then on purpose — the same reasoning wasm-client/README.md's publishing section gives: rare, irreversible, and worth a deliberate decision rather than a side effect of finishing the code. What follows is the mechanism, which is unusual enough to be worth reading before the next release. Packagist lists v0.1.0 and v0.1.2; v0.1.1 was a mis-tag that duplicated v0.1.0's tree, and "Publishing a version" below records how, because the way to avoid repeating it is a step in that procedure.

What the first tag actually shipped, confirmed against git archive v0.1.0 in a clone of the mirror: the two licences, this README, composer.json, composer.lock, .gitignore and src/Core. Nothing else — see "What the package ships" above for why. Note too that Packagist's recorded dist and source reference is the mirror's tag rather than a commit in the development repository: 1431093 for v0.1.0, 2a56af8 for v0.1.2.

Packagist versioning needs more than a tag. Unlike cargo publish/npm publish, which package whatever the manifest says at the moment you run them, Packagist derives a package's version from a git tag — and a tag in this repository is repo-wide, so it would apply to pay-on-chain and wasm-client too, meaning nothing to either of them. Prefixing the tag to scope it (php-client-v0.1.0) looks like the obvious fix and was tested directly against Composer's own VCS driver (the same code Packagist runs): it does not work. Composer's tag parser requires the tag to be the version string, with only an optional leading v — v0.1.0 alone resolves to 0.1.0; php-client-v0.1.0 and php-client/v0.1.0 are both invisible to it, not even parsed incorrectly, just never listed as a version at all. A second, independent problem showed up in the same test: Composer's VCS repository type requires composer.json at the repository root, and fails outright (No valid composer.json was found in any branch or tag) when it lives in a subdirectory the way this one does.

Both problems have the same fix, and it is the standard one in the PHP ecosystem (Symfony's components, Laravel's illuminate/*): a subtree split — mirror php-client/ into its own repository and point Packagist at that instead of this one. The split repo gets composer.json at its root for free, and a tag history that cannot collide with the other two artifacts' versioning.

Publishing a version

bin/split-php-client does the local half and stops. It refuses if php-client/ has uncommitted changes, runs bin/test-php and composer test, synthesizes the split, checks the result is actually publishable, and prints the remaining commands. It does not push, tag, or publish: those leave the machine, need personal credentials, and are hard to take back — the same reasoning that leaves cargo publish unscripted.

One-time setup. Create a public repository for the split — it holds only this package. Add it as a remote here, over SSH:

git remote add split git@github.com:<owner>/<mirror>.git
git config remote.split.tagOpt --no-tags
git fetch split

The https:// URL GitHub offers on the repository page is the wrong one to paste. Nothing else here uses it, so it has no credential helper behind it and no key to fall back on: git will not try SSH at all, it will prompt for a GitHub username and password, and password authentication for git operations was removed years ago — so the push fails with Invalid username or token while the SSH key that would have worked sits unused. If that has already happened, git remote set-url split git@github.com:<owner>/<mirror>.git fixes it. The git fetch is what gives bin/split-php-client a refs/remotes/split/master to check against; without it the script says so and checks nothing.

tagOpt --no-tags matters more than it looks. A default fetch also takes any tags reachable from what it fetched, so once the mirror has releases, one git fetch split would copy v0.1.0, v0.1.1, … straight into this repository's tag namespace — the exact thing the split exists to prevent, and one git push --tags origin away from claiming to version pay-on-chain and wasm-client. Setting it once shuts that off permanently. If tags have already arrived here, git tag -d them; the mirror keeps the real ones.

Then submit that repository's URL at https://packagist.org/packages/submit, and enable the GitHub hook Packagist offers so later tags are picked up without resubmitting. Nothing is ever committed in the split repo by hand; see below.

The first push is the one exception, and it needs --force. A repository created through GitHub's own form is not empty — the "Initial commit" it makes for you, and anything added to it since, are commits that exist nowhere in this repository, so the synthesized history shares no ancestor with them and the push is refused as a non-fast-forward. Overwriting them is right rather than regrettable: the split tree is exactly php-client/'s contents, so a hand-written README.md there would be replaced by this file on the first successful push anyway, and a note that belongs to the mirror belongs at the top of this file instead, where every split carries it forward.

git ls-remote split master
git push --force-with-lease=master:<that sha> split php-client-release:master
  • git ls-remote split master: asks the mirror; git rev-parse split/master answers from the last fetch

The sha has to be the mirror's, and there is a live way to get it wrong. It is what master is on in the mirror — not a commit from this repository, however recently you made it. Read it back rather than typing one you remember: a sha the remote's master has never been at can never satisfy the lease, so the push is refused no matter how many times you run it, and the error talks about stale info rather than about the sha being from the wrong repository.

--force-with-lease rather than --force so it still refuses if the mirror has moved since you looked — the thing worth catching is somebody having committed there, which is the one failure this whole arrangement is trying to avoid. Once, at setup, and never again: every later push is a fast-forward, and one that is not means the history diverged and needs working out, not forcing.

Per release, from the root of this repository:

bin/split-php-client
git push split php-client-release:master
  • bin/split-php-client: verifies, then updates php-client-release

Then, in a clone of the split repository — not here:

git pull --ff-only
git tag vX.Y.Z <the sha bin/split-php-client printed>
git push origin vX.Y.Z
  • git pull --ff-only: the push above moved the mirror, not this clone

Pull first, and tag the sha the script printed. The clone does not know about the push — it went from the other repository straight to the mirror — so its master is wherever it was after the last release. Tagging a stale HEAD does not fail: it produces a release whose tree is byte-identical to the previous one, published under a new number, and nothing tells you until you compare them. That is not hypothetical; v0.1.1 here is exactly that, the same commit as v0.1.0, and v0.1.2 is the release it was meant to be. Naming the sha explicitly is what makes the step independent of the clone's state — bin/split-php-client prints it as synthesized <sha> and again in the instructions it ends with.

--ff-only rather than a plain git pull: this clone must never diverge from the synthesized history, so a pull that cannot fast-forward should refuse rather than quietly write a merge commit — which would be a commit made in the split repository, the one thing that breaks the arrangement for good.

The tag belongs there and only there. A tag in this repository would claim to version pay-on-chain and wasm-client as well, which is the whole problem the split exists to avoid. composer.json carries no version field on purpose — Composer takes it from the tag, and a hardcoded one conflicts with it — and bin/split-php-client fails if one appears.

Which of those two pushes publishes the version? Neither, exactly, and the distinction is worth holding on to. Packagist's hook fires on GitHub's push event, and GitHub sends that for a tag push as much as a branch push. But the hook carries no instruction about what changed: it queues a re-crawl, and Packagist then re-reads the mirror's whole list of branches and tags through Composer's VCS driver and rebuilds the version list from scratch. Tags become releases, branches become dev- versions.

So the branch push refreshes dev-master and finds no tag; the tag push triggers a second crawl that finds one. The tag is discovered, never announced — which is why the order matters only in the sense that whichever push happens last is the one that makes the release visible, and why forgetting the hook is survivable rather than fatal: Packagist crawls a hookless package weekly on its own, and a hooked one at least monthly if a delivery is missed.

Two consequences. A release's metadata comes from composer.json as it exists at that tag — fixing something on master afterwards changes nothing about a version already cut, and needs a new tag. And the tag has to be pushed to the mirror: tagging here does nothing, since the hook is on the mirror and Composer could not parse a scoped tag name anyway (above).

Why this is not a one-time task. The split repository is a projection of a moving source, so every release re-runs the split to pick up what changed. That is cheap: git subtree split is deterministic, so the same commits synthesize to the same SHAs, the branch extends rather than being rewritten, and the push is an ordinary fast-forward. The script checks that property explicitly instead of trusting it, and refuses rather than suggesting a force-push if it ever fails.

The one way to break it: never commit in the split repository. It is write-only from here. A commit made there diverges from the synthesized history, and the next split stops fast-forwarding.

Why the licences are copied, not linked

LICENSE-MIT and LICENSE-APACHE here are intentional duplicates of the ones at the repository root, for the same reason wasm-client/ carries its own: the licence has to be inside the published artifact, and this directory becomes the root of one.

Copies rather than symlinks, and that is not a style preference. Git stores a symlink as a blob whose content is the target string, and git subtree split moves that blob verbatim — so php-client/LICENSE-MIT -> ../LICENSE-MIT arrives at the split root still pointing at ../LICENSE-MIT, now outside the repository entirely. Measured: it dangles in a fresh clone, and it survives git archive as a symlink (the zip entry carries the symlink bit and 14 bytes of path, not the licence text), so it dangles on Composer's dist path too. On a checkout without symlink support it is worse — a plain file whose entire content is ../LICENSE-MIT, which looks real and is not. bin/split-php-client fails on a symlink there rather than shipping one.

What the package ships

Decided 2026-09-05. The split takes everything under php-client/, which includes pda-spike/ — a dated experiment kept as a record — and vectors-gen/, a Rust crate that cannot run for a Composer consumer at all. Neither breaks anything, and both are small in git, but a composer require should not hand somebody a spike and a Rust generator alongside the library.

The suites go too, which is the conventional exclusion in this ecosystem — tests/ and phpunit.xml, and conformance/ with them. That last one is not a judgement call: conformance/vectors.php reads vectors-gen/vectors.json, which is gitignored and has never been in a package, so the script could not run from a dist zip before this either.

Two remedies were available and they are not the same remedy. Excluding all of it from the split would take it out of the published repository too, and it should be there: the split repo is the package's public source, and how the field arithmetic was settled, how the vectors are made, and how anyone checks the claim are all part of that source. Excluding it from git archive takes it out of the dist zip only. That is php-client/.gitattributes:

/pda-spike      export-ignore
/vectors-gen    export-ignore
/tests          export-ignore
/conformance    export-ignore
/phpunit.xml    export-ignore
/.gitattributes export-ignore

git archive is what GitHub serves and what Packagist hands Composer as the dist zip, which is how essentially every composer require of this package will arrive. Cloning the split repository, or composer install --prefer-source, still gets everything. That is the line worth drawing: the record stays with the source, and the install gets the library.

composer.lock deliberately stays. It is committed here for a reproducible dev install, Composer ignores a dependency's lock file outright, and excluding it would buy nothing. composer.json's autoload-dev still names the now absent tests/, which is correct and not an oversight — dev autoload rules of a dependency are never used, and composer validate --strict and composer dump-autoload --no-dev were both run against the dist shape to confirm it. What ships is the licences, the README, composer.json, composer.lock and src/Core.

Two mechanical notes, both measured rather than assumed. export-ignore on a directory excludes the whole subtree, even though git check-attr on a file inside it reports the attribute unspecified — check-attr answers per path and does not inherit, git archive prunes at the directory entry. And this file is itself inside the prefix, so it splits with everything else and lands at the split repository's root, where the leading slashes resolve; each path anchors to the directory holding the file, which is php-client here and the package root there — the same two directories either way.

bin/split-php-client extracts the split with git archive, so what it verifies is the dist zip. Its check is a whitelist of the top level, not a list of things to keep out, and that is deliberate: the exclusion is a few lines in one file that nothing else in the repository would notice if they were deleted, and the failure worth guarding against is the next thing added under php-client/ with no line written for it — which a blocklist cannot see. Adding to the package is then a deliberate edit to the script, the same reasoning that leaves publishing unscripted. It still prints the top level on every run.

Licence

Dual licensed under either of Apache License, Version 2.0, or the MIT license, at your option — see the repository root LICENSE.md for why both.