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-workersLaravel ≥ 10 · PHP only at build time · Cloudflare Workers, free plan first
- your laravel approutes, controllers, models, views, migrations — untouched
- build time · php 8.3one boot per phase · blade → php → js · byte-parity
- cloudflare workersstatic · snapshot · compiled p95 ≤ 5 ms · port
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.
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.
Nothing silently wrong
A route the compiler cannot serve fails --strict. There is no fallback that answers a different thing than Laravel would.
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.
Dump
PHP boots your app and dumps what only Laravel knows: routes, compiled Blade, the schema from your migrations, config, the schedule, events.
Transpile
Blade becomes JavaScript, routes become a router, migrations become schema.json, controllers become handlers. Outside the supported subset is a compile error.
Bundle
The Workers-native kernel and everything generated are bundled into a single Worker ESM. Nothing at request time loads PHP.
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.
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.
Prerender
Clean GET routes render into static snapshots, served as assets: they cost zero worker invocations. Another phase worker, still at build time.
Deploy
wrangler deploys the Worker with static assets and the D1 schema; the URL, ids and schema hash are recorded locally.
$ 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.
0 invocations
Built assets served by Workers Static Assets. Free, cached at the edge, no handler runs.
prerendered HTML
Clean GET routes render at build time into static files. Same speed as static; data as of the build.
p95 ≤ 5 ms
Router → middleware → controller → D1, as JavaScript. Sessions, CSRF, validation and Eloquent on the Workers kernel.
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.
- @extends / @section
- @if / @foreach / @switch
- {{ e($x) }}
- @vite
- @csrf
- translations at build
- {param} / {param?}
- OPTIONS / 405
- route-model binding
- FormRequest
- redirect()->intended()
- signed URLs
- where / join / group
- paginate()
- hasMany / belongsTo
- casts + mutators
- soft deletes
- D1 blob storage
- session guard
- remember-me
- CSRF tokens
- Gate::allows()
- throttle:n,m
- bcrypt parity
- Mail::to()->send()
- Event::dispatch()
- schedule → cron
- Cache (D1)
- uploads
- Log
- 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.
| construct | why it stops | the way out |
|---|---|---|
| Queue::push(Job::class) | nothing would consume the job at the edge | Cloudflare Queues + your consumer, or a D1 row a cron reads |
| Storage::disk('named') | each disk needs its own bucket; R2 asks for a card | the default disk, or an explicit R2 binding |
| <x-components> | they compile to a view runtime the edge does not carry | a partial today, or a port |
| config('app.custom') | the config dump carries framework keys plus declared groups | registry.json → a declared group, e.g. "config": ["app"] |
// 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
# 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 nothingThe 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.
- 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@v1Two 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.
| limit | free plan | what it means |
|---|---|---|
| CPU per request | 10 ms | the compiled handler budget is p95 ≤ 5 ms; the platform enforces softly at ~2 s |
| requests | 100k / day | static assets do not count as invocations |
| subrequests | 50 / request | eager loading exists for a reason |
| D1 writes | 100k / day | cookie sessions write zero rows; throttle only what needs it |
| storage | D1 blobs, 1.5 MB | R2 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.
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.