laravel-workersv0.4.10

v0.4.10 · MIT · Node >=22 · Bun >=1.0

Your Laravel app, compiled to a Worker.

Blade, routes, Eloquent, sessions and D1 run on Cloudflare Workers — compiled at build time and byte-checked against PHP 8.3. No PHP at request time, no WASM, no boot that spends a second of CPU against a 10 ms budget.

npm i -D laravel-workers

Laravel ≥ 10 · PHP only at build time · Cloudflare Workers, free plan first

Laravel source is compiled at build time into one Worker moduleLeft: a Laravel app's routes, controllers, models, Blade views and migrations. Middle: the build, where PHP 8.3 boots once per phase and the source is cut and translated to JavaScript. Right: a single dist/worker.mjs on Cloudflare Workers, with four serving tiers — static, snapshot, compiled and port. The static frame is the whole picture; the animation only shows the traffic moving through it.your laravel appthe source of truth — untouchedroutes/web.phpPHPapp/Http/Controllers/PostController.phpPHPapp/Models/Post.phpPHPresources/views/posts/show.blade.phpBLADEdatabase/migrations/2026_10_01_posts.phpSQLbuild time · php 8.3boots laravel once per phase, never at request timeschema1 bootviews1 bootsnapshots0–1 bootmetadata0–1 bootcloudflare workersone ESM bundle · v8 isolatesdist/worker.mjsstatic assets + handlers + schema.sqlJSstatic0 invocationssnapshotprerenderedcompiledp95 ≤ 5 msporthand-written
  1. your laravel approutes, controllers, models, views, migrations — untouched
  2. build time · php 8.3one boot per phase · blade → php → js · byte-parity
  3. cloudflare workersstatic · snapshot · compiled p95 ≤ 5 ms · port
build PHP boots here, once per phase that has workartifact static assets, snapshots, compiled handlers, portsruntime no PHP, no WASM, no boot per request
p95 ≤ 5 mscompiled handler CPU budget
0PHP processes at request time
55/55Blade corpus byte-identical vs PHP 8.3
118constructs audited, one verdict each
105/105Laravel docs pages mapped to a verdict
5Laravel boots per full build, not per request

why

A 10 ms budget does not fit a one-second boot.

Running PHP inside a Worker boots Laravel on every request. Measured on the Free plan, that burst costs 1,008–2,018 ms of CPU against a 10 ms budget — so the platform answers 503. laravel-workers moves the boot to build time.

Measured CPU per request against the Free plan's 10 ms budgetOn a logarithmic axis from 1 to 2,600 milliseconds: a PHP-in-Worker cold boot measured 1,008 to 2,018 milliseconds; the kernel's page-like load measured a p50 of 5, a p95 of 41 and a max of 62 milliseconds; a hand-written port handler measured 1 to 6 milliseconds. The dashed line is the Free plan's 10 millisecond budget.1101001,0002,00010 ms budgetphp-in-workercold boot1,008–2,018 msover budgetkernel hot pathspage-like probep50 5 · p95 41 · max 62tail overport handlernative JS1–6 msfits
Log scale, 1 ms to 2,000 ms per request. A bar is the spread the probe recorded; a dot is a percentile. Sources: MAP.md §1 (the PHP-in-Worker probe), the project's npm run gate:cpu (page-like load, read back from the platform's own cpuTime and outcome) and the port-handler probe.
the move

Boot once, ship plain JS

Every PHP-live step runs at build time, one Laravel boot per phase that has work. The artifact is JavaScript, static assets and a schema — not a PHP runtime.

the guarantee

Nothing silently wrong

A route the compiler cannot serve fails --strict. There is no fallback that answers a different thing than Laravel would.

the shape

Free plan first

Workers free, D1 free, the D1 blob store for objects — no credit card. R2 and the paid plan are opt-in, never a prerequisite.

how it works

Seven stages. PHP lives in one of them.

The build is a pipeline with a hard boundary: everything that needs Laravel happens at build time, in phase workers that each boot the app at most once — and everything the edge serves is generated. The phases are measured, not estimated.

S0

Dump

PHP boots your app and dumps what only Laravel knows: routes, compiled Blade, the schema from your migrations, config, the schedule, events.

tools/*.php — build time
S1

Transpile

Blade becomes JavaScript, routes become a router, migrations become schema.json, controllers become handlers. Outside the supported subset is a compile error.

tools/*.mjs · compiler/
S2

Bundle

The Workers-native kernel and everything generated are bundled into a single Worker ESM. Nothing at request time loads PHP.

dist/src/worker.mjs
S3

Prove

The real Laravel render and the compiled render are compared byte for byte, and the shadow harness diffs a whole request — status, headers, body.

shadow/ · 55/55 corpus
S4

Gate

--strict fails the build on any route without a handler, a snapshot or a declared port. A build that cannot serve the app ships no artifact.

READINESS: …
S5

Prerender

Clean GET routes render into static snapshots, served as assets: they cost zero worker invocations. Another phase worker, still at build time.

the 0-invocation tier
S6

Deploy

wrangler deploys the Worker with static assets and the D1 schema; the URL, ids and schema hash are recorded locally.

dist/ → Workers + D1
$ npx laravel-workers build --strict
… schema → snapshots → views → metadata → controllers
READINESS: RUNS ON THE EDGE
  every route is served by a compiled handler, a snapshot or a port;
  no route is ignored, and no binding is missing.

Unchanged inputs cost nothing

The build hashes its inputs by content — app sources, the autoloader, tool sources,.env, the PHP version and curated LW_*keys. A hit restores dist/ in ≈1.3 s instead of ≈35 s, with zero Laravel boots. --no-cache is the escape hatch, and it is what CI should use: a cache hit prints no READINESS block.

Full build, 43-route fixture: exactly 5 Laravel boots — schema → snapshots → route/error views → metadata → controller views. Route export, config, schedule and metadata extraction never boot PHP.

the artifact

Four tiers. The first one costs nothing.

A Worker is not one thing. Static files never invoke it, snapshots render once at build time, compiled handlers answer the dynamic routes, and ports cover what the compiler refuses. The build decides per route.

static

0 invocations

Built assets served by Workers Static Assets. Free, cached at the edge, no handler runs.

snapshot

prerendered HTML

Clean GET routes render at build time into static files. Same speed as static; data as of the build.

compiled

p95 ≤ 5 ms

Router → middleware → controller → D1, as JavaScript. Sessions, CSRF, validation and Eloquent on the Workers kernel.

port

your JavaScript

When a construct cannot be compiled, a port handler replaces that route. Declared, not hidden — and it still fails the build if left unanswered.

coverage

What compiles today.

The supported surface is a contract, not a best effort. Every construct in the audit has one of three verdicts — compiles, fails loud, documented — and the compatibility page lists all 118 plus all 105 Laravel documentation pages.

views
  • @extends / @section
  • @if / @foreach / @switch
  • {{ e($x) }}
  • @vite
  • @csrf
  • translations at build
http
  • {param} / {param?}
  • OPTIONS / 405
  • route-model binding
  • FormRequest
  • redirect()->intended()
  • signed URLs
data
  • where / join / group
  • paginate()
  • hasMany / belongsTo
  • casts + mutators
  • soft deletes
  • D1 blob storage
auth & state
  • session guard
  • remember-me
  • CSRF tokens
  • Gate::allows()
  • throttle:n,m
  • bcrypt parity
platform
  • Mail::to()->send()
  • Event::dispatch()
  • schedule → cron
  • Cache (D1)
  • uploads
  • Log
escape hatch
  • registry.json ports
  • guard ports
  • static / ignore
  • --scaffold stubs

The full matrix → · Blade parity is measured against PHP 8.3, not assumed: 55/55 corpus cases are byte-identical.

the refusal

If it cannot be carried, the build stops.

The compiler has a fixed subset and says so. Every refusal names the construct and the way out; nothing is dropped quietly, and nothing is emulated badly.

constructwhy it stopsthe way out
Queue::push(Job::class)nothing would consume the job at the edgeCloudflare Queues + your consumer, or a D1 row a cron reads
Storage::disk('named')each disk needs its own bucket; R2 asks for a cardthe default disk, or an explicit R2 binding
<x-components>they compile to a view runtime the edge does not carrya partial today, or a port
config('app.custom')the config dump carries framework keys plus declared groupsregistry.json → a declared group, e.g. "config": ["app"]
ports/seal-guard.mjsjavascript
// ports/seal-guard.mjs — a guard the app registers itself
export default {
  async user(ctx) {
    const id = await readToken(ctx.request, ctx.app.env.SEAL_KEY);
    return id === null ? null : ctx.app.models.User.find(ctx.app.db, id);
  },
};

Ports are first-class

A port is an ES module the build imports statically — no dynamic module load, which the Workers runtime forbids. --scaffold writes a 501 stub for every route that needs one, so the gap is a file you can see, not a surprise in production.

deploy

Preview it. Then let it do the build.

deploy asks only what it cannot decide: worker name, database, and whether to deploy. --plan answers all of it without touching anything — no build, no writes, no prompts.

$ npx laravel-workers deploy --plan --mode 3 --d1-auto
mode          3 · build + database + deploy
worker        my-laravel-app
database      create "my-laravel-app-db" (none recorded)
schema        dist/schema.sql · not checked by --plan
exit          0 — actionable

$ npx laravel-workers deploy
→ built dist/ · READINESS: RUNS ON THE EDGE
→ D1 "my-laravel-app-db" ready
→ deployed · https://my-laravel-app.example.workers.dev
quick startshell
# in a real Laravel app (vendor + public/build there)
npm i -D laravel-workers

npx laravel-workers build --strict     # dist/ — compile only
npx laravel-workers deploy             # build, then D1 (+ deploy) on request
npx laravel-workers deploy --plan --mode 3 --d1-auto   # preview; changes nothing
RUNS ON THE EDGERUNS WITH DECLARED GAPSDOES NOT RUN

The verdict is about routes, not data: every route has a handler, a snapshot or a declared port. What it does not promise is that your database is populated — that is deploy's D1 step and your rows. The deploy guide →

ci

A verdict a reviewer can read.

The tool decides, the reporter publishes, and your pipeline schedules it. Three layers, and the boundary is the point: a green check means one specific thing.

.github/workflows/ci.ymlyaml
- name: Edge build (--strict)
  run: |
    set -o pipefail
    npx laravel-workers build --no-cache 2>&1 | tee edge-build.log
- name: Verdict
  if: always()
  uses: arfoux/laravel-workers/action/verdict@v1

Two details are not optional: --no-cache (a cache hit prints no READINESS block, and the reporter would read an empty log) and a valid randomAPP_KEY in the check job.

# Verdict: RUNS WITH DECLARED GAPS
2 declared gaps — your decision, and they ship
  GET /health   ignored in registry.json
  (an ignored route answers 404 at the edge; Laravel still serves it locally)

the honest half

What “runs on the edge” does not claim.

The verdict is about routes: every route is served, nothing is ignored by accident, no binding is missing. It does not say your data is there, that every Laravel package has an equivalent, or that the platform has no limits.

Workers Free — the tier this toolchain is shaped around.
limitfree planwhat it means
CPU per request10 msthe compiled handler budget is p95 ≤ 5 ms; the platform enforces softly at ~2 s
requests100k / daystatic assets do not count as invocations
subrequests50 / requesteager loading exists for a reason
D1 writes100k / daycookie sessions write zero rows; throttle only what needs it
storageD1 blobs, 1.5 MBR2 takes over for bigger objects or many of them — and asks for a card

Prerendered means as-of-build

A page that reads rows the build has never seen prerenders as it looks with an empty table. The build gives the app a throwaway SQLite for that reason — your database and the cloud D1 are untouched.

Local-only stays local

Artisan, Dusk, Telescope, Sail, Horizon dashboards: 26 of the 105 documentation pages are not part of a deployed app at all. The coverage page says which, and why.

The limits page → ·The 105-page coverage table →

questions

What people ask first.

Is this a Laravel fork?

No. Laravel stays your source of truth in development, with its own test suite and PHPUnit. The edge artifact is generated output: static assets, JavaScript handlers and a D1 schema. Nothing in your app/ directory has to change to be compiled.

Do I need PHP in production?

No. PHP runs only at build time, inside the phase workers that dump routes, compile views, read migrations and render snapshots. The deployed Worker is plain JavaScript on a V8 isolate; there is no PHP runtime and no WASM.

What happens to a feature the compiler cannot carry?

The build fails loud. --strict refuses to produce an artifact for a route it cannot serve, and the error names the construct and the fix. If you want the route, you write a port — a small JavaScript module the build wires in.

What about queues, broadcasts and image resizing?

They are not compiled. Queue::push(SomeJob::class) fails loud because nothing would consume the job at the edge; images need GD/Imagick, which the platform does not have. Both have documented home in the limits page, with the Cloudflare service or port that replaces them.

Does my existing Laravel 10/11/12 app work?

Laravel ≥ 10 is the requirement. Whether your app compiles is answered by build --strict before anything is deployed: it names every route that needs a decision and every construct the edge cannot carry. It never guesses.

What does it cost to run?

Free-plan-first. Workers Free gives 100k requests/day and 10 ms CPU per invocation; D1 Free covers 100k writes and 5M reads a day; the D1 blob store covers storage without a credit card. R2 is opt-in (and asks for a card); the paid plan is headroom, never a prerequisite.

How do I know the numbers on this page are true?

The audit section is generated from the repository itself: 118 constructs with a verdict, 47 of which compile, and all 105 Laravel documentation pages mapped. Every figure carries the file and commit it came from, and the build fails when the source moves.

start

Put the build where the boot belongs.

npm i -D laravel-workers — then npx laravel-workers build --strict tells you the truth about your app before you deploy anything.