PHP in Cloudflare Workers and Pages
Use php-cloud-wasm to run
embedded PHP in Cloudflare’s Worker runtime. Its dedicated
profile supports PHP 8.0–8.5 and includes Vrzno, ordinary
ZIP/deflate, zlib, and PDO-CFD1 with native parameters, binary
values, and atomic batches. It is not the general-purpose
php-wasm browser/Node package, the browser Service
Worker adapter, or PHP-CGI.
This guide uses Pages advanced mode to match the original
examples. You write the HTTP handler in JavaScript and
explicitly call PHP; uploading a .php file does not
make Cloudflare execute it. The same PHP adapter can be used in
a Module Worker when its uploader preserves the static Wasm
imports.
What the two example repositories demonstrate
php-cloud is
the execution side: its Pages
function imports a Wasm module, maps requests to a separate
static origin, runs fetched PHP, and passes other resources
through. Its origin map pairs the deployed Worker with
php-static on GitHub Pages, and local ports 8788
and 8081.
php-static supplies the application side: PHP scripts, CSS, a wiki, and a ZIP-based autoloader. That autoloader illustrates fetching package archives through JavaScript, extracting them into PHP’s in-memory filesystem, and loading PHP classes. These are architectural examples, not an unchanged installation recipe for the current package.
The examples were checked at the linked revisions on September 12, 2026. Important migration differences are:
| Legacy examples | Current Cloudflare profile |
|---|---|
Local PhpWeb.mjs, php-web.mjs, and
php-web.wasm |
Standalone php-cloud-wasm package and
version-bound entry |
Application supplies instantiateWasm |
Generated entry imports its matched hashed Wasm; the adapter owns instantiation |
db plus vrzno: DSNs in the old PDO
example |
cfd1: { mainDb: env.DB } and
new PDO('cfd1:mainDb', ...) |
File-based functions/[[path]].js routing |
Explicit routing in the advanced-mode Worker below |
| Runtime lookup of public application/package sources | Prefer packaged code or pinned, verified sources you control |
The legacy PDO example uses an earlier Vrzno-backed DSN, not the current PDO-CFD1 interface. Do not copy old instantiation hooks, JavaScript string evaluation, or request-to-arbitrary-PHP routing into the new handler.
1. Build or obtain the complete package
Use a php-wasm checkout containing
packages/php-cloud-wasm and the
cloudflare-mjs target. Do not assume an older npm
release contains this profile. With Node.js, npm, Make, and
Docker available, run from that checkout:
npm ci
make image ENV_FILE=/dev/null
make cloudflare-mjs ENV_FILE=profiles/cloudflare.mak PHP_VERSION=8.5
make test-cloudflare PHP_VERSION=8.5The current CLI also accepts
php-wasm-builder build cloudflare mjs. Use Make’s
explicit PHP_VERSION for the 8.5 example here. The
target uses profiles/cloudflare.mak by default and
accepts another configuration through ENV_FILE. The
CLI honors .php-wasm-rc; include the Cloudflare
profile there when customizing it. Builds use the ordinary
Make/Docker Compose recipes and reuse unchanged native inputs in
a configuration-specific build workspace. Ordinary
LIB_TYPE=static artifacts do not select this
profile.
The final package contains
php8.5-cloudflare.mjs, its low-level runtime, a
content-addressed .wasm, helpers, declarations,
package metadata, and
php8.5-cloudflare.manifest.json. Keep the whole
package and manifest together. A tested nightly artifact can
replace the build step; select one immutable build containing
php-cloud-wasm, not an ordinary
worker binary. Build and artifact-only tests need no Cloudflare
credentials.
For an extracted artifact, run from the builder checkout:
CLOUDFLARE_ARTIFACT_ROOT=/path/to/extracted/packages/php-cloud-wasm \
make test-cloudflare PHP_VERSION=8.5See the builder’s
Cloudflare guide for the package contract and six-version
tests. There is no implicit latest-version package entry: choose
php8.5-cloudflare.mjs explicitly.
2. Stage raw Worker modules
Create a separate php-cloud-demo project beside
the php-wasm checkout:
mkdir ../php-cloud-demo
cd ../php-cloud-demo
npm init -y
npm install --save-dev --save-exact [email protected]Save this as copy-runtime.mjs. It verifies the
final manifest and copies only the selected version’s executable
modules into an empty stage:
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { verifyCloudflare } from '../php-wasm/bin/package-cloudflare.mjs';
const source = '../php-wasm/packages/php-cloud-wasm';
const output = 'dist/_worker.js/php-cloud-wasm';
const { manifest } = await verifyCloudflare(source, '8.5');
await fs.mkdir(output, { recursive: true });
if ((await fs.readdir(output)).length) {
throw new Error('Use an empty runtime staging directory');
}
for (const file of manifest.files) {
if (!/\.(mjs|wasm)$/.test(file.path)) continue;
const bytes = await fs.readFile(path.join(source, file.path));
const hash = createHash('sha256').update(bytes).digest('hex');
if (hash !== file.sha256) throw new Error('Changed artifact: ' + file.path);
await fs.writeFile(path.join(output, file.path), bytes, { flag: 'wx' });
}Run node copy-runtime.mjs. The layout is:
php-cloud-demo/
wrangler.toml
dist/
index.html
_worker.js/
index.js
php-cloud-wasm/
php8.5-cloudflare.mjs
php8.5-cloudflare-runtime.mjs
<content-hash>.wasm
PhpCloudflare.mjs
...manifest-owned helpers...
Here _worker.js is a directory,
not a JavaScript file. Pinned Wrangler uploads this inventory
with --no-bundle. The generated runtime has an
unused variable import(name) helper that automatic
discovery can reject; do not work around that by enabling eval
or arbitrary external imports.
The version-bound entry already statically imports the
factory and Wasm. Upload the .wasm as a compiled
Wasm module, not a URL or byte-buffer binding. In Miniflare’s
explicit module inventory this type is
CompiledWasm; JavaScript files are
ESModule. Instantiate PHP inside the request with
the precompiled module, not by fetching and compiling bytes
during the request. See Cloudflare’s
Wasm API and Miniflare
modules.
3. Write a fixed PHP and D1 handler
Save this as dist/_worker.js/index.js. It runs
one fixed, read-only query at /php, and forwards
other paths to Pages static assets:
import { PhpCloudflare } from './php-cloud-wasm/php8.5-cloudflare.mjs';
let running = false;
export default {
async fetch(request, env) {
if (new URL(request.url).pathname !== '/php') {
return env.ASSETS.fetch(request);
}
if (request.method !== 'GET') {
return new Response('Method not allowed', {
status: 405, headers: { Allow: 'GET' },
});
}
if (running) {
return new Response('PHP is busy', {
status: 503, headers: { 'Retry-After': '1' },
});
}
running = true;
try {
const php = new PhpCloudflare({ cfd1: { mainDb: env.DB } });
let output = '';
let outputBytes = 0;
let failed = false;
const encoder = new TextEncoder();
php.addEventListener('output', event => {
if (failed) return;
const part = event.detail.join('');
outputBytes += encoder.encode(part).byteLength;
if (outputBytes > 64 * 1024) { failed = true; return; }
output += part;
});
php.addEventListener('error', () => { failed = true; });
const exit = await php.run(`<?php
$pdo = new PDO('cfd1:mainDb', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$query = $pdo->prepare('SELECT ? AS answer');
$query->execute([42]);
$row = $query->fetch(PDO::FETCH_ASSOC);
echo json_encode(['php' => PHP_VERSION, 'answer' => (int)$row['answer']]);
`);
if (exit !== 0 || failed) throw new Error('PHP execution failed or output limit exceeded');
return new Response(output, {
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
} catch (error) {
console.error('PHP request failed', error);
return new Response('PHP request failed', { status: 500 });
} finally {
running = false;
}
},
};Add a small static dist/index.html, for example
a page linking to /php. Advanced mode replaces the
functions/ router and must explicitly forward
static requests; env.ASSETS.fetch() handles that
here. Pages
advanced-mode documentation.
DB is the Cloudflare binding;
mainDb is the key PHP sees in its
cfd1: DSN. No database schema is needed.
Instantiate PHP per request so D1 handles, PHP globals, shared
values and filesystem contents are not reused across visitors.
The guard admits one PHP request per isolate; it is not a global
queue or a guarantee of production memory capacity.
The handler accepts no PHP source, SQL text, archive URL, or binding name from clients. If you add input, validate it and use bound PDO parameters or Vrzno shared values, not PHP-source concatenation. The example caps captured output at 64 KiB and returns a generic error if PHP exits unsuccessfully or writes to stderr; application-specific error handling may refine that policy.
4. Configure D1, test locally, and deploy
Create a D1 database using your own authenticated Cloudflare account:
npx wrangler login
npx wrangler d1 create php-cloud-demoPut the returned database UUID into
wrangler.toml:
name = "php-cloud-demo"
compatibility_date = "2024-12-01"
compatibility_flags = ["enable_weak_ref"]
pages_build_output_dir = "./dist"
[limits]
cpu_ms = 1000
[[d1_databases]]
binding = "DB"
database_name = "php-cloud-demo"
database_id = "REPLACE_WITH_YOUR_DATABASE_UUID"Vrzno requires WeakRef and
FinalizationRegistry; the flag enables weak
references with this retained compatibility date.
Database IDs identify resources; API tokens are credentials.
Keep tokens out of source files and use Wrangler authentication
or your deployment secret store. Pages supports configured D1
bindings and local D1 with pages dev. Pages
D1 binding documentation.
Start the local Worker and test it from another terminal:
npx wrangler pages dev dist --d1 DB --port 8788
curl --fail http://localhost:8788/phpFor local development, pinned Wrangler generates a Pages
wrapper that needs bundling, and --d1 DB explicitly
supplies the local binding. Using
pages dev --no-bundle can fail on Wrangler’s
generated wrapper before PHP starts. This development workaround
does not change the staged files: production deployment below
uses --no-bundle to preserve their original module
inventory. The project’s direct Miniflare artifact tests
separately validate those unchanged JavaScript and compiled Wasm
modules.
Expect JSON containing PHP 8.5.x and
"answer":42. Local D1 is separate from your
production database. The runtime’s artifact suite also tests
local-D1 CRUD, missing bindings, errors and recovery.
After local verification, create a Pages project and deploy an explicit preview:
npx wrangler pages project create php-cloud-demo --production-branch main
npx wrangler pages deploy dist --project-name php-cloud-demo --branch preview --no-bundleCheck /php on the preview URL before promoting
the same files with --branch main. Confirm your
account plan and Pages/Workers bundle, CPU and memory limits
first; the project’s nightly PHP demo targets Paid Workers.
These commands deploy your own project, not the example
repositories or the php-wasm nightly service. Local success
alone does not establish live capacity.
Loading an application like php-static
The php-static repository deliberately hosts PHP
as downloadable source. You can adapt that separation, but use a
fixed allowlist of entrypoints and an immutable revision you
control. Never publish credentials inside PHP on a static host
or turn a request pathname into an unrestricted remote-code
loader.
For a small application, ship its PHP with your Worker and
load it with php.writeFile(), then
await php.run('<?php require ...;');. For an
archive, fetch a pinned, trusted ZIP inside the request, verify
its digest, bound its compressed and extracted sizes, write it
into PHP’s filesystem, and use ZipArchive to
extract and require a fixed entrypoint. The builder
guide includes an archive-loading
example. Do not reproduce the old autoloader’s mutable
latest-package selection as a production dependency policy;
resolve dependencies and integrity data beforehand.
Compression and runtime constraints
- Brotli/gzip are download transports. A
nightly server may serve a sidecar behind the original
.wasmURL with matchingContent-Encoding, the original MIME type, andVary: Accept-Encoding. Decode downloads before checking manifest hashes. Upload raw.wasmto the Worker, never a compressed sidecar disguised as a compiled module. - PHP eval is not JavaScript eval. PHP
eval()and Vrzno’s asynchronous calls work; JavaScript string evaluation such asvrzno_evalis unavailable. No eval permission is needed for the supported adapter. - PDO-CFD1 supports ordinary execution and atomic
batches. Bare
?, numbered?NNN, and named:nameparameters,execute([...]),exec(),quote(),lastInsertId(), binary values, buffered scroll cursors and observed column metadata are supported.cfd1Batch()uses already bound statements; ordinary execution does not require explicit binding. Open transactions and streaming cursors remain unsupported. See PDO-CFD1. - The filesystem is instance-local. There is no browser persistence, dynamic/shared native extension loading, CGI adapter, or ordinary PHP server process. ZIP support excludes encrypted AES archives.
- Memory is per instance, but the limit is per isolate. PHP starts with 64 MiB of Wasm memory and can grow to 96 MiB. The isolate’s 128 MB allowance also covers JavaScript and concurrent requests. Even two initial PHP memories exhaust it before overhead; bound concurrency and test real workloads. Workers memory limits.
For a deployment dynamic module-specifier error, check the
explicit inventory and --no-bundle; local
pages dev uses the workaround above. For a Wasm
trap or mismatched artifact, verify the whole manifest rather
than swapping binaries. For D1 errors, check the Worker binding,
the cfd1 map key, and the PDO DSN separately.