workers-php
v2.0.1
Published
PHP on Cloudflare Workers, Durable Objects and Containers
Readme
workers-php
Run PHP applications on Cloudflare Workers Containers. Your PHP app (Laravel, Symfony, or plain PHP) runs unchanged in a container; the Worker fronts traffic, serves R2 objects, holds requests while the container boots, and answers the container's outbound calls to D1, R2 and email — plain HTTP against "magic" hosts, no FPM socket tricks.
How it works
browser ──► Worker (phpWorker)
├── /storage/* ──► R2 (no container boot needed)
└── everything else ──► Durable Object ──► container :8080
▲
container ──► http://example.com/{DB,FILES,EMAIL} ──► outboundByHost ──► D1 / R2 / send_emailThe container's contract with the Worker is exactly two things:
- a TCP port serving HTTP (8080 by default), and
- a ready flag file (
/tmp/workers-php-ready) that appears once the app is safe to serve.
Until the flag exists the app answers 503 + Retry-After and the Worker
holds traffic through the boot window, so cold starts surface as a short
wait, never as an error page.
Install
npm install workers-php
composer require workers-php/workers-phpThe worker side ships on npm; the PHP runtime ships on Packagist under
the same name. Composer consumers get the runtime in vendor/; apps
without composer can autoload it from node_modules/workers-php/php/
instead. On Laravel, the service providers register themselves via
package discovery.
You write four files; the library ships everything else (PHP runtime, reference entrypoint, Caddy config) inside the npm package.
worker.ts
import { PhpOutbound, phpWorker } from "workers-php";
export { AppContainer } from "./do";
export { PhpOutbound };
export default phpWorker({
container: "CONTAINER",
name: "app",
storage: { bucket: "FILES", prefix: "/storage/" },
});Binding names are typed against the app's generated Env
(wrangler types → worker-configuration.d.ts): editors autocomplete
valid names here and in every binding factory (d1, r2, kv,
queue, mail, service, analytics, hyperdrive), and a mistyped
name is a compile error. Without generated types, any string is
accepted.
do.ts
import { env as workerEnv } from "cloudflare:workers";
import {
d1,
kv,
log,
mail,
phpOutbound,
PhpContainer,
queue,
r2,
} from "workers-php";
export class AppContainer extends PhpContainer {
instance: ContainerStartupOptions["instance"] = "standard-1";
envVars = {
APP_KEY: workerEnv.APP_KEY,
DB_CONNECTION: "d1",
DB_D1_ENDPOINT: "http://example.com/DB",
FILESYSTEM_DISK: "r2",
R2_ENDPOINT: "http://example.com/FILES",
MAIL_MAILER: "http-mail",
MAIL_ENDPOINT: "http://example.com/EMAIL",
CACHE_STORE: "database",
KV_ENDPOINT: "http://example.com/KV",
SESSION_DRIVER: "cookie",
QUEUE_CONNECTION: "cfqueue",
QUEUE_ENDPOINT: "http://example.com/QUEUE",
LOG_CHANNEL: "stderr",
};
}
AppContainer.outboundByHost = phpOutbound(
d1("DB"),
r2("FILES"),
kv("KV"),
queue("QUEUE"),
mail("EMAIL"),
log(),
);wrangler.jsonc
{
"name": "my-app",
"main": "worker.ts",
"compatibility_date": "2026-09-15",
"containers": [
{
"name": "app",
"class_name": "AppContainer",
"scheduling_policy": "durable_object",
"images": {
"app": {
"dockerfile": "./Dockerfile",
"build_context": "../..",
},
},
},
],
"durable_objects": {
"bindings": [{ "name": "CONTAINER", "class_name": "AppContainer" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["AppContainer"] }],
"d1_databases": [
{ "binding": "DB", "database_name": "my-app", "database_id": "…" },
],
"r2_buckets": [{ "binding": "FILES", "bucket_name": "my-app" }],
"kv_namespaces": [{ "binding": "KV", "id": "…" }],
"queues": {
"producers": [{ "binding": "QUEUE", "queue": "my-app" }],
"consumers": [
{ "queue": "my-app", "max_batch_size": 10, "max_retries": 3 },
],
},
"send_email": [
{
"name": "EMAIL",
"allowed_sender_addresses": ["[email protected]"],
},
],
}The container runs on the durable_object scheduling
policy:
wrangler only declares the named images, and PhpContainer picks the
image, instance size, and entrypoint in code when it starts the container
— which is also what makes cold starts fast (the policy's scheduling path
is several times faster than centrally managed rollouts). max_instances
does not exist under this policy; running instances count toward account
limits. Readiness stays with the PHP runtime's boot gate, which
phpWorker holds against.
Dockerfile
The image layout must mirror the composer's relative PSR-4 paths (see the PHP section below):
FROM composer:2 AS vendor
WORKDIR /srv/my-app
COPY my-app/composer.json my-app/composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --ignore-platform-req=ext-gd
COPY my-app/ ./
COPY node_modules/workers-php/php /srv/node_modules/workers-php/php
RUN rm -f bootstrap/cache/*.php \
&& composer install --no-dev --optimize-autoloader --classmap-authoritative --ignore-platform-req=ext-gd
FROM dunglas/frankenphp:1-php8.5-bookworm AS runtime
RUN install-php-extensions gd intl bcmath zip opcache \
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
ENV SERVER_NAME=:8080
ENV APP_DIR=/srv/my-app
WORKDIR /srv
COPY --from=vendor /srv /srv
COPY node_modules/workers-php/etc/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]wrangler supports image_build_context, so the image can COPY from
node_modules/workers-php/.... The reference entrypoint honours
APP_DIR, SERVER_CMD and WORKERS_PHP_READY_FLAG; replace it with
your own if you need something else — only the port + ready-flag
contract matters.
The PHP runtime
The package ships three PSR-4 trees, dependency-split:
| Tree | Namespace | Needs |
| -------------- | --------------------- | ---------------------- |
| php/base/ | WorkersPhp\ | nothing (ext-curl) |
| php/laravel/ | WorkersPhp\Laravel\ | illuminate + flysystem |
| php/symfony/ | WorkersPhp\Symfony\ | symfony/mailer |
Map them in your composer.json:
{
"autoload": {
"psr-4": {
"WorkersPhp\\": "node_modules/workers-php/php/base/",
"WorkersPhp\\Laravel\\": "node_modules/workers-php/php/laravel/",
"WorkersPhp\\Symfony\\": "node_modules/workers-php/php/symfony/"
}
}
}Base clients (framework-free)
WorkersPhp\D1\D1HttpClient—query()/exec()against a D1 endpoint, with PDO-style named-placeholder rewriting.WorkersPhp\D1\HttpD1PDO+HttpD1PDOStatement— drop-in PDO whose handle is the endpoint; last insert id from D1 meta.WorkersPhp\R2\R2HttpClient—get/head/put/delete(single or batch) /list.WorkersPhp\KV\KVHttpClient—get(value + metadata) /put(TTL + metadata) /delete/list.WorkersPhp\Analytics\AnalyticsHttpClient—write()data points to an Analytics Engine dataset (20 blobs, 20 doubles, one index per call).WorkersPhp\Queue\QueueHttpClient—send/sendJson/sendBatchto a Cloudflare Queue (producing; see Queues).WorkersPhp\Mail\MailHttpClient— structured send (no raw MIME).WorkersPhp\Session\D1SessionHandler—SessionHandlerInterfacebacked by D1 with aregister()convenience and first-use table creation; durable sessions in one line.
Every client takes an optional transport callable:
new D1HttpClient("http://example.com/DB", fn ($m, $u, $h, $b) => [200, [], "{}"]);so tests (and alternative HTTP stacks) substitute their own; the default
is WorkersPhp\Http\CurlTransport.
Laravel
Register the providers in bootstrap/providers.php:
return [
App\Providers\AppServiceProvider::class,
WorkersPhp\Laravel\CloudflareServiceProvider::class,
WorkersPhp\Laravel\D1ServiceProvider::class,
];and prepend WorkersPhp\Laravel\Middleware\WaitForBoot in
bootstrap/app.php. Then:
Database (config/database.php) — the d1 driver needs the full
SQLite connection shape even though only endpoint is used:
'd1' => [
'driver' => 'd1',
'endpoint' => env('DB_D1_ENDPOINT', 'http://example.com/DB'),
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],Cache (config/cache.php):
'kv' => [
'driver' => 'kv',
'endpoint' => env('KV_ENDPOINT', 'http://example.com/KV'),
],then CACHE_STORE=kv. TTLs ride KV's native expiration; flush() sweeps
the store's prefix (KV has no atomic clear).
Filesystem (config/filesystems.php):
'r2' => [
'driver' => 'r2',
'endpoint' => env('R2_ENDPOINT', 'http://example.com/FILES'),
'url_prefix' => env('R2_URL_PREFIX', '/storage'),
],Objects are served by the Worker under the URL prefix (no container
boot), and Storage::url() produces those URLs.
Mail (config/mail.php):
'http-mail' => [
'transport' => 'http-mail',
'endpoint' => env('MAIL_ENDPOINT', 'http://example.com/EMAIL'),
],Symfony
WorkersPhp\Symfony\Mailer\HttpMailTransport implements Symfony Mailer's
TransportInterface over the structured endpoint; Laravel registers it
as the http-mail transport, plain Symfony apps wire it directly.
Hyperdrive
Postgres behind a Hyperdrive binding speaks the same D1 query protocol,
so the PHP runtime is unchanged; the worker side lives behind an
optional subpath because it needs the postgres driver package and the
nodejs_compat flag:
npm install postgres{
"compatibility_flags": ["nodejs_compat"],
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "…" }],
}import { hyperdrive } from "workers-php/hyperdrive";
AppContainer.outboundByHost = phpOutbound(hyperdrive("HYPERDRIVE"));PHP talks to the endpoint with WorkersPhp\Hyperdrive\HttpPgsqlPDO
(ATTR_DRIVER_NAME reports pgsql). Laravel gets a full
DB_CONNECTION=hyperdrive through CloudflareServiceProvider
(config/database.php):
'hyperdrive' => [
'driver' => 'hyperdrive',
'endpoint' => env('DB_HYPERDRIVE_ENDPOINT', 'http://example.com/HYPERDRIVE'),
'database' => env('DB_DATABASE', 'postgres'),
'prefix' => '',
],? placeholders are rewritten to Postgres's $n in the worker; write
jsonb's ? operator family as jsonb_exists(), jsonb_exists_any() or
jsonb_exists_all(). Insert ids come from RETURNING instead of
PDO::lastInsertId().
Queues
Producing rides the cfqueue driver (config/queue.php):
'cfqueue' => [
'driver' => 'cfqueue',
'endpoint' => env('QUEUE_ENDPOINT', 'http://example.com/QUEUE'),
],then QUEUE_CONNECTION=cfqueue. push, later (via delaySeconds)
and bulk work as usual. Consuming runs inside the container through a
dedicated internal endpoint — see
Queue consuming.
Queue consuming
export default phpWorker({
container: "CONTAINER",
name: "app",
storage: { bucket: "FILES", prefix: "/storage/" },
consume: true,
});With consume: true the worker also handles the queue's batches: every
message is POSTed to the container's consume port (8081 by default,
override with consumePort) as {id, attempts, body}. A 200 acks the
message; anything else retries it, honoring an X-Queue-Delay response
header as delaySeconds.
The reference etc/Caddyfile serves that port from a dedicated front
controller, etc/queue-consumer.php — never the app's public routes,
so the endpoint cannot leak. Copy both into the image:
COPY node_modules/workers-php/etc/Caddyfile /etc/frankenphp/Caddyfile
COPY node_modules/workers-php/etc/queue-consumer.php /etc/workers-php/queue-consumer.phpInside, WorkersPhp\Laravel\Queue\QueueConsumer runs the payload
through Laravel's queue Worker: a successful job acks; a failing one
retries with the connection's retry_after as the delay; exhausting
max_tries marks it failed through Laravel's usual failed-job flow and
acks, leaving redelivery to the queue's own retry/DLQ configuration:
'cfqueue' => [
'driver' => 'cfqueue',
'endpoint' => env('QUEUE_ENDPOINT', 'http://example.com/QUEUE'),
'max_tries' => 3,
'retry_after' => 30,
],Payloads must be Laravel-format — producers on other stacks should use a separate queue or a custom front controller.
Scheduled tasks
export default phpWorker({
container: "CONTAINER",
name: "app",
storage: { bucket: "FILES", prefix: "/storage/" },
schedule: true,
});// wrangler.jsonc
"triggers": { "crons": ["0 0 * * *"] },With schedule: true the worker also handles Cron Triggers: every
event POSTs /schedule on the container's internal port (the same one
queue consuming uses, never routed publicly), holding through the boot
window for up to scheduleDeadlineMs — 120 seconds by default, since a
cold boot plus migrations outlasts a web request's patience. A non-200
response throws so the event shows as failed in the logs. The reference
etc/Caddyfile routes /schedule to etc/schedule-runner.php, which
runs php artisan schedule:run; copy it alongside the consumer:
COPY node_modules/workers-php/etc/schedule-runner.php /etc/workers-php/schedule-runner.phpThe cron is only the tick — schedule:run decides what is due, exactly
like the classic per-minute crontab entry. Laravel matches due-ness
against the invocation minute, so the trigger should fire at your
schedule's due-times rather than every minute: a per-minute cron keeps
the container awake around the clock, while a daily 0 0 * * * boots
it once a night, runs the ->daily() tasks, and lets it sleep again.
Several crons in one array cover differing due-times; two firing the
same minute run the task twice.
Outbound host
phpOutbound(d1("DB"), r2("FILES"), mail("EMAIL"), log()) routes all of
the container's outbound calls through a single shared host —
example.com by default, override with phpOutbound({ host: "…" }, …).
Interception keys on the host alone and diverts traffic to the worker
before egress, so the host never receives real traffic — only its DNS
record matters, because interception happens per-connection after DNS
resolution. example.com is IANA-reserved and always resolvable, which
is exactly what makes it safe to hardcode. Each factory answers under a
path named after its binding, verbatim: d1("DB") serves
http://example.com/DB/* — keep the *_ENDPOINT env vars in step.
The log() handler is a debug sink: POSTs to /log land in the worker's
tail. Drop it for production, or pass { sink: "https://…" } to also
forward the output to a real collector.
service("API") proxies to a service binding verbatim — method, path,
query and body ride through, no PHP client needed:
{ "services": [{ "binding": "API", "service": "other-worker" }] }Http::get(env("API_ENDPOINT")."/users"); // http://example.com/API/usersCaveats
- Migrations run at container boot over HTTP; keep them small.
- Transactions are no-ops (D1 has none); a
D1Connectionshim aligns Laravel 13's SQLite transaction SQL with that. - One container instance is one PHP process tree; size
instanceandinactivityTimeoutMsaccordingly.
Repository layout
src/,php/,etc/,tests/— the library itselfexamples/laravel— a full Laravel app consuming the library; the living reference for the four files above
License
Apache-2.0
