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

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_email

The container's contract with the Worker is exactly two things:

  1. a TCP port serving HTTP (8080 by default), and
  2. 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-php

The 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 / sendBatch to a Cloudflare Queue (producing; see Queues).
  • WorkersPhp\Mail\MailHttpClient — structured send (no raw MIME).
  • WorkersPhp\Session\D1SessionHandler — SessionHandlerInterface backed by D1 with a register() 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.php

Inside, 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.php

The 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/users

Caveats

  • Migrations run at container boot over HTTP; keep them small.
  • Transactions are no-ops (D1 has none); a D1Connection shim aligns Laravel 13's SQLite transaction SQL with that.
  • One container instance is one PHP process tree; size instance and inactivityTimeoutMs accordingly.

Repository layout

  • src/, php/, etc/, tests/ — the library itself
  • examples/laravel — a full Laravel app consuming the library; the living reference for the four files above

License

Apache-2.0