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.5
- ext-mbstring: *
- nyholm/psr7: ^1.8
- phpdot/console: ^0.3
- 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: ^0.3
- phpdot/package: ^0.3
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
- psr/container: ^2.0
Suggests
- ext-curl: Lets symfony/http-client use its curl transport for Bun runtime downloads; without it the native-stream transport is used.
- 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 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: Discovers BunInstaller at composer time — the hook that fills config/bun.php directory keys with portable absolute paths. Without it, set the three directory keys yourself.
Provides
None
Conflicts
None
Replaces
None
README
The asset pipeline and JavaScript bridge for the PHPdot ecosystem. A version-pinned
Bun binary is installed once into your project, invisibly; the npm
ecosystem is reached through the console you already use; and one command turns the TypeScript and
CSS you author in resources/ into hashed, manifest-tracked assets in public/build. Nothing
JavaScript ever lands in the project root.
Table of Contents
Requirements
| Requirement | Constraint |
|---|---|
| PHP | >= 8.5 |
ext-mbstring |
* |
nyholm/psr7 |
^1.8 |
phpdot/console |
^0.3 |
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 |
phpdot/package and phpdot/container are require-dev + suggest entries, phpdot/config a
suggest entry: in a PHPdot application they scaffold config/bun.php, fill its directories and
wire the services automatically; a plain PHP project constructs BunConfig by hand.
ext-pcntl is suggested — it forwards SIGINT/SIGTERM to long-lived children (run, --watch).
ext-curl is suggested — symfony/http-client uses its curl transport for runtime downloads when present.
Installation
composer require phpdot/bun
In a PHPdot application that is the whole install: config/bun.php is generated, its three
directories are filled with portable absolute paths, and the Bun binary downloads itself on first use.
No Node, no nvm, no global tooling.
Usage
Where things live
Two configured directories, plus the JavaScript yard derived from one of them — all absolute:
| directory | default | holds |
|---|---|---|
resourcesDir |
resources |
your sources — TypeScript, CSS, and the build recipe build.php; nothing is ever generated here |
outputDir |
public/build |
built js/, css/, assets/ and manifest.json; wiped on every build |
| home (derived) | resources/.bun |
everything JavaScript, never configured: the Bun binary (runtime/), package.json, bun.lock, node_modules/, tsconfig.json, build intermediates (build/) |
The JavaScript manifest is committed; the heavy state is not. Add this to your .gitignore:
resources/.bun/* !resources/.bun/package.json !resources/.bun/bun.lock !resources/.bun/tsconfig.json
Everything else under resources/.bun is developer-machine state. A fresh clone is just
bun:resources:build: the binary downloads from the pin, bun install restores node_modules
from the committed lockfile, and the recipe runs.
Console commands
php dot bun:install typescript @tailwindcss/cli --dev # bun add — lands in resources/.bun php dot bun:install # no args — install from the committed lockfile php dot bun:remove lodash php dot bun:search chart # npm registry search, no binary needed php dot bun:view react php dot bun:run dev # a package.json script php dot bun:x prettier -- --write . # any installed CLI tool php dot bun:resources:build # build the assets php dot bun:resources:build --watch # rebuild on change — Ctrl+C to stop
bun:build exposes bun's raw bundler flags for one-off builds; the pipeline above is the everyday path.
Building assets: TypeScript + Tailwind CSS
1. Install the tooling — it lands in resources/.bun, never in the project root:
php dot bun:install typescript @tailwindcss/cli --dev
2. Author your sources in resources/:
// resources/platform.ts import '@build/app.build.css'; // the compiled stylesheet rides this entry import { greet } from './greeting'; document.body.className = 'p-4'; console.log(greet('world'));
/* resources/app.css */ @source "../resources"; /* tailwind scans your sources for class names */ .brand { color: red; }
3. Write the recipe — resources/build.php, your file. It declares what to build; the pipeline
knows how:
<?php declare(strict_types=1); use PHPdot\Bun\Resources\Recipe; return static function (Recipe $recipe): void { $root = dirname(__DIR__); $bridge = $recipe->bridge('app.css', "@import \"tailwindcss\";\n@import \"{$root}/resources/app.css\";\n"); $recipe->bunx( '@tailwindcss/cli', ['-i', $bridge, '-o', $recipe->buildDir() . '/app.build.css'], watch: ['--watch'], ); $recipe->entries($root . '/resources/platform.ts'); };
The bridge() line exists because tailwind resolves @import "tailwindcss" from the input file's own
directory: a bridge inside resources/.bun/build sits beside node_modules, names the package, and pulls
your stylesheet in by path. Your CSS never names a package. Steps run in declaration order as one
fail-fast flow — the first failing step fails the build and nothing after it runs. bunx() runs any
installed tool from resources/.bun — tailwind, postcss, sass; run() runs one of your own scripts —
an icon extractor, a code generator:
$extractor = $recipe->bridge( 'extract-icons.mjs', (string) file_get_contents($root . '/resources/icons/extract-icons.mjs'), ); $recipe->run($extractor, ['-i', $root . '/resources/icons/svg', '-o', $recipe->buildDir() . '/icons']);
A script that imports npm packages must live under home — stage it with bridge() as above — because
from elsewhere bun silently resolves its bare imports from the public registry; a script using only
Node builtins can stay in resources/. The watch: argument is one declaration for both modes: those
extra arguments are added only under --watch, where the step keeps running as the tool's own watcher.
A build never sees them, so a watch flag can never hang a one-shot build. For a watcher with no build
phase at all — a dev server, a codegen loop — use $recipe->bunxWatch('some-tool', ['serve']) or
$recipe->runWatch($script, ['serve']).
4. Build:
php dot bun:resources:build
The pipeline wipes public/build, installs from the committed lockfile when node_modules is
missing, runs your steps — installed tools and your own scripts, each verified, fail-fast —
bundles the entries (minified, split,
content-hashed), writes manifest.json, and prepends @charset "UTF-8" to every built stylesheet.
With --watch, entry names stay stable so the manifest remains valid all session, and any step with
watch: arguments keeps running as a side process beside bun's own watcher.
Bare imports such as import { nanoid } from 'nanoid' resolve through resources/.bun/tsconfig.json,
which the first build writes with a paths mapping to node_modules and the @build/* alias. After
that the file is yours — add targets, libs, strictness.
Using the built assets in templates
The manifest keys every output by the authored entry, relative to resources/. The Twig extension
is your application's, and it is not auto-discovered: phpdot/template discovers extensions
only from classes inside installed packages, never from application code. Write the few lines, then
register the extension yourself before the first render — rebind View::class in your application's
DI, fetch the shared environment, and addExtension() behind a hasExtension() guard:
use PHPdot\Bun\Resources\AssetPipeline; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; final class Assets extends AbstractExtension { public function __construct(private readonly AssetPipeline $pipeline) {} public function getFunctions(): array { return [ new TwigFunction('js', fn (string $entry): string => $this->pipeline->manifest()->js($entry)), new TwigFunction('css', fn (string $entry): string => $this->pipeline->manifest()->css($entry)), ]; } }
<script type="module" src="{{ js('platform.ts') }}"></script> <link rel="stylesheet" href="{{ css('platform.ts') }}">
→ /build/js/platform-297bcrg3.js and /build/css/platform-dzwezbt3.css. Set
'baseUrl' => 'https://cdn.example.com/build' and the same calls emit CDN URLs; the hashes are the
cache-busting.
The Bun service
Everything the commands do is available as an injected service:
use PHPdot\Bun\Bun; $bun->install(['react'], dev: false); $bun->x('prettier', ['--write', '.']); $bun->build(['/abs/resources/platform.ts'], fn (BuildSpec $b) => $b->outDir('/abs/public/build')); $bun->binary(); // the resolved, verified binary path
Every method returns bun's exit code, output streamed through.
Configuration
config/bun.php, generated once and yours thereafter:
return [ 'pinnedVersion' => '1.4.0', // never "latest" 'registryUrl' => 'https://registry.npmjs.org', // a mirror is this one setting 'resourcesDir' => dirname(__DIR__) . '/' . 'resources', // home is its derived .bun subdirectory 'outputDir' => dirname(__DIR__) . '/' . 'public/build', 'baseUrl' => null, // null → derived (/build); a path; or an https CDN base ];
The directories must be absolute — a relative path is refused at load with the remedy in the message.
The dirname(__DIR__) form keeps the file portable across machines.
Architecture
The Bun service is the façade. It asks the runtime layer for the platform's binary — downloaded from
the npm registry over PSR-18 on first use, integrity-verified, executed and version-checked, memoized
for the process — then delegates every operation through a single ProcessRunnerInterface seam. The
resources layer sits above it: a Recipe declares what to build, the AssetPipeline runs the stages,
and the Manifest maps authored entries to hashed URLs.
graph TD
CMD["bun:resources:build [--watch]"]
RECIPE["Recipe (resources/build.php)<br/><br/>entries · bunx/run steps · bridges — WHAT"]
PIPE["AssetPipeline<br/><br/>clean → tsconfig → steps → bundle → charset — HOW"]
BUN["Bun service<br/><br/>install / x / build / watch — returns Bun's exit code"]
RUNTIME["Runtime layer<br/><br/>resolve → download → verify → memoize the pinned binary"]
HOME["resources/.bun<br/><br/>runtime · node_modules · tsconfig · build/"]
OUT["public/build<br/><br/>js/ · css/ · manifest.json"]
TPL["Templates<br/><br/>js('platform.ts') / css('platform.ts')"]
CMD --> RECIPE --> PIPE --> BUN --> RUNTIME --> HOME
PIPE --> OUT --> TPL
Loading
Testing
composer install composer test # PHPUnit composer analyse # PHPStan, level max + strict rules composer cs-check # PHP-CS-Fixer composer check # All three
Integration tests that download the real binary and run real builds are gated behind BUN_LIVE=1.
License
MIT — see LICENSE.
phpdot/bun wraps Bun (oven-sh/bun), which is MIT licensed. The binary is downloaded per project from the npm registry and is never redistributed inside this package.
This repository is a read-only mirror. The canonical source lives in phpdot/monorepo; pull requests and issues are handled there: pulls · issues.