shyim / composer-binary-downloader
Generic Composer plugin that downloads platform-specific binaries — FFI shared libraries, CLI tools, WASM modules — from GitHub releases or any URL, driven by composer.json extra configuration
Package info
github.com/shyim/composer-binary-downloader
Type:composer-plugin
pkg:composer/shyim/composer-binary-downloader
Requires
- php: >=8.2
- composer-plugin-api: ^2.0
Requires (Dev)
- composer/composer: ^2.4
- phpunit/phpunit: ^10.5 || ^11.0
README
A generic Composer plugin that downloads platform-specific binaries. Any package
declares what it needs in composer.json; the plugin fetches the right build for
the host on composer install.
It replaces the pattern of writing a bespoke Composer plugin per native dependency — download from GitHub releases, verify the checksum, unpack, put the file where the loader expects it — with configuration.
Works for anything shipped as a per-platform release asset:
- FFI shared libraries (
.so/.dylib/.dll) — the original use case - CLI tools — a Rust or Go binary your package shells out to
- WASM modules, ICU data, model files, or any other platform-specific blob
The engine does not care which: it fetches an asset, verifies it, and puts one file at a configured path.
Install
composer require shyim/composer-binary-downloader
Composer plugins must be allowed explicitly:
{
"config": {
"allow-plugins": { "shyim/composer-binary-downloader": true }
}
}
Configure
Add extra.binaries to the package that needs the binary. Every installed
package is scanned, as is the root project, so a wrapper package declares its
native dependency once. Consumers approve the package a single time — via the
interactive prompt or an extra.allow-binaries entry (see
Security) — and need no other setup.
{
"name": "shyim/sasso-ffi",
"require": { "shyim/composer-binary-downloader": "^1.0" },
"extra": {
"binaries": {
"sasso": {
"version": "0.8.2",
"repository": "shyim/sasso",
"asset": "{name}-v{version}-{target}-c-api.{ext}",
"archive": "tar.xz",
"path": "bin/{target}/{library}",
"targets": {
"x86_64-apple-darwin": "libsasso.dylib",
"aarch64-apple-darwin": "libsasso.dylib",
"x86_64-unknown-linux-gnu": "libsasso.so",
"aarch64-unknown-linux-gnu": "libsasso.so",
"x86_64-unknown-linux-musl": "libsasso.so",
"aarch64-unknown-linux-musl": "libsasso.so",
"x86_64-pc-windows-msvc": { "library": "sasso.dll", "archive": "zip" }
},
"checksums": {
"aarch64-apple-darwin": "5f511055100a96c935a734c783b880567fd65e54a87a51c583b7ca8223cee9e7"
}
}
}
}
}
The library lands at
vendor/shyim/sasso-ffi/bin/aarch64-apple-darwin/libsasso.dylib.
Options
| Key | Required | Default | Meaning |
|---|---|---|---|
version |
yes | — | Release version. A leading v is stripped. "self" uses the declaring package's own version — see below. |
repository |
yes* | — | owner/repo for GitHub releases. |
tag |
no | v{version} |
Release tag template. Use {version} for projects that tag without the v. |
url |
yes* | — | Full URL template; use instead of repository for S3, GitLab, self-hosted. |
asset |
no | {name}-v{version}-{target}.{ext} |
Published asset file name. |
archive |
no | tar.gz |
tar.gz, tar.xz, tar.bz2, tar, zip, or none. |
library |
yes** | — | Shared-library file name inside the archive. |
path |
no | lib/{target}/{library} |
Install path, relative to the declaring package. |
targets |
yes | — | Map of target triple to per-target settings (or a library-name string). |
checksums |
no | — | Map of target triple to expected sha256. |
env-prefix |
no | upper-cased library name | Prefix for the environment variables below. |
optional |
no | true |
When false, a download failure aborts composer install. |
bin |
no | — | Expose the tool as a vendor/bin command: true (named after the binary) or a command name. |
download |
no | eager |
lazy defers the download to first use instead of fetching at composer install. |
* One of repository or url is required.
** Not required when archive is none; the asset's own name is used.
Per-target settings (asset, library, archive, checksum, url) override
the library-wide value — real pipelines ship a .zip of foo.dll on Windows
and a .tar.gz of libfoo.so elsewhere.
Placeholders
Usable in asset, url, library, and path:
| Placeholder | Expands to |
|---|---|
{name} |
The library key (sasso) |
{version} |
The configured version |
{target} |
Target triple (aarch64-apple-darwin) |
{arch} |
Architecture (aarch64) |
{os} |
Rest of the triple (apple-darwin) |
{ext} |
Archive extension (tar.xz); the library extension when archive is none |
{libext} |
Shared-library extension for the target (dylib, so, dll) |
{binext} |
Executable extension for the target (.exe on Windows, empty elsewhere) |
{goos} / {goarch} |
Go-style names for the target (linux/darwin/windows, amd64/arm64) — matches goreleaser asset naming like tool_1.0_linux_amd64 |
{library} |
Resolved library file name (path only) |
{libext} and {binext} follow the target rather than the host, so cross-target
prefetching names files correctly. {binext} includes the dot, so
"library": "mytool{binext}" yields mytool.exe on Windows and mytool
elsewhere without a per-target override.
CLI tools on vendor/bin
A binary can be a command, not just a library. "bin" exposes it in Composer's
bin-dir, and "download": "lazy" defers the fetch to first use — together they
reproduce the pattern tools like frosh/shopmon-cli hand-write in a bin stub,
as pure configuration:
{
"name": "frosh/shopmon-cli",
"require": { "shyim/composer-binary-downloader": "^1.0" },
"extra": {
"binaries": {
"shopmon-cli": {
"version": "self",
"repository": "FriendsOfShopware/shopmon-cli",
"tag": "{version}",
"asset": "{name}_{version}_{goos}_{goarch}.{ext}",
"archive": "tar.gz",
"library": "shopmon-cli{binext}",
"path": "bin/{version}/{target}/shopmon-cli{binext}",
"bin": true,
"download": "lazy",
"targets": {
"x86_64-unknown-linux-gnu": {},
"aarch64-unknown-linux-gnu": {},
"x86_64-apple-darwin": {},
"aarch64-apple-darwin": {}
}
}
}
}
}
Any project requiring this package gets vendor/bin/shopmon-cli. Running it
downloads the release on first use (with a notice on stderr), then executes it
with stdin/stdout/stderr and the exit code passed straight through — via a true
exec where pcntl is available, a waiting child process otherwise.
The pieces:
"version": "self"— the binary version is the Composer package's own version. Tagging the wrapper repo releases both;extranever needs a version bump. A branch install (dev-main) has no tagged release, so it fails at parse time with a message saying exactly that."bin": true— generate a proxy named after the binary (or pass a string for a different command name, e.g."bin": "rg"for ripgrep). Generated proxies carry a marker; the plugin never overwrites or deletes a bin-dir file it did not generate, and removes its own when the declaration goes away."download": "lazy"— nothing fetched duringcomposer install; the proxy (orBinaries::install()) fetches on demand.composer binary:installand--targetprefetch it anyway — an image build asking explicitly should not be refused.{version}inpath— an upgrade fetches the new release instead of finding the old file at the same path and skipping it.{goos}/{goarch}— goreleaser asset naming from one template.
Downloaded files are chmod 0755, so the tool is runnable as fetched. PHP code
can call the same binary via Binaries::path('shopmon-cli').
For a package that must work even with plugins disabled, skip "bin" and ship
a two-line stub as your own bin entry — Composer core links those without any
plugin:
#!/usr/bin/env php <?php require __DIR__ . '/../../../autoload.php'; // adjust to taste \Shyim\BinaryDownloader\Bin::run('shopmon-cli');
See examples/shopmon-cli.json and examples/ripgrep.json for complete
files.
Supported target triples
Detection follows the PHP binary, not the CPU — an x86_64 PHP under Rosetta gets
an x86_64 library. musl is detected via the dynamic linker, so Alpine resolves to
-musl.
x86_64-apple-darwin aarch64-apple-darwin
x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
x86_64-unknown-linux-musl aarch64-unknown-linux-musl
x86_64-pc-windows-msvc
Any triple works as a key — these are just what host detection produces.
Commands
composer binary:install # all libraries, host platform composer binary:install sasso # one library composer binary:install -t all --force # every target, re-download composer binary:list # what is configured and what is on disk
binary:list is the diagnostic for a failing FFI::cdef(): it prints the exact
path that was expected and whether it exists.
--target prefetches a platform other than the build host, which is what image
builds need (an arm64 runner baking an x86_64 image). Libraries with no build for
a requested target are reported and skipped rather than failing the run.
Without the plugin
Composer never loads the root package as a plugin, and --no-plugins disables
it. The bundled script covers both, reading vendor/composer/installed.json
directly:
php vendor/bin/binary-download php vendor/bin/binary-download --list php vendor/bin/binary-download sasso --target=all
Wire it into a checkout of the declaring package with a script:
{ "scripts": { "post-install-cmd": "@php vendor/bin/binary-download" } }
Runtime API
Binaries is the API for consuming packages — what you call to get the path of a
downloaded binary, whether that means FFI::cdef() on a shared library or
exec() on a CLI tool. It reads a data file the plugin generates at
autoload-dump time (vendor/composer/installed-binaries.php), in the same spirit
as Composer\InstalledVersions: no composer.json parsing per lookup.
use Shyim\BinaryDownloader\Binaries; // a shared library $ffi = FFI::cdef($header, Binaries::path('sasso')); // or an executable exec(escapeshellarg(Binaries::path('ripgrep')) . ' --version', $output);
| Method | Behaviour |
|---|---|
path(string $name): string |
Resolved path. Never downloads. Throws if unusable. |
install(string $name): string |
Same, but downloads when missing. |
isInstalled(string $name): bool |
Never throws. For graceful degradation. |
status(string $name): LibraryStatus |
Full diagnostic snapshot; never throws for a configured library. |
names(): list<string> |
Every configured library name. |
path() deliberately does no network I/O — a web request should not stall on a
release host. Use install() where fetching is acceptable: a CLI tool, a warmup
command, a test bootstrap.
Resolution order is <PREFIX>_LIBRARY, then the installed path, then (for
install() only) a download.
Exceptions
All of them implement LibraryUnavailableExceptionInterface, so code that only
cares "usable or not" needs one catch:
use Shyim\BinaryDownloader\Exception\LibraryUnavailableExceptionInterface; try { $ffi = FFI::cdef($header, Binaries::path('sasso')); } catch (LibraryUnavailableExceptionInterface $e) { // $e->getMessage() states the cause and the fix return new PurePhpFallback(); }
| Exception | Meaning |
|---|---|
UnknownLibraryException |
No package declares that name. Lists what is configured. |
PlatformNotSupportedException |
No build for this platform. Lists the configured targets. |
LibraryNotInstalledException |
Configured, but not on disk. |
LibraryNotInstalledException::reason() returns a Reason enum so callers can
branch without matching message text:
Reason |
Cause |
|---|---|
Missing |
Not downloaded yet — usually composer install --no-plugins. |
Declined |
<PREFIX>_SKIP_DOWNLOAD or BINARY_DOWNLOADER_SKIP forbade it. |
DownloadFailed |
A download was attempted and failed; getPrevious() has the cause. |
ExplicitPathMissing |
<PREFIX>_LIBRARY names a file that is not there. |
use Shyim\BinaryDownloader\Exception\LibraryNotInstalledException; use Shyim\BinaryDownloader\Exception\Reason; try { $path = Binaries::path('sasso'); } catch (LibraryNotInstalledException $e) { // Respect a deliberate opt-out; retry only what is worth retrying. if ($e->reason() === Reason::Declined) { throw $e; } $path = Binaries::install('sasso'); }
Each message names the specific cause and the command that fixes it, e.g.
sasso is not installed at /app/vendor/shyim/sasso-ffi/bin/aarch64-apple-darwin/libsasso.dylib,
and downloading is disabled by SASSO_SKIP_DOWNLOAD.
Unset it and run `composer binary:install sasso`, or point SASSO_LIBRARY at a library
you built yourself.
Diagnostics
status() never throws for a configured library, so a health check can report a
broken install rather than handle exceptions:
$status = Binaries::status('sasso'); $status->installed; // bool $status->isSupported(); // false when no build exists for this platform $status->isOverridden(); // true when <PREFIX>_LIBRARY is in effect $status->target; // 'aarch64-apple-darwin', or null $status->path; // where it is, or would be $status->configuredTargets; // every triple the library publishes echo $status->describe(); // one-line summary
The generated data file
The plugin writes vendor/composer/installed-binaries.php on every autoload dump.
Paths are emitted relative to __DIR__, not baked in absolutely, so a vendor/
directory built in CI and copied into an image (COPY vendor/) still resolves —
the same approach Composer uses for installed.php:
return [ 'sasso' => [ 'package' => 'shyim/sasso-ffi', 'version' => '0.8.2', 'env_prefix' => 'SASSO', 'optional' => true, 'targets' => [ 'aarch64-apple-darwin' => [ 'path' => __DIR__ . '/../shyim/sasso-ffi/bin/aarch64-apple-darwin/libsasso.dylib', 'library' => 'libsasso.dylib', 'url' => 'https://github.com/shyim/sasso/releases/download/v0.8.2/...', ], ], ], ];
Output is sorted and stable, so an unchanged installation produces no diff. Do not commit or edit it; re-dump the autoloader instead.
If the file is absent — the autoloader was dumped with --no-plugins, or you are
in a checkout of the declaring package — Binaries falls back to reading
composer.json directly. Same results, just slower.
Lower-level access
LibraryLocator exposes the parsed configuration (definition(),
definitions()) for tooling that needs URLs, checksums, or every target rather
than one resolved path.
Environment variables
Per binary, prefixed by env-prefix (default: the upper-cased binary name — so
a binary named sasso gets SASSO_*):
| Variable | Effect |
|---|---|
SASSO_LIBRARY |
Use this file instead; skips downloading entirely. Works for executables too. |
SASSO_TARGET |
Fetch this target instead of the detected one. |
SASSO_SKIP_DOWNLOAD |
Do not download. Fails at runtime if the file is missing. |
SASSO_DOWNLOAD_BASE_URL |
Fetch assets from this base URL instead. |
BINARY_DOWNLOADER_SKIP |
Global: skip every download this run. |
Setting *_DOWNLOAD_BASE_URL drops pinned checksums, since a mirror may
legitimately repackage an asset. See below.
Root overrides
A project can retarget a dependency's library — an internal mirror, a pinned version — without forking the package that declared it:
{
"extra": {
"binaries-overrides": {
"sasso": { "url": "https://artifacts.internal/ffi/{name}-{version}-{target}.tar.xz" },
"shyim/sasso-ffi:sasso": { "version": "0.9.0" }
}
}
}
Keys are a library name or vendor/package:library, the latter winning on
conflict. An override naming no installed library is an error, not a silent
no-op. Overrides are re-validated, so they cannot introduce a shape the parser
would reject.
Checksums and overrides. A pinned digest describes one exact asset. An
override that moves the download (url, version, repository, asset,
archive) drops inherited checksums, because enforcing them against different
bytes would turn a working mirror into a hard failure. Supply checksums in the
override to keep verification:
{
"extra": {
"binaries-overrides": {
"sasso": {
"url": "https://artifacts.internal/ffi/{name}-{version}-{target}.tar.xz",
"checksums": { "aarch64-apple-darwin": "5f5110..." }
}
}
}
}
An override that only changes path keeps the checksums, since the bytes are
unchanged.
Security: allow-binaries
Downloading — and, through bin proxies, executing — code from a URL chosen by a
transitive dependency is the same decision Composer gates with allow-plugins.
This plugin gates it the same way: nothing a dependency declares is fetched
until the root project allows the package in extra.allow-binaries.
{
"extra": {
"allow-binaries": {
"shyim/sasso-ffi": true,
"frosh/*": true,
"acme/sketchy-tool": false
}
}
}
Keys are package names or globs; the first matching entry wins, in declaration
order. The root project's own extra.binaries are always trusted — that file is
yours.
On an interactive composer install, each unreviewed package is prompted for
once, Composer-style, and the answer is written to composer.json:
binary-downloader: acme/mytool-bin declares downloadable binaries:
- mytool 2.5.0 from https://github.com/acme/mytool (exposed as vendor/bin/mytool)
Do you trust "acme/mytool-bin" to download and execute these binaries?
(writes "allow-binaries" to composer.json) [y,n,d,?]
y allows and persists, n denies and persists (no further prompts), d
skips for this run only. Non-interactively (CI) nothing is downloaded and the
warning carries the exact snippet to commit.
The gate holds on every path, not just the install hook:
- the installer skips the package (fatally, if it set
optional: false— it cannot both work and be refused); - no
vendor/binproxy is generated, and a previously generated one is removed when a package becomes denied; - the runtime refuses too:
Binaries::path()/install()— and therefore a lazy first-use download — throwNotAllowedException, so composer.json is the only place permission can come from. No CLI flag or env variable bypasses it.
An explicitly false package is skipped silently; an unlisted one warns. Both
show up in composer binary:list with the entry that would enable them.
Failure behaviour
Libraries are optional by default: a failure warns and composer install
still succeeds. That keeps installs working on an unsupported platform or behind
a blocked network, where the package may only be needed for autoloading, and the
runtime can still fetch on first use. Set optional: false when the package
cannot function without the binary.
Malformed configuration always fails loudly — it is the package author's bug, and ignoring it would hide a library that should have been installed.
binary:install exits non-zero on any failure, optional or not: an explicit
request wants the binary, not a best effort.
Verification
Downloads are checked against checksums when present, and an archive is
unpacked to a temporary directory and moved into place only once complete — an
interrupted or mismatched download never leaves a partial library where the
loader would find it.
Checksums are optional but recommended. Without one, a compromised release host or mirror is trusted implicitly.
License
MIT