npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bun-php

v1.0.0

Published

Import .php files into Bun and call PHP functions as async JavaScript functions.

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-php

Then 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:

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 boot

Class 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 below

capture 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 value

The 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 JavaScript

Inline 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 name

Driving 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.
  • timeoutMs exists here. In-process a running request can't be interrupted, so bun-php has no deadline there: an imported .php module refuses timeoutMs outright, and on an in-process createInterpreter it does nothing at all. Under isolation the deadline SIGKILLs the child, the work stops, and the call rejects with PhpTimeoutError.
  • 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-3

Every 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

  1. An onLoad hook intercepts .php imports.
  2. php-parser reads the file and collects its top-level functions and constants.
  3. The plugin emits a JS module whose exports proxy into PHP.
  4. 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.
  5. 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 2

Use $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. .php modules can't be loaded with require().
  • Values cross by JSON. Integers beyond Number.MAX_SAFE_INTEGER lose precision; resources and closures can't be returned; objects arrive as their public properties. NaN and Infinity cross as whole arguments only — nested in an array or object they throw rather than silently arriving as null. Nested undefined follows JSON instead: null in an array, and a dropped key in an object. Only a whole argument that is undefined is an error. PHP list arrays become JS arrays, associative arrays become objects, and JS objects arrive in PHP as associative arrays (not stdClass).
  • 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 require pointing outside the detected root won't resolve. Set mount: false to opt out, leaving only the imported file's own source. The autoloader goes with it — a configured autoload path as well as a detected one — because nothing outside the mount is reachable, and requiring a file that isn't there fails every call. Use createPhpModule from bun-php/runtime if 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, sqlite3 and friends. intl is absent, so packages requiring ext-intl won't load.
  • function readonly() doesn't parse. PHP 8.5 allows readonly as 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_basedir or disable_functions as a substitute; their behaviour under php-wasm varies by build.
  • Short open tags are off, and pinned. bun-php sets short_open_tag=0 on every build, so in a .php file <? ... ?> is markup rather than code and <?xml version="1.0"?> passes through untouched. In an inline BunPHP snippet a leading <? is simply left in place, which PHP then rejects, since snippets are code. <?php and <?= are unaffected. Setting short_open_tag yourself 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 example

License

MIT