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.

Maintainers

Package info

github.com/phpdot/bun

pkg:composer/phpdot/bun

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-06-20 17:18 UTC

This package is auto-updated.

Last update: 2026-06-20 17:19:00 UTC


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

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 -baseline x64 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, Flow and the result objects are readonly; 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.

  • $packages list<string> — package names (optionally version-tagged, e.g. lodash@4).
  • $devtrue adds to devDependencies (--dev).
  • $cwd — working dir; falls back to BunConfig::$workingDir, else the process cwd.
  • Returns Bun's exit code (0 on 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).

  • $args list<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.

  • $entrypoints string|list<string> — one entry path or several.
  • $configure (callable(BuildSpec): BuildSpec)|null — receives the preset BuildSpec to adjust before building (see BuildSpec).
  • 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. Default outDir is public/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 deployed manifest.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. $handler is callable(Bun): int. Returns the Task to reference or sequence.
  • get(string $name): Task — fetch by name; throws UnknownTaskException if undefined.
  • run(?string $name = null): FlowResult — run one task (defaults to the first registered) as a one-step flow.

Task

  • public readonly string $name
  • then(Task $next): Flow — sequence this task before $next, producing a Flow (type-safe: takes a Task, 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). Throws RegistryException.
  • search(string $term, int $limit = 20): list<SearchResult> — registry search. Throws RegistryException.

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 forward SIGINT/SIGTERM to 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

  1. PlatformDetector resolves host OS, architecture, and (on Linux) libc flavour.
  2. BinaryResolver checks the cached binary; if missing or version-mismatched it takes a file lock and downloads the matching @oven/bun-* npm package.
  3. BinaryDownloader streams the tarball, verifies its sha512, and streams the single binary entry out of the gzipped tar — never loading the ~86 MB binary into memory.
  4. The binary is probed with --version; if it doesn't run (e.g. no AVX2), the resolver retries the -baseline variant.
  5. 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.