bun-php
v1.0.0
Published
Import .php files into Bun and call PHP functions as async JavaScript functions.
Maintainers
Readme
🐘 bun-php
Now you can finally run PHP files in Bun! Import a .php file and bun-php turns its functions into async,
typed JavaScript functions running under PHP 8.5.
<?php
// hello.php
function greet(string $name): string
{
return "Hello, $name!";
}import { greet } from "./hello.php";
console.log(await greet("world")); // "Hello, world!"No PHP binary is required — PHP 8.5 runs inside WebAssembly.
Install
bun add bun-phpThen register the plugin in bunfig.toml:
preload = ["bun-php/register"]
# The top-level `preload` does not apply to `bun test`, so repeat it here
# if your tests import .php files.
[test]
preload = ["bun-php/register"]Can I do the meme thing?
Yes!
import { BunPHP } from "bun-php";
await BunPHP`<?php echo "Hello, World!"; ?>`;
> Hello, World!The original tweet:

What gets exported
Every top-level function becomes an async JavaScript function, and every top-level constant with a
literal value becomes a plain exported value.
<?php
const GREETING = 'Hello';
function greet(string $name, string $greeting = GREETING): string
{
return "$greeting, $name!";
}
function addAll(int ...$numbers): int
{
return array_sum($numbers);
}import phpModule, { greet, addAll, GREETING } from "./hello.php";
await greet("world"); // "Hello, world!"
await greet("Bun", "Hey"); // "Hey, Bun!"
await addAll(1, 2, 3); // 6
GREETING; // "Hello" — a plain value, no await, no PHP bootClass methods, closures and arrow functions are ignored. A constant that needs PHP to evaluate it (say
const C = 'a' . 'b';) is skipped and noted in a comment in the generated module.
PHP function names that are JavaScript reserved words still work — import them with an alias:
import { delete as deleteFile } from "./files.php";Inline PHP
For a snippet that doesn't warrant a PHP file, tag a template with BunPHP. It prints as PHP runs and
resolves to a top-level return (or null); BunPHP.capture prints nothing and resolves to the output
instead:
import { BunPHP } from "bun-php";
await BunPHP`<?php echo "Hello world";`; // prints "Hello world", resolves to null
await BunPHP`<?php return 40 + 2;`; // 42
await BunPHP.capture`<?php echo "Hello world";`; // "Hello world"
await BunPHP.capture`<?php return 40 + 2;`; // 42 — a return still wins
await BunPHP.capture`<?php echo "out"; return null;`; // "out" — see belowcapture falls back to the output when the snippet returns null, and PHP cannot tell an explicit
return null; from a snippet that returned nothing at all. Every other value wins, false and ""
included.
Both tags are optional: a snippet is PHP code. A leading <?php is dropped, <?= becomes echo,
and a trailing ?> becomes ; — it doubles as a statement terminator, which is what keeps
<?= 6 * 7 ?> valid. Inline markup is not supported: a snippet that starts or ends in HTML fails
with PHP's own parse error, so echo the markup instead:
await BunPHP`return 1 + 1;`; // 2
await BunPHP.capture`<?= 6 * 7 ?>`; // "42"
await BunPHP.capture`echo "<p>a</p>";`; // "<p>a</p>"Interpolated values are converted to PHP expressions, so they go where an expression is valid, not inside a string literal:
const name = "Bun";
await BunPHP`<?php return "Hello " . ${name} . "!";`; // "Hello Bun!"
await BunPHP`<?php return array_sum(${[1, 2, 3, 4]});`; // 10
await BunPHP`<?php return "Hello ${name}";`; // literal text, not the valueThe snippet is read from the template's raw segments, so escapes are PHP's rather than JavaScript's —
preg_match('/\d+/', ...) keeps its \d, and \DateTime keeps its backslash. Write real newlines rather
than \n when you want one:
await BunPHP`return preg_match("/\d+/", "abc123");`; // 1
await BunPHP`return 'a\tb';`; // "a\tb" - single quotes, so PHP keeps the backslash
await BunPHP`return "a\tb";`; // "a<tab>b" - PHP expands it, not JavaScriptInline PHP is a plain runtime API: it needs no plugin registration and no preload entry. Both tags share
one interpreter, each snippet runs as its own PHP request, and BunPHP.dispose() shuts it down.
Types
The plugin writes a sidecar hello.php.d.ts next to each .php file, derived from the PHP type
declarations, so import ... from "./hello.php" gets autocomplete and type errors:
export declare function greet(name: string, greeting?: string): Promise<string>;
export declare function addAll(...numbers: number[]): Promise<number>;
export declare function stats(values: PhpArray): Promise<PhpArray>;
export declare const GREETING: "Hello";Commit these generated files or add *.php.d.ts to .gitignore — either works.
| PHP | TypeScript |
| ---------------- | ------------------------- |
| int, float | number |
| string | string |
| bool | boolean |
| array | PhpArray |
| void | void |
| mixed, no hint | any |
| ?T, T\|null | T \| null |
| A\|B | A \| B |
| a class name | Record<string, unknown> |
PhpArray is PhpValue[] | { [key: string]: PhpValue }, declared at the top of every sidecar — PHP
has one array type and only the call knows which shape comes back. Types come from the declared hints
only; @param / @return docblock tags are not read, so a bare array hint stays the honest
PhpArray and an unhinted parameter is any. A docblock summary still becomes the function's JSDoc
comment.
Not generating sidecars? Reference the fallback declaration, which types every .php import as any:
/// <reference types="bun-php/types" />Multi-file projects and Composer
bun-php mounts the project directory into the WebAssembly filesystem, so require of a sibling file,
__DIR__, and Composer's autoloader work as expected:
<?php
// app/report.php
use League\CommonMark\CommonMarkConverter;
require_once __DIR__ . '/helpers.php';
function report(string $markdown): string
{
return (new CommonMarkConverter())->convert($markdown) . footer();
}import { report } from "./app/report.php";
await report("# Title");The project root is found by walking up from the .php file looking for vendor/autoload.php or
composer.json (the file's own directory is the fallback). When a vendor/autoload.php is found it is
required before every call.
The mount is a live view of the host filesystem — files written after the interpreter booted are visible,
and PHP can write back to disk. See demos/ for real examples.
Module API
The default export exposes methods for controlling the interpreter directly:
import php from "./hello.php";
await php.$ready(); // boot without calling anything
await php.$eval("return PHP_VERSION;");
await php.$reset(); // discard all PHP state; the next call boots afresh
await php.$dispose(); // shut the interpreter down
const raw = await php.$php(); // the underlying php-wasm PHP instance
php.$meta; // what the parser found in this file
await php.$call("greet", ["x"]); // call by nameDriving PHP directly
Importing a .php file is for calling library code. Driving a PHP tool — a phar, a linter, a
formatter — needs an argument list, a directory only known at call time, and a fitting php.ini. That's
what createInterpreter is for. It involves no .php import, no codegen and no preload:
import { createInterpreter } from "bun-php";
const php = createInterpreter({
phpVersion: "8.3",
spawn: "refuse",
ini: { memory_limit: "1024M" },
});
await php.mount("/tmp/some-project", "/project");
const { stdout, exitCode } = await php.cli([
"php",
"/tools/phpcs.phar",
"--report=json",
"/project",
]);| Option | Default | Meaning |
| ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------- |
| phpVersion | "8.5" | Which build to boot. Any other version must be installed by you — see below. |
| loader | – | Supply the php-wasm build yourself. Takes precedence over phpVersion. |
| ini | – | php.ini entries, applied before the first call. |
| spawn | – | "refuse", or your own handler. See the warning below. |
| mounts | – | { host, at } directories to mount up front. |
| timeoutMs | – | Deadline for cli() under isolation: "process" only — the child is SIGKILLed. Ignored in-process (see Limitations). |
| isolation | – | "process" runs each cli() in a child process that exits afterwards. |
Beyond cli(), an interpreter offers mount(), ini(), writeFile(), mkdir(), php() (the raw
php-wasm instance) and dispose(). Every cli() runs in a fresh PHP instance, and what you staged with
mount/writeFile/mkdir/ini is re-applied to each one.
Process isolation
The in-process interpreter is fine for a handful of calls. For running a tool across thousands of inputs,
isolation: "process" runs every cli() in a child process that exits when the call ends:
const php = createInterpreter({
isolation: "process",
phpVersion: "8.3",
spawn: "refuse",
ini: { memory_limit: "1024M" },
timeoutMs: 600_000, // a real deadline: the child is SIGKILLed
});
await php.mount(exportDir, "/plugin");
await php.writeFile("/files.txt", list);
const { stdout, exitCode } = await php.cli(["php", "/tools/phpcs.phar", ...]);It fixes three things the in-process interpreter can't:
- Memory returns to baseline. The wasm heap retains hundreds of MB across boot/dispose cycles in-process; an exiting child hands it back to the OS.
timeoutMsexists here. In-process a running request can't be interrupted, so bun-php has no deadline there: an imported.phpmodule refusestimeoutMsoutright, and on an in-processcreateInterpreterit does nothing at all. Under isolation the deadline SIGKILLs the child, the work stops, and the call rejects withPhpTimeoutError.- Calls run in parallel. Two concurrent one-second calls take 1.04× the time of one, versus 1.96× in-process.
Errors keep their type across the boundary: a PhpBuildNotInstalledError from the child is still one in the
parent, with its packageName and the cause's message intact.
Each child boots its own wasm and replays your mount/writeFile/mkdir/ini calls before the command,
so it sees the same files and config an in-process interpreter would. That setup has to survive JSON:
loader and a function-valued spawn are rejected at construction (spawn: "refuse" is fine), and php()
has no in-process instance to return. Each call also pays a child spawn plus a fresh wasm boot (a few
hundred milliseconds) — noise for a tool run, wrong for a hot loop of small calls.
Choosing a PHP version
phpVersion defaults to 8.5, the only build bun-php depends on. Every other version is an optional peer
dependency, so you install the one you want:
bun add @php-wasm/node-8-3Every build is covered by CI, which runs the compatibility suite against 8.0 through 8.5 on each pull request, so an optional peer is verified rather than merely declared.
Each build is tens of megabytes of WebAssembly, which is why they aren't all bundled. Asking for one you
haven't installed throws PhpBuildNotInstalledError, naming the package to add; a build that is installed
but won't load throws PhpBuildLoadError instead, with the real failure as its cause. Each build picks
the JSPI or asyncify variant itself; use loader to pin one:
createInterpreter({
loader: () => import("@php-wasm/node-8-3/asyncify/php_8_3.js"),
});Spawning
PHP's exec, shell_exec and popen reach the host through a spawn handler. There is no default, and
leaving one uninstalled hangs the process: a tool that probes for a terminal with shell_exec('tty') —
PHP_CodeSniffer does — waits forever for an answer that never comes.
spawn: "refuse" answers every spawn with an immediate non-zero exit, which is what analysis tools want. A
real handler that shells out gives any PHP you run full host execution, so install one deliberately.
Plugin options
import { phpPlugin } from "bun-php";
Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
target: "bun",
plugins: [phpPlugin({ stdout: "capture" })],
});| Option | Default | Meaning |
| ---------- | ----------- | --------------------------------------------------------------------------------------------------------- |
| dts | "auto" | Write sidecar types. "auto" writes unless producing a bundle. |
| stdout | "inherit" | Where PHP's echo output goes: "inherit", "capture" (drain with php.$output()), or "ignore". |
| filter | /\.php$/ | Which files to handle. |
| mount | true | Mount the project directory so sibling requires and Composer resolve. false drops the autoloader too. |
| autoload | auto | Path to a file to require before each call. Auto-detects vendor/autoload.php; false disables. |
| runtime | - | Interpreter options for every module loaded: phpVersion, ini, mounts, spawn. |
runtime reaches the module as generated source, so it takes only what survives JSON: loader, a
spawn handler function and isolation are rejected up front. timeoutMs is rejected too, for a
different reason — module calls have no deadline at all (see Limitations), so accepting it would be
dead configuration. Reach for createInterpreter when you need any of them.
phpPlugin({ runtime: { phpVersion: "8.3", ini: { memory_limit: "256M" } } });The bun build CLI can't use plugins at all — use the Bun.build() JS API, or
[serve.static] plugins = ["bun-php"] for the dev server.
How it works
- An
onLoadhook intercepts.phpimports. - php-parser reads the file and collects its top-level functions and constants.
- The plugin emits a JS module whose exports proxy into PHP.
- On the first call, php-wasm boots a PHP 8.5 interpreter and mounts the project directory into its virtual filesystem; later calls reuse it.
- Arguments and return values cross as JSON. PHP's own output is streamed separately so it can never corrupt the result.
Limitations
Each call is an isolated PHP request. php-wasm resets request-scoped state between runs, so static
variables, globals and superglobals don't carry over. The interpreter is reused (that's what makes calls
fast), but PHP userland state is not.
function tick(): int { static $n = 0; return ++$n; }await tick(); // 1
await tick(); // 1, not 2Use $eval or module-level PHP for state within a single call, or keep state on the JavaScript side.
A running PHP request can't be interrupted, and in-process calls don't run in parallel. The wasm holds
the thread, max_execution_time is ignored, and PHP.exit() mid-call returns without stopping anything.
That is why there is no in-process timeout option: a deadline that could only reject your promise while
the PHP kept running would buy nothing. To stop waiting, race the call yourself — the PHP still runs to
completion, and later calls queue behind it:
const value = await Promise.race([
slowPhpCall(),
new Promise((_, reject) => setTimeout(() => reject(new Error("gave up waiting")), 5_000)),
]);isolation: "process" is the real answer: its timeoutMs SIGKILLs the child, calls run in parallel, and
it's crash-safe too — an uncatchable wasm abort takes only its own child.
Other things to know:
- ESM only.
.phpmodules can't be loaded withrequire(). - Values cross by JSON. Integers beyond
Number.MAX_SAFE_INTEGERlose precision; resources and closures can't be returned; objects arrive as their public properties.NaNandInfinitycross as whole arguments only — nested in an array or object they throw rather than silently arriving asnull. Nestedundefinedfollows JSON instead:nullin an array, and a dropped key in an object. Only a whole argument that isundefinedis an error. PHP list arrays become JS arrays, associative arrays become objects, and JS objects arrive in PHP as associative arrays (notstdClass). - By-reference parameters (
&$x) don't write back. Arguments pass by value; the generated types carry a JSDoc warning. - Only the project directory is mounted. A
requirepointing outside the detected root won't resolve. Setmount: falseto opt out, leaving only the imported file's own source. The autoloader goes with it — a configuredautoloadpath as well as a detected one — because nothing outside the mount is reachable, and requiring a file that isn't there fails every call. UsecreatePhpModulefrombun-php/runtimeif you need a root-less module plus an autoloader you mount yourself. - No networking and no Xdebug. Available extensions are whatever the php-wasm build ships:
mbstring,openssl,hash,bcmath,dom,tokenizer,gd,zip,curl,sqlite3and friends.intlis absent, so packages requiringext-intlwon't load. function readonly()doesn't parse. PHP 8.5 allowsreadonlyas a function name, but php-parser rejects it, so a file declaring one fails to import.- Only what you mount exists inside the virtual filesystem. Don't reach for
open_basedirordisable_functionsas a substitute; their behaviour under php-wasm varies by build. - Short open tags are off, and pinned. bun-php sets
short_open_tag=0on every build, so in a.phpfile<? ... ?>is markup rather than code and<?xml version="1.0"?>passes through untouched. In an inlineBunPHPsnippet a leading<?is simply left in place, which PHP then rejects, since snippets are code.<?phpand<?=are unaffected. Settingshort_open_tagyourself logs a warning and is ignored: php-parser reads<?as markup, and a runtime that disagreed made a<?file export nothing at all. - Constants are evaluated at build time, so a shape whose value depends on the PHP version isn't
exported at all: an array key past 2^53, or an implicit key following a negative one (PHP 8.3
changed where it resumes). Those land in the
// Not exported:trailer instead of exporting a value that would be wrong on some supported build.
Development
bun install
bun test
bun run exampleLicense
MIT
