phpdot / bun
A PHP wrapper around the Bun binary (oven-sh/bun, MIT licensed): manages a hidden Bun runtime and exposes its CLI as console commands.
Requires
- php: >=8.4
- nyholm/psr7: ^1.8
- phpdot/console: ^2.0
- psr/container: ^2.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- symfony/console: ^8.0
- symfony/http-client: ^8.0
- symfony/process: ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpdot/container: ^1.7
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- ext-pcntl: Forwards SIGINT/SIGTERM to long-lived child processes (bun run dev server, build --watch) for clean shutdown.
- phpdot/config: Populates BunConfig from config/bun.php (pinnedVersion, registryUrl, runtimeDir, workingDir) when used with phpdot/package.
- phpdot/container: Autowires the #[Singleton]/#[Binds] services and the #[Config('bun')] DTO (the attributes stay inert until reflected, so standalone consumers don't need it installed).
- phpdot/package: Auto-discovers bun's services and config by scanning the #[Singleton]/#[Binds]/#[Config] attributes at composer install — zero manual wiring.
README
A PHP wrapper around the Bun toolkit for the PHPdot ecosystem. It manages a hidden, version-pinned Bun binary — downloaded on first use, integrity-verified, cached per machine — and exposes Bun's package manager, script runner and bundler as console commands, an injectable service, and composable task pipelines.
This is a thin wrapper: it doesn't reimplement a bundler, a package manager or a runtime — it
resolves the right Bun binary for your platform and delegates to it, streaming output through. Every
subprocess goes through one ProcessRunnerInterface seam and every HTTP call through PSR-18; the rest
is plain PHPdot code.
phpdot/bun wraps Bun (oven-sh/bun), which is MIT licensed. The binary is downloaded per machine from the npm registry, never redistributed inside this package.
Contents
- Install · Why this package · Quick start
- API reference:
Bun·BuildSpec·BuildOptions·Manifest· Tasks/Flow ·BunConfig· Registry · Process · Runtime ·HttpClient· Exceptions - Console commands · Framework integration · Architecture · How the binary resolves · Development
Install
composer require phpdot/bun
| Requirement | Version | Notes |
|---|---|---|
| PHP | >= 8.4 | |
| ext-curl | * | registry access + binary download (via symfony/http-client) |
| phpdot/console | ^2.0 | command base class + #[AsCommand] discovery |
| symfony/console · http-client · process | ^8.0 | commands · downloads · subprocesses |
| PSR-18 client + PSR-17 factories | — | bundled by default: symfony/http-client + nyholm/psr7 |
| ext-pcntl | suggested | forwards SIGINT/SIGTERM to long-lived children (run, build --watch) |
Add the runtime directory to .gitignore — the binary is per-machine, never committed:
/.phpdot/
On Alpine/musl, apk add libstdc++ — the downloaded binary links against it. Glibc distros and
macOS/Windows already provide it.
Why this package
- Hidden, reproducible runtime. No global Bun install, no Node. The binary is pinned to an exact
version, downloaded on first use, sha512-verified, and cached in
.phpdot/runtime/. - Self-healing platform resolution. Detects OS · arch · libc, picks the matching
@oven/bun-*package, and falls back to the-baselinex64 variant if the standard build won't run (no AVX2). - Coroutine-friendly, CLI-first. Plain blocking subprocess I/O that Swoole's runtime hooks make
non-blocking. Long-lived processes (
run,--watch) stream live and exit cleanly on a signal. - One seam, well-fenced. All subprocess work is behind
ProcessRunnerInterface; all HTTP behind PSR-18. Swap or mock either without touching the rest. - Immutable everywhere.
BunConfig,BuildSpec,BuildOptions,Flowand the result objects arereadonly; withers clone, so a configured spec is a safe reusable template. - Strict.
declare(strict_types=1)throughout, PHPStan at max with strict rules, zero ignored errors.
Quick start
use PHPdot\Bun\Bun; final class Assets { public function __construct(private readonly Bun $bun) {} public function compile(): int { $this->bun->install(['lodash']); // bun add lodash return $this->bun->build('resources/js/app.ts'); // minify + split + hash + manifest } }
dot bun:install lodash dot bun:build resources/js/app.ts --out-dir=public/build --minify --splitting --hashed-names
API reference
Every public method below is documented as signature → parameters → returns → throws → behavior.
Each call on the Bun service resolves the binary (downloading it on first use), streams Bun's output
through to the console, and returns Bun's exit code.
The Bun service
#[Singleton] — the package's whole public surface. Inject it; there is no static entry point or facade.
install(array $packages, bool $dev = false, ?string $cwd = null): int
Runs bun add [--dev] <pkg…>. Auto-creates package.json + lockfile if absent.
$packageslist<string>— package names (optionally version-tagged, e.g.lodash@4).$dev—trueadds todevDependencies(--dev).$cwd— working dir; falls back toBunConfig::$workingDir, else the process cwd.- Returns Bun's exit code (
0on success).
$bun->install(['axios']); $bun->install(['typescript', 'sass'], dev: true);
remove(array $packages, ?string $cwd = null): int
Runs bun remove <pkg…>. Same $cwd resolution as install().
view(string $package, ?string $cwd = null): int
Runs bun pm view <package> — prints registry metadata for an installed/known package.
run(string $script, array $args = [], ?string $cwd = null): int
Runs a package.json script: bun run <script> [args…]. May be long-lived (e.g. a dev server) —
output streams live and SIGINT/SIGTERM are forwarded to the child (needs ext-pcntl).
$argslist<string>— forwarded verbatim after the script name.
$bun->run('dev', ['--port', '3000']); // bun run dev --port 3000
x(string $tool, array $args = [], ?string $cwd = null): int
Runs an installed CLI tool: bun x <tool> [args…]. The supported path for build-step tools
(prettier, tailwindcss, an obfuscator…). Install with install(), then run here.
$bun->x('prettier', ['src', '--write']);
build(string|array $entrypoints, ?callable $configure = null, ?string $cwd = null): int
Bundles with a production preset — --minify, --splitting, content-hashed names, --target=browser
— and distils a deploy-safe manifest.json into the output dir.
$entrypointsstring|list<string>— one entry path or several.$configure(callable(BuildSpec): BuildSpec)|null— receives the presetBuildSpecto adjust before building (seeBuildSpec).- Returns Bun's exit code — or a non-zero code if the build succeeded but its manifest could not
be written (fail-loud; see
Manifest). - Behavior — uses a private throwaway metafile under
.phpdot/build/, distils it to<outDir>/manifest.json, then deletes it. DefaultoutDirispublic/build.
$bun->build('resources/js/app.ts'); $bun->build('resources/js/app.ts', fn (BuildSpec $b) => $b->outDir('public/dist')->sourcemap());
watch(string|array $entrypoints, ?callable $configure = null, ?string $cwd = null): int
Dev counterpart of build(): no minify, sourcemaps on, splitting on, --watch. Long-lived —
rebuilds on change, streams output, exits cleanly on signal. Does not distil a manifest (it's a
long-running build, not a one-shot).
buildWith(array $entrypoints, BuildOptions $options, ?string $cwd = null): int
The low-level build — runs bun build with an explicit, fully-formed BuildOptions
(this is what the bun:build command uses). For any one-shot build with an output dir it also
distils the manifest (injecting a throwaway metafile when the caller didn't ask for one) and returns
non-zero if that manifest can't be written. --watch options skip the distillation.
Build configuration — BuildSpec
Fluent, immutable configuration: every wither returns a new clone, so a configured spec is a safe
reusable template. toOptions() produces the BuildOptions value object. You normally
receive one inside the build()/watch() closure rather than constructing it.
| Wither | Purpose |
|---|---|
outDir(string $dir) |
Output directory (--outdir). |
outFile(string $file) |
Single output file (--outfile); mutually exclusive with outDir. |
target(string $target) |
Execution target — browser | bun | node. |
format(string $format) |
Module format — esm | cjs | iife. (Splitting needs esm.) |
minify(bool = true) / noMinify() |
All minification on/off. |
minifySyntax(bool = true) |
Minify syntax only. |
minifyWhitespace(bool = true) |
Minify whitespace only. |
minifyIdentifiers(bool = true) |
Minify identifiers only. |
splitting(bool = true) / noSplitting() |
Code splitting (requires format('esm')). |
sourcemap(string = 'linked') |
linked | inline | external | none. |
hashedNames(bool = true) / noHashedNames() |
Content-hash output names → [dir]/[name]-[hash].[ext] (and hashes chunks too unless overridden). |
chunkNaming(string $pattern) |
Override chunk filename pattern, e.g. [name]-[hash].[ext]. |
assetNaming(string $pattern) |
Asset filename pattern. |
metafile(string $path) |
Write Bun's metafile to a path (kept; not treated as throwaway). |
define(string $keyValue) |
Append a --define K=V (repeatable). |
external(string $package) |
Append an --external <pkg> (repeatable). |
banner(string) / footer(string) |
Prepend/append text to the output. |
drop(string $identifier) |
Append a --drop <id> e.g. console (repeatable). |
watch(bool = true) |
Rebuild on change (long-lived). |
toOptions(): BuildOptions |
Materialize the spec. |
$bun->build('resources/js/app.ts', fn (BuildSpec $b) => $b ->outDir('public/dist') ->format('esm')->splitting() ->sourcemap('linked') ->define('process.env.NODE_ENV="production"') ->external('react') ->drop('console'));
BuildOptions
final readonly value object — the materialized build, mapped 1:1 to Bun's CLI. Construct it directly
for buildWith(), or get one from BuildSpec::toOptions().
new BuildOptions( outDir: null, outFile: null, target: null, format: null, minify: false, minifySyntax: false, minifyWhitespace: false, minifyIdentifiers: false, splitting: false, sourcemap: null, hashedNames: false, chunkNaming: null, assetNaming: null, metafile: null, define: [], external: [], banner: null, footer: null, drop: [], watch: false, );
toArguments(): list<string>
The bun build flags (excluding entrypoints) in a stable order — value flags use --flag=value;
hashedNames is emitted as --entry-naming=[dir]/[name]-[hash].[ext].
Asset manifest — Manifest
Resolves a source entrypoint to its content-hashed output URL, from the trimmed manifest.json that
build() produced. Coroutine-safe (per-instance cache; no static state).
__construct(string $metafilePath, string $publicPrefix = '/build')
$metafilePath— path to the deployedmanifest.json.$publicPrefix— URL prefix prepended to each resolved file (default/build).
js(string $sourceEntry): string · css(string $sourceEntry): string · asset(string $sourceEntry, string $ext): string
Resolve the source entry to its hashed URL for that extension. A single entry that imports CSS produces
both a .js and a .css with the same entryPoint, disambiguated by js()/css(); asset() takes
any extension. Subdirectories in the output are preserved (a nested entry resolves to a nested URL).
- Throws
ManifestEntryNotFoundException(unknown entry/extension),ManifestNotReadableException(manifest missing/unreadable).
$assets = new Manifest('public/build/manifest.json', '/build'); echo '<script type="module" src="' . $assets->js('resources/js/app.ts') . '"></script>'; echo '<link rel="stylesheet" href="' . $assets->css('resources/js/app.ts') . '">';
static compile(string $metafilePath, string $manifestPath): bool
Distils Bun's verbose metafile into the trimmed, deploy-safe manifest.json: keeps outputs +
entryPoint only, drops the inputs section (which carries absolute build-machine paths). Returns
true on success; false if the metafile is unreadable, malformed, or the write fails. Called for you
by build()/buildWith() — you rarely call it directly.
Security: the manifest holds relative paths only — safe to commit, deploy and serve. The verbose metafile is a throwaway intermediate, never web-served.
Task pipelines — Tasks · Task · Flow
Define named, reusable build steps and sequence them. A handler receives the Bun service and returns
an exit code; a Flow runs steps fail-fast (once one fails, the rest are reported skipped) and is
immutable, so it's coroutine-safe.
Tasks
__construct(Bun $bun)task(string $name, callable $handler): Task— register a reusable task.$handleriscallable(Bun): int. Returns theTaskto reference or sequence.get(string $name): Task— fetch by name; throwsUnknownTaskExceptionif undefined.run(?string $name = null): FlowResult— run one task (defaults to the first registered) as a one-step flow.
Task
public readonly string $namethen(Task $next): Flow— sequence this task before$next, producing aFlow(type-safe: takes aTask, not a closure).
Flow
__construct(list<Task> $steps)— linear by construction, so cycles are impossible.then(Task $next): Flow— append a step, returning a new Flow (original unchanged).run(Bun $bun): FlowResult— execute fail-fast.stepNames(): list<string>
FlowResult · StepResult
FlowResult (readonly): successful(): bool, firstFailure(): ?StepResult, exitCode(): int, and
public array $steps (list<StepResult>). StepResult (readonly): $task, $executed
(false = skipped after an earlier failure), $exitCode (-1 sentinel when skipped), successful(): bool.
$tasks = new Tasks($bun); $install = $tasks->task('install', fn (Bun $b) => $b->install(['lodash'])); $build = $tasks->task('build', fn (Bun $b) => $b->build('resources/js/app.ts')); $result = $install->then($build)->run($bun); if (! $result->successful()) { exit($result->exitCode()); }
Configuration — BunConfig
#[Config('bun')] final readonly — hydrated from config/bun.php by phpdot/config; omitted keys fall
back to the defaults below. Edit config/bun.php to change these (there is no env override).
| Property | Type | Default | Meaning |
|---|---|---|---|
pinnedVersion |
string |
1.3.14 |
Exact Bun version — reproducible, never "latest". |
registryUrl |
string |
https://registry.npmjs.org |
Used for search, metadata and download — point at a mirror in one place. |
runtimeDir |
string |
.phpdot/runtime |
Where the binary is cached, relative to the project root (absolute paths honored). |
workingDir |
?string |
null |
Default cwd for package-context commands (install/remove/view/run/x) so package.json + node_modules land there. build/watch are unaffected. |
Registry — NpmRegistryClient · SearchResult
#[Singleton] — a thin PSR-18 client over the npm registry HTTP API. Backs binary download (metadata)
and discovery (search); Bun has no search command, so this hits the registry directly and never needs
the binary. Honors BunConfig::$registryUrl.
registryUrl(): string— the configured base URL (lets dependent requests stay on the same host).packageDocument(string $package): array— the full registry document (all versions + dist metadata). ThrowsRegistryException.search(string $term, int $limit = 20): list<SearchResult>— registry search. ThrowsRegistryException.
SearchResult (final readonly): string $name, string $version, string $description,
float $score.
Process layer
The single chokepoint for invoking executables — binary-agnostic, so callers never depend on the
concrete runtime. Under Swoole's coroutine hooks, symfony/process' proc_open becomes non-blocking
transparently.
ProcessRunnerInterface
run(string $executable, array $args = [], ?string $cwd = null): ProcessResult— run to completion and capture output (one-shot, parseable).passthrough(string $executable, array $args = [], ?string $cwd = null): int— stream stdout/stderr live and forwardSIGINT/SIGTERMto the child (long-lived processes). Returns the exit code.
BunProcess
#[Singleton] #[Binds(ProcessRunnerInterface::class)] — the default implementation over
symfony/process. Provide your own binding to swap it.
ProcessResult
final readonly: int $exitCode, string $stdout, string $stderr; successful(): bool,
output(): string (combined stdout + stderr — some tools, e.g. ldd, report on stderr).
Runtime internals
You rarely call these directly — they back Bun — but they're public and overridable.
BinaryResolver
#[Singleton] — resolve(): string returns an absolute path to a working Bun binary matching the
pinned version, downloading + verifying on first use. Probes the binary with --version; if the x64
build won't execute (no AVX2 → SIGILL), retries the -baseline variant. Throws
BinaryDownloadException, UnsupportedPlatformException.
BinaryDownloader
#[Singleton] — download(string $npmPackage, string $version, string $destination, string $binaryFilename): void.
Streams the npm tarball to a temp file, sha512-verifies it, streams the single binary entry out of
the gzipped tar (never loading ~86 MB into memory), and chmods it. Rejects non-HTTP(S) tarball URLs
and refuses to downgrade from an HTTPS registry. Throws BinaryDownloadException.
PlatformDetector · Platform
PlatformDetector #[Singleton] — detect(): Platform resolves OS family, CPU architecture and
(on Linux) libc flavour. Platform (final readonly: $os, $arch, $libc) maps the host to the
@oven/bun-* package via npmPackage(): string, npmPackageBaseline(): ?string (null off x64),
binaryFilename(): string (bun / bun.exe). Throws UnsupportedPlatformException.
RuntimeLock
#[Singleton] — withLock(string $lockFile, callable $work): mixed runs $work while holding an
exclusive flock, so concurrent first-use downloads don't race. (flock isn't transformed by Swoole's
hooks, so it briefly blocks the coroutine — only during the rare first download.)
HTTP — HttpClient
#[Singleton] #[Binds(ClientInterface::class)] #[Binds(RequestFactoryInterface::class)] — the
default PSR-18 client + PSR-17 factory (symfony/http-client + nyholm/psr7), with no overall time cap
(binaries are large) but a 30 s idle timeout so a stalled transfer fails. Bind your own PSR-18
client to override.
Exceptions
Every exception implements the marker interface BunException (extends \Throwable) — catch that
to trap anything from the package.
| Exception | Thrown when |
|---|---|
Exception\BinaryDownloadException |
Download, integrity, extraction, or baseline-fallback failure. |
Exception\RegistryException |
Registry request failed or returned malformed JSON. |
Exception\UnsupportedPlatformException |
OS/architecture Bun doesn't ship. |
Manifest\ManifestNotReadableException |
manifest.json missing/unreadable at resolve time. |
Manifest\ManifestEntryNotFoundException |
A source entry (or extension) isn't in the manifest. |
Task\UnknownTaskException |
Tasks::get()/run() for an undefined task name. |
Console commands
Discovered automatically via #[AsCommand].
| Command | Maps to | Purpose |
|---|---|---|
bun:search <term> [--limit=20] |
npm registry | Search packages — never needs the binary |
bun:install <pkg…> [--dev] |
bun add |
Install deps |
bun:remove <pkg…> |
bun remove |
Remove deps |
bun:view <pkg> |
bun pm view |
Show package metadata |
bun:run <script> [-- …] |
bun run |
Run a package.json script (may be long-lived) |
bun:x <tool> [-- …] |
bun x |
Run any installed CLI tool |
bun:build <entry…> [flags] |
bun build |
Bundle (full flag set below) |
bun:run dev -- --port 3000 # flags for the script go after -- bun:x prettier src -- --write bun:build src/index.ts --out-dir=public/build --minify --splitting --hashed-names bun:build src/index.ts --watch # long-lived; exits cleanly on signal
bun:build flags: --out-dir · --out-file · --target · --format · --minify
(--minify-syntax/-whitespace/-identifiers) · --splitting · --sourcemap · --hashed-names ·
--chunk-naming · --asset-naming · --metafile · --define K=V (repeatable) · --external
(repeatable) · --banner · --footer · --drop (repeatable) · --watch. A build with an out-dir also
writes manifest.json and cleans up the metafile.
Passthrough commands (
bun:run,bun:x) forward arguments verbatim — put script/tool flags after--.
Framework integration
Wired entirely through phpdot/container attributes — and because those same attributes are what phpdot/package scans and phpdot/config populates, all three integrate with zero manual registration. None are hard dependencies (attributes are inert until reflected).
| Package | How bun integrates | Status |
|---|---|---|
| phpdot/container | #[Singleton] on every service; HttpClient #[Binds] PSR-18/PSR-17; BunProcess #[Binds] ProcessRunnerInterface; BunConfig carries #[Config('bun')]. |
✅ full |
| phpdot/package | Its scanner discovers any package requiring phpdot/container (bun does, in require-dev) and reads those attributes — services + config land in the generated definitions. |
✅ full |
| phpdot/config | #[Config('bun')] lets phpdot/config hydrate BunConfig from config/bun.php; missing keys fall back to the DTO defaults. |
✅ full |
// config/bun.php (scaffolded into your app, then yours to edit) return [ 'pinnedVersion' => '1.3.14', 'registryUrl' => 'https://registry.npmjs.org', 'runtimeDir' => '.phpdot/runtime', 'workingDir' => 'resources', ];
Architecture
src/
├── Bun.php #[Singleton] — inject this; the whole public surface
├── Command/ symfony/console commands (#[AsCommand]) — bun:search/install/remove/view/run/x/build
├── Config/BunConfig.php #[Config('bun')] immutable settings
├── Build/
│ ├── BuildSpec.php fluent, immutable build configuration
│ └── BuildOptions.php immutable VO → bun build flags (toArguments())
├── Manifest/Manifest.php distils the metafile → deploy-safe manifest.json + URL resolver
├── Task/ Tasks · Task · Flow · FlowResult · StepResult
├── Process/
│ ├── ProcessRunnerInterface.php the one subprocess seam (run + passthrough)
│ ├── BunProcess.php #[Binds] — symfony/process runner, signal forwarding
│ └── ProcessResult.php
├── Registry/
│ ├── NpmRegistryClient.php #[Singleton] — registry search + package metadata
│ └── SearchResult.php
├── Runtime/
│ ├── PlatformDetector.php OS · arch · libc
│ ├── Platform.php → @oven/bun-* npm package name (+ baseline)
│ ├── BinaryResolver.php resolve · probe · baseline fallback
│ ├── BinaryDownloader.php stream download · sha512 verify · tar extract
│ └── RuntimeLock.php cross-process first-use download lock
├── Http/HttpClient.php #[Binds] PSR-18 + PSR-17 (symfony + nyholm), idle-timeout
└── Exception/ BunException + Binary/Registry/Platform
How the binary resolves
PlatformDetectorresolves host OS, architecture, and (on Linux) libc flavour.BinaryResolverchecks the cached binary; if missing or version-mismatched it takes a file lock and downloads the matching@oven/bun-*npm package.BinaryDownloaderstreams the tarball, verifies its sha512, and streams the single binary entry out of the gzipped tar — never loading the ~86 MB binary into memory.- The binary is probed with
--version; if it doesn't run (e.g. no AVX2), the resolver retries the-baselinevariant. - Concurrent first-use downloads are guarded by a cross-process file lock.
Development
composer test # PHPUnit (Unit + Integration; integration drives a real Bun binary) composer analyse # PHPStan (max level, strict rules) composer cs-check # php-cs-fixer dry run composer check # all three
License
MIT — see LICENSE. Bun itself is MIT licensed; redistribution via download preserves that notice.