lts / phpxq
Pure PHP command-line equivalents of jq and yq
Requires
- php: ^8.5
- ext-ctype: *
- ext-json: *
- ext-mbstring: *
Requires (Dev)
- ext-posix: *
- lts/php-qa-ci: ^85.0
- nikic/php-parser: ^5.8
- phpunit/phpunit: ^13.4
- sebastian/comparator: ^8.4
- symfony/process: ^8.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 00:50:34 UTC
README
phpxq is a command-line equivalent of jq (JSON) and yq (YAML and friends), written in pure PHP 8.5 with no production dependencies. One executable provides both tools:
echo '{"a":[1,2,3]}' | phpxq jq '.a | map(. * 2)' phpxq yq '.spec.replicas = 3' deployment.yaml
It targets jq 1.8.2 and mikefarah/yq v4.54.1. The goal is equivalence, not invention: where jq or yq defines a behaviour, phpxq matches it, and the upstream test suites are run against it to prove that.
Quick start
-
Install a release binary (no PHP needed; see Install for the other ways):
curl -fsSL https://github.com/LongTermSupport/phpxq/releases/latest/download/install.sh | sh -
Query JSON with the jq language:
curl -s https://api.github.com/repos/LongTermSupport/phpxq | phpxq jq -r '.full_name, .default_branch'
-
Query and edit YAML with the yq language:
phpxq yq '.services | keys' docker-compose.yml phpxq yq -i '.image.tag = "v2"' values.yaml
If you already know jq or yq you already know phpxq: the filters, flags, exit codes and error messages are the same (usage, differences).
Goals
- Equivalence, not invention. No new functionality, no new query language.
- Fast. A PHP CLI that starts quickly (about 70 ms for a small filter from the PHAR, about 30 ms from the static binary) and processes input as efficiently as PHP allows.
- Verified against upstream. The upstream jq and yq test suites run in CI, with every known gap listed and justified.
- No production dependencies.
composer.jsondeclares the PHP version and required extensions (ext-ctype,ext-json,ext-mbstring) and nothing else.
Install
Release binary (no PHP needed)
Releases ship static binaries for Linux (x86_64, aarch64) and, best effort, macOS, plus a PHAR and a
SHA256SUMS file. The installer verifies the SHA-256 checksum before installing anything:
curl -fsSL https://github.com/LongTermSupport/phpxq/releases/latest/download/install.sh | sh # also create jq and yq links (they would shadow a real jq or yq, so this is opt-in): curl -fsSL https://github.com/LongTermSupport/phpxq/releases/latest/download/install.sh | sh -s -- --links
Options: --version X.Y.Z, --dir DIR, --links, --phar (see install.sh --help). You can also
download an asset by hand and check it with sha256sum -c SHA256SUMS.
PHAR (needs PHP 8.5)
Download phpxq.phar from the releases page, make it executable and run it. It works under any name; a
link named jq or yq selects that tool.
chmod +x phpxq.phar && ./phpxq.phar jq --version
Composer
Needs PHP 8.5 with ctype, json and mbstring.
composer global require lts/phpxq
# or the development version straight from the repository:
composer global config repositories.phpxq vcs https://github.com/LongTermSupport/phpxq
composer global require lts/phpxq:dev-main
The package is on Packagist as lts/phpxq.
Composer places phpxq in its global bin directory.
From a checkout
git clone https://github.com/LongTermSupport/phpxq && cd phpxq composer install --no-dev bin/phpxq jq --version
Use as a library
The Composer package can be required by another project and called from PHP code, without spawning a process:
$names = LTS\PhpXq\Jq\Jq::run('.items[] | .name', (new LTS\PhpXq\Json\JsonDecoder())->decodeOne($json)); $yaml = LTS\PhpXq\Yq\Yq::evaluate('.items[0].n = 10', $yamlText);
docs/LIBRARY.md lists the supported public classes (everything else is internal and may change), with executed examples for JSON, jq, YAML and yq, the exceptions raised, and the memory limits.
Usage
The executable dispatches on its name, busybox style. Either name the tool as the first argument or
call it through a link named jq or yq:
phpxq jq '.items[] | select(.ok)' data.json phpxq yq -o=json '.' config.yaml ln -s "$(command -v phpxq)" ~/bin/jq # now `jq ...` is phpxq's jq ln -s "$(command -v phpxq)" ~/bin/yq
jq
Takes the same filters, options, exit codes and error messages as jq 1.8.2:
echo '{"name":"phpxq","tags":["a","b"]}' | jq -r '.name, (.tags | join(","))' jq -n '[range(5)] | map(select(. % 2 == 0))' jq --stream -c . big.json jq -e '.ready' status.json || echo "not ready" # exit status 1 when the result is false or null jq --arg who world '"hello \($who)"' -n
yq
Follows mikefarah/yq v4 (eval is the default command, eval-all loads every document first):
yq '.metadata.name' pod.yaml yq -i '.spec.replicas = 3' deployment.yaml # edit in place, comments preserved yq -o=json '.' config.yaml # YAML to JSON yq -p=json -o=yaml '.' data.json # JSON to YAML yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' a.yaml b.yaml yq -o=csv '.[] | [.name, .age]' people.yaml
Formats: YAML, JSON, XML, CSV, TSV, properties, TOML, HCL, INI, Lua, base64 and URI, plus shell and KYAML
as output formats (as in yq 4.54.1; yq --help lists the flags).
Common tasks
| I want to... | Command |
|---|---|
| pretty-print JSON | phpxq jq . file.json |
| compact JSON onto one line | phpxq jq -c . file.json |
| pick a field as plain text | phpxq jq -r '.user.name' file.json |
| filter an array | phpxq jq '[.[] | select(.age > 30)]' file.json |
| use a shell variable in a filter | phpxq jq --arg id "$ID" '.[] | select(.id == $id)' file.json |
| fail a script on a false/null result | phpxq jq -e '.ready' file.json |
| read a value out of YAML | phpxq yq '.spec.template.spec.containers[0].image' deploy.yaml |
| change a value, keep the comments | phpxq yq -i '.spec.replicas = 3' deploy.yaml |
| merge two YAML files | phpxq yq eval-all '. as $item ireduce ({}; . * $item)' a.yaml b.yaml |
| convert YAML to JSON and back | phpxq yq -o=json . a.yaml / phpxq yq -p=json -o=yaml . a.json |
| split a multi-document YAML file | phpxq yq -s '.metadata.name' manifests.yaml |
Everything reads standard input when no file is given, so it works in pipelines
(kubectl get pods -o json | phpxq jq -r '.items[].metadata.name').
Version
$ phpxq --version phpxq 0.1.0 jq-1.8.2 compatible (jq) yq v4.54.1 compatible (yq) $ jq --version # through a link named jq jq-1.8.2 $ yq --version # through a link named yq yq (https://github.com/mikefarah/yq/) version v4.54.1
Differences from upstream and known gaps
phpxq passes every upstream test it can: jq 878 of 879 cases and yq 565 of 572 (two further yq examples,
whose expected output is a Go-seeded shuffle, are skipped because ours is random), and all of the
upstream shell suites. The remainder are deliberate, justified, and enforced (a gap that starts passing
or an unlisted failure breaks the build):
- jq known gaps: one case, an artefact of the upstream test
runner (it has no
inputcallback), not of the CLI. - yq known gaps: seven documentation examples. Three depend on a
frozen clock, two use the
systemoperator, which is intentionally unsupported (it spawns processes), and two are damaged upstream fixtures.
Other differences you may notice:
phpxq --versionis an addition;jq --versionandyq --versionprint exactly what upstream prints.- phpxq is a PHP program, so it starts slower than the native tools; see Performance.
Performance
phpxq is not a replacement for a native binary when startup time matters (a shell loop calling jq thousands of times). It is competitive on larger inputs, where startup is a rounding error.
What it costs to start. PHP itself takes 45 to 56 ms of CPU just to launch, so a trivial filter is about 70 ms from a checkout or the PHAR and about 30 ms from the static binary, against about 40 ms (jq 1.6) for the native jq.
Xdebug. It slows every PHP call, so when it is loaded with an active mode bin/phpxq re-runs itself
with XDEBUG_MODE=off (this needs ext-pcntl; without it the run carries on as is). Set
PHPXQ_ALLOW_XDEBUG=1 to keep it on, for example to debug phpxq itself. With Xdebug kept on, yq accepts
YAML nested up to 5,000 levels instead of 10,000, because Xdebug uses far more native stack per call.
jq throughput (CPU milliseconds, lower is better; minimum of 3 runs; reference is jq 1.6, the version available on the benchmark host):
| workload | phpxq jq | jq 1.6 |
|---|---|---|
| startup | 71 | 40 |
| identity on a medium file | 133 | 134 |
group_by on a medium file |
136 | 119 |
| identity on a large file | 1,436 | 1,926 |
| aggregate on a large file | 741 | 1,926 |
group_by on a large file |
1,455 | 1,851 |
yq throughput (CPU milliseconds; minimum of 3 runs; production-like build):
| workload | phpxq yq |
|---|---|
| startup | 56 |
| identity on a medium file | 333 |
select on a medium file |
273 |
group_by on a medium file |
342 |
| YAML to JSON, medium file | 317 |
| identity on a large file | 6,417 |
select on a large file |
4,852 |
On medium YAML files a wall-clock comparison against mikefarah yq v4.54.1 on the same (busy) host put phpxq between 0.64x and 0.96x of its time; startup and many-small-files workloads are 2 to 5x slower.
How to read these numbers: they come from a shared, loaded machine, so only CPU time (user plus system,
minimum of several runs) is reported, and differences under about 5 percent are noise. "Medium" is about
640 KB and "large" is tens of MB of generated data. Absolute numbers depend on your hardware; run the
benchmark suite below to measure your own. The full results, what each optimisation bought, and the ones
that were tried and rejected are in
CLAUDE/Plan/Completed/00007-performance-optimisation-round
(results.md, results-yq.md, results-binary.md, hot-spots.md).
Development
The project is developed in a CCY container; the
PHP 8.5 environment is defined in .claude/ccy/Dockerfile.
composer install # dev dependencies (lts/php-qa-ci, PHPUnit) vendor/bin/qa # full QA pipeline; mutation testing only on what the branch changed (needs Xdebug) scripts/check-qa-measurements.bash # after a run: coverage and mutation ran, floors met vendor/bin/phpunit -c qaConfig/phpunit.xml --no-coverage # unit tests
The project has no production dependencies, so php-qa-ci's Safe-function Rector lane and its
thecodingmachine/safe require-checker scan files are overridden in qaConfig/, and the
#[\SensitiveParameter] check is disabled in qaConfig/qa.php.
Conformance
scripts/conformance.bash [jq|yq|all] runs the vendored upstream suites (the jq .test files, the
jq shtest shell driver, the yq documentation examples and acceptance scripts) and checks the results
against tests/Conformance/<Tool>/known-gaps.txt. It prints CONFORMANCE: OK on success and exits
non-zero on an unexpected failure or on a known gap that now passes. The same suites are available as
PHPUnit suites (--testsuite jq, --testsuite yq), and PHPXQ_BINARY=dist/phpxq-linux-x86_64 scripts/conformance-shell.bash all runs the shell suites against a built binary.
Vendored fixtures keep their own licences: see
tests/Conformance/Jq/fixtures/NOTICE.md and
COPYING (jq, MIT), and
tests/Conformance/Yq/fixtures/NOTICE.md and
LICENSE (yq, MIT).
scripts/refresh-upstream-fixtures.bash re-fetches them from the pinned upstream tags.
Benchmarks
scripts/bench/bench.bash measures startup time and throughput (small and medium JSON and YAML by
default, large with --sizes; representative filters) for phpxq and, when installed, the real jq and mikefarah yq.
scripts/bench/bench.bash run # JSON + Markdown report under untracked/bench/ scripts/bench/bench.bash baseline --label NAME # store benchmarks/baselines/NAME.json scripts/bench/bench.bash run --compare benchmarks/baselines/NAME.json
Inputs are generated deterministically and never committed. Results record the PHP, OPcache/JIT, CPU and
kernel configuration; only compare results taken on the same machine. Methodology:
CLAUDE/Plan/Completed/00005-benchmarking-suite/BENCHMARKS.md.
Defence Before Fix
Bugs found in this project are handled with the
Defence Before Fix method: a defect is attributed to its class,
a static-analysis rule that catches the whole class is built and proven to fire, every other instance is
found and fixed, and only then is the original bug fixed with a regression test. The rules, what each one
catches and how to run it are in docs/defence-before-fix.md (vendor/bin/rules
lists every active defence, vendor/bin/phpstan-rule <identifier> <path> proves one on one path).
Claude Code hooks
This repository uses the
Claude Code Hooks Daemon for
deterministic guardrails on agent tool calls; configuration lives in .claude/hooks-daemon.yaml.
Releasing
Releases are cut from the changelog; nobody picks a version or tags by hand. Record every user-visible change
under ## Unreleased in CHANGELOG.md (QA enforces it); the headings choose the next
SemVer version. When main is green and has unreleased entries, a bot opens a release
pull request into the release branch. Merging it runs the release workflow: full QA and conformance, the
PHAR and static binaries, smoke tests, the tag vX.Y.Z and the GitHub Release. A back-merge pull request then
brings VERSION and the changelog on main in line. The flow, the version rules and the one-off GitHub
settings are in docs/RELEASING.md.
Sponsor
phpxq's development, including the AI tokens used to build it, is paid for by Edmonds Commerce, a digital agency that builds and maintains e-commerce platforms and bespoke web applications. Thank you.
Licence
phpxq is released under the MIT licence. Vendored upstream test fixtures are covered by their own licences, noted above.