docs / Compatibility
Compatibility
Two tables, both generated from the repository: 105 Laravel documentation pages and the 49-row feature map behind them. A construct without a verdict is a gap, and gaps are build-blocking.
Reading the verdicts
| verdict | meaning |
|---|---|
| runs on the edge | works as-is, inside the budget |
| runs with declared gaps | works with a documented trade |
| needs a padanan (port) | a platform wall: write the equivalent |
| not applicable at the edge | local tooling or a meta page |
For your own app the same words come from the build: READINESS: RUNS ON THE EDGE,RUNS WITH DECLARED GAPS (declared gaps ship, and are named), orDOES NOT RUN (a route has no decision). The table below is about Laravel's own documentation surface, not about a particular app.
Laravel documentation, page by page
| page | verdict | what it means for your app |
|---|---|---|
| ai | n/a | dev tooling (Boost/AI guidelines) |
| ai-sdk | gaps | HTTP + streaming via fetch fits |
| artisan | n/a | local-only; edge CLI equivalents |
| authentication | gaps | kernel SessionGuard oracle-verified (M14); wired M15 to M20 (auth.test 4/4, login-flow E2E) |
| authorization | gaps | the Gate facade compiles: Gate::allows()/denies() answer from the compiled policy (the guest rules included); authorize()/any()/define()/before() fail loud |
| billing | gaps | Cashier via fetch; webhooks = HMAC routes (padanan) |
| blade | gaps | views compile (echo/if/loops/expr); the layouts compile too (M40: the sections travel to the layout, @extends/@section/@yield); the Blade components (<x-...>) stay a port |
| boost | n/a | dev tooling only |
| broadcasting | port | SSE/polling padanan; WS via DO/external |
| cache | gaps | the Cache facade + the cache() helper compile to the D1 database store; the tags need a taggable store (LW002, the D1 store has none, Laravel throws there too); a non-database driver fails the build (it would read and write a different store) |
| cashier-paddle | gaps | same as billing (padanan) |
| collections | gaps | kernel shim subset; fail loud per method |
| concurrency | gaps | Promise.all; 6-conn limit → batch |
| configuration | runs | frozen at build + secrets |
| console-tests | n/a | local; edge = parity harness |
| container | gaps | compile-time DI subset |
| context | gaps | request-scoped shim |
| contracts | runs | type-erased; two-sided custom drivers |
| contributions | n/a | meta |
| controllers | gaps | M10/M17/M22/M23/M28: GET/POST/PUT/PATCH/DELETE actions compiled (one handler per verb); \->validate() + flash (M19); edirect()->intended() + session regen/invalidate (M20); FormRequests, Cache/Hash/Storage facades, class constants and Eloquent writes compile (M23/M28); the route-model binding compiles (M40: show(Post $post) fetches the model from the segment); pagination + Eloquent relations still a port; fail-loud LW002 (unsupported construct) / LW003; REQUEST_CALL synced to kernel (#2: date removed, str added) |
| csrf | runs | session token; @csrf/@method |
| database | gaps | D1 builder: where/or/join/group/having |
| database-testing | gaps | sqlite↔D1 parity harness |
| deployment | runs | build → wrangler deploy (D1 + assets) |
| documentation | n/a | meta (TOC page) |
| dusk | n/a | local browser tests |
| eloquent | gaps | schema-driven ORM subset |
| eloquent-collections | gaps | collections shim |
| eloquent-factories | gaps | local dev tooling; no runtime code (unverified) |
| eloquent-mutators | gaps | compiled accessors/casts; build-time fillable dump; hashed cast |
| eloquent-relationships | gaps | lazy + eager subset |
| eloquent-resources | gaps | compiled toJSON subset |
| eloquent-serialization | gaps | toArray/toJson subset |
| encryption | gaps | AES-CBC+HMAC WebCrypto; serialize subset |
| envoy | n/a | local (SSH deploys) |
| errors | gaps | build-time compiled error views when present; an unhandled exception renders that 500 (JSON requests get Laravel's {"message":"Server Error"}); status-only stubs otherwise; new <PHP exception> + throw_if/throw_unless compile (M34) |
| events | gaps | Event::dispatch(new X(...)) compiles (M39c: the build compiles the event classes + their listeners, dist/src/classes.generated.mjs, and the dispatch calls them synchronously, like Laravel); a dispatch of an event name fails loud (no class, no listener); a queued listener fails loud (the edge has no queue consumer) → port; a listener that injects a service compiles (M44: the edge builds the app services the build compiled and adapts the framework services, kernel/service-adapters.mjs: the mailer, the logger, the worker hands the live one down); a service the build does not compile fails loud (the edge has no container and no file for it) |
| facades | gaps | kernel shims; the Cache/Hash/Storage facades compile (M23/M28/M30); the Gate facade compiles (M39a: the policy is compiled at build time); mail/queue/notification/password still unmapped → port |
| filesystem | gaps | wired (M28/M30): Storage:: → R2 when bound, else the D1 blob store (lw_blobs); the default disk compiles (Storage::disk('local') is that same store, M39b), a named disk needs a bucket of its own (LW002: R2 asks for a credit card); file_exists/public_path stay build-only (no local FS); the upload compiles (M40 part 5): the multipart body is parsed at the edge, the file object + store()/storeAs() answer Laravel's values (the file rule and the size rules in kilobytes work), the content type of a file (finfo) fails loud. Tiers: free plan first (D1 blob store, 1,5 MB/objek, tanpa kartu) → R2 kalau app butuh (disk bernama / objek besar; R2 minta kartu kredit) → paid plan hanya kalau free path memang tak cukup (keputusan operator; tak ada construct yang diblokir karena free plan kecil, build hanya berhenti kalau edge tak bisa menjawab seperti PHP) |
| folio | n/a | NOT ported (0 code); Laravel Folio page routing unsupported |
| fortify | gaps | compile routes/actions, or manual auth (padanan) |
| frontend | runs | Vite unchanged; proven |
| hashing | gaps | PBKDF2 both-sides; bcrypt runs on free (bcryptjs, verdict safe); argon (N/A at edge); PHP 8.3 password_get_info parity + hashed cast |
| head | gaps | metadata pkg; view-time render |
| helpers | gaps | kernel shim subset incl. html-entities, php-json, php-serialize, php-bytes (PHP strings as byte-strings: strlen/substr/ord semantics), php-json-ordered (the json_decode reader every PHP-facing decode uses, rt.json_decode, Collection::fromJson, a json cast, the request body: document key order via __lwOrder, exact 64-bit ints when a double carries the literal else LW006); abort_if/abort_unless/now/today compile (M33) |
| homestead | n/a | local VM |
| horizon | n/a | Redis dashboard; padanan CF dashboard |
| http-client | runs | fetch (already HTTP) |
| http-tests | n/a | local; parity harness covers |
| images | port | no GD/Imagick → CF Images or build-time |
| installation | runs | toolchain (§7) |
| license | n/a | meta |
| lifecycle | runs | §3 execution model |
| localization | gaps | frozen JSON; pluralization subset; the compiled rung answers the dates in UTC (the Workers' timezone) while Laravel uses config('app.timezone'), an app with a non-UTC timezone gets different dates at the edge (a documented divergence; the plan: M57) |
| logging | gaps | the Log:: facade compiles (M33) → the platform log (the Workers console); no Log module (a local file sink) |
| gaps | wired (M28): the worker builds the mailer from the app config (mail.default -> log/noop/resend) and hands it to the handler; the fluent Mail::to()->send() compiles (M57: the mailable is a class, the build compiles it and the kernel's Mailable base carries Laravel's fluent surface) → port; padanan = HTTP provider (SMTP impossible, hard) | |
| mcp | gaps | HTTP/SSE servers compile; stdio (N/A at edge) (padanan) |
| middleware | gaps | compiled pipeline wired (throttle + guest/auth stages); aliases auth.session/precognitive/can* fail loud |
| migrations | runs | ops-time; remote via wrangler/SQL |
| mix | n/a | legacy; Vite is the path |
| mocking | n/a | local; harness mocks fetch |
| mongodb | n/a | D1 = SQLite only; explicit out-of-scope |
| notifications | gaps | no module; needs mail+queue+sse padanan (mail/db channels; broadcast → SSE) |
| octane | n/a | server runner irrelevant at edge |
| packages | n/a | meta (this repo becomes one) |
| pagination | runs | kernel paginator |
| passport | gaps | OAuth endpoints compile; JWT WebCrypto (padanan) |
| passwords | gaps | tokens + mail; signed URLs (signed URLs ok; token+mail wiring pending) |
| pennant | gaps | D1/KV store driver (padanan) |
| pint | n/a | local style tool |
| precognition | gaps | validation kernel + headers |
| processes | port | no process spawning → off-edge padanan |
| prompts | n/a | CLI-only |
| providers | gaps | compile-time registration subset |
| pulse | gaps | D1-backed; or CF analytics padanan |
| queries | gaps | builder: where/or/join/group/having/distinct |
| queues | gaps | wired (M28): the worker hands the queue adapter to the handler (a port can push); the Queue::push(SomeJob::class) facade fails loud, the job would never run at the edge: the worker has no queue handler, so nothing consumes the queue binding, and the binding carries a JSON body, not a PHP job (the runtime throws the same). The paths that work: a Cloudflare Queues binding with a consumer you write (a limited version is on the Workers free plan; the paid plan charges per operation), or a D1 table the cron trigger reads (a port handler writes the row, the edge has no producer for it). The job class is not compiled either → port; CF Queues (a limited version on the Workers free plan; the paid plan charges per operation) / D1 queue padanan |
| rate-limiting | gaps | D1 counters (KV driver pending); throttle build-gated |
| readme | n/a | meta |
| redis | port | KV/D1 drivers; Upstash REST optional |
| releases | n/a | meta; pin 13.x policy |
| requests | gaps | kernel request subset |
| responses | gaps | kernel responses + SSE |
| reverb | port | WS server → DO/external/SSE padanan |
| routing | runs | compiled router |
| sail | n/a | local Docker |
| sanctum | gaps | tokens; SPA via sessions (padanan) |
| scheduling | gaps | wired (M29): the build dumps the app schedule into dist/src/schedule.generated.mjs + dist/cron.json (the Cloudflare trigger list); the worker's scheduled handler queues the due events for the app runtime (the edge triggers, it does not run artisan) |
| scout | gaps | engines via HTTP; D1 FTS fallback (padanan) |
| search | gaps | FTS5; vector → CF Vectorize padanan |
| seeding | gaps | local dev tooling; no runtime code (unverified) |
| session | gaps | D1 store; write budget soft; driver kv/encrypt pending |
| socialite | gaps | fetch-based OAuth (padanan) |
| starter-kits | gaps | kit = app code; covered with auth + blade (padanan) |
| strings | gaps | Str shim subset; fail loud |
| structure | runs | §7 source of truth |
| telescope | n/a | local debugger |
| testing | runs | local PHPUnit + edge parity harness |
| upgrade | n/a | meta; upgrade = recompile + parity |
| urls | runs | url/route/asset; signed URLs |
| valet | n/a | local env |
| validation | gaps | the full rule surface (KNOWN_RULES + the conditional/presence sets); $request->validate() → redirect-back / 422 JSON (M19); unique/exists via D1; $attributes display names + wildcard custom messages (T10); a FormRequest compiles its rules()/messages()/attributes() and its prepareForValidation() hook (the compiled class extends the kernel's kernel/form-request.mjs, the request surface the hook uses); the timezone rule tests PHP's own identifier list (kernel/timezone-ids.data.mjs, generated by tools/timezone-ids.php; Intl's list is a different set); the messages are Laravel 13's own (kernel/validation-messages.mjs + the generated kernel/validation-messages.data.mjs, the app's published lang/ file wins, the build dumps it); a rule object (Rule::file()->max()) and a FormRequest that overrides prepareForValidation() fail loud (the compiled rung evaluates the string rules only); the rule parameters are parsed like Laravel (CSV - in:"a","b" works, nothing is trimmed) (M61a) |
| verification | gaps | signed URLs + mail (signed URLs ok; mail wiring pending) |
| views | gaps | compiled view handlers (echo/if/concat subset) |
| vite | runs | manifest shims; proven today |
The feature map
The coverage index above is the reader-facing view of this map: 49 rows across 7 areas, each stating what Laravel does, what the edge does instead, and the trade where there is one.
A. Routing & HTTP
| Laravel | on the edge | verdict | note |
|---|---|---|---|
| Basic routes, params | Compiled router (pattern match) | runs | from route:list dump |
| Named routes, route(), url() | Frozen name map | runs | |
| Route groups / middleware | Compiled pipeline | runs | handleHttp runs web group + auth/guest/throttle; 11-step oracle parity (M15) |
| Redirects | Response redirect | runs | redirect()/->to/->route + ->intended() (M20, pulls url.intended); back() needs session |
| Signed URLs | HMAC in kernel | runs | same key derivation |
| Route model binding | Compiled lookup | gaps | id-first subset initially |
| Custom middleware | Compiled like controllers | runs | |
| Throttle middleware | Counter in D1/KV | runs | numeric throttle:n[,m] wired in handleHttp over the D1-backed database cache store (M18); named throttle:name needs its AppServiceProvider definition → build fails loud |
| CSRF | Token in session store | runs | kernel PreventRequestForgery wired in handleHttp + end-to-end oracle parity (M15) |
| Cookie encryption | Laravel Encrypter parity (AES-CBC + HMAC) via WebCrypto | runs | EncryptCookies wired in handleHttp; round-trip + prefix parity in cookies.test.mjs |
B. Views
| Laravel | on the edge | verdict | note |
|---|---|---|---|
| Echo, @if/@foreach/@include/@extends/@section... | Compiled JS render fns | runs | |
| @vite / @fonts | Runtime shim reading build manifest | runs | already proven in snapshots |
| @php blocks | Restricted subset | gaps | else build error (no silent) |
| Blade components (x-..., slots) | Phase 2 | gaps | padanan now: inline partial |
| Custom directives | Registered renderers | gaps | later |
C. App code
| Laravel | on the edge | verdict | note |
|---|---|---|---|
| Controllers / closures | PHP-subset → JS | runs | multi-verb controllers compiled (GET/POST/PUT/PATCH/DELETE, one handler per verb): scalar params + Request $request injection, model chains + views, session()/old()/csrf_token()/csrf_field()/request()/cookie()/auth()/Auth::, response()/response()->json(), Cache::/Hash::* facades, class constants (RouteServiceProvider::HOME, dumped at build time), Eloquent writes (create/fill/save/delete, $model->attr = ...); fail loud elsewhere |
| Service classes / helpers | PHP-subset → JS | runs | |
| Form Requests | Compile rules + authorize | runs | rules()/messages()/attributes() are dumped at build time (tools/formrequest-dump.php) and compiled into the handler: the request is validated before the method body (like the container resolving the FormRequest), and validated() returns the ruled attributes. authorize() must return true, prepareForValidation() is not supported → build fails loud. A class-typed parameter that is not a FormRequest is a service → build fails loud |
| Validation | Rule subset in kernel; unique/exists via D1 | gaps | $request->validate()/validateWithBag() compile to an awaited kernel-validator call; a failure renders like Laravel, 302 back (Referer → previous URL → /) with flashed errors + _old_input (minus dontFlash), or 422 JSON for expectsJson. $attributes display names (exact/wildcard/PHP-truthiness/raw-implicit/Str::snake fallback) and :attribute custom-message replacement are wired; json session serialization only (the ViewErrorBag php-serialization path fails loud) (M19/T10). A date-comparison parameter (before/after/date_equals) reads Carbon's relative grammar, weekday, month name, noon/3pm, +3 days/2 weeks ago, next week, first day of january, verified 0/405 divergent vs PHP 8.3 (audit #671) |
| Events / Listeners | Synchronous compiled | runs | queued listeners → queue |
| Exceptions / error pages | Kernel map → static error pages | gaps | resources/views/errors/{code}.blade.php compile at build time and render for abort()/HTTP errors and for an unhandled exception (500; its expectsJson answer is Laravel's {"message":"Server Error"}) when present; otherwise status-only stubs |
D. Data
| Laravel | on the edge | verdict | note |
|---|---|---|---|
| Migrations | PHP-side, applied to remote D1 (SQL export / wrangler) | runs | ops-time, not request-time |
| Seeders / factories | Local + SQL import | runs | |
| Eloquent CRUD | Generated model metadata + D1 | gaps | subset: find/findOrFail/all/where/whereIn/orderBy/limit + writes (create, fill, save, delete, forceDelete, restore, $model->attr = …); the write path needs the D1 binding (rt.db()) |
| Relationships | includes / eager subset | gaps | belongsTo, hasMany first |
| Casts / accessors | Compiled | gaps | |
| Query builder subset | Kernel builder → SQL | gaps | |
| Raw SQL | Pass-through to D1 | runs | |
| Transactions | D1 atomic batch() | gaps | interactive tx unsupported (platform) → padanan: single-request batch |
| Pagination | Length-aware paginator | runs | |
| Search | D1 (SQL LIKE / FTS5); vector → CF Vectorize padanan | runs |
E. State & security
| Laravel | on the edge | verdict | note |
|---|---|---|---|
| Sessions | D1-backed store + encrypted cookie | runs | kernel Store + cookie/D1 handlers, oracle byte-parity (M11); wired in handleHttp, payload bytes compared per step (M15); regenerate()/invalidate()/regenerateToken() wired from compiled controllers (M20) |
| Auth (guard, login/logout, remember) | Kernel + D1 users | runs | kernel SessionGuard, EloquentUserProvider, BcryptHasher, auth/guest middleware oracle-verified (M14); wired in the compiled pipeline + end-to-end parity (M15); the auth.basic middleware is honoured (Basic header → guard->attempt(), 401 + WWW-Authenticate: Basic realm="Basic" on failure); a guard driver the app registers ITSELF (Auth::extend('seal', fn), a closure the rung cannot run) is answered by the app's own JS through a guard port (registry.json guards → ports/<slug>.mjs → PortedGuard, kernel/auth.mjs): the driver is the app's, the middleware/auth()->user()/401 answer around it stay compiled, user() runs at most once per guard per request, and a ported method the port does not export fails loud. An UNPORTED non-session driver refuses the build (it would otherwise answer for an unauthenticated request) |
| Password hashing | bcrypt via bcryptjs ($2y$ parity) | runs | kernel BcryptHasher make/check/needsRehash/verifyConfiguration (M5); free-plan CPU banked (measured); the Hash facade compiles to it (Hash::make/check/needsRehash/isHashed) with the app's hashing.bcrypt.rounds |
| Rate limiting | D1 counters (cache table) | runs | kernel RateLimiter + ThrottleRequests oracle-verified (M12); runs on the D1 database cache store (M18). increment is read-then-write, D1 has no interactive transactions |
F. Platform services
| Laravel | on the edge | verdict | note |
|---|---|---|---|
| HTTP providers (Resend etc.) via fetch | gaps | kernel/mail.mjs exists (log/noop/resend); wired (M28), the worker builds the mailer from the app config (mail.default → log/noop/resend) and hands it to the handler; the fluent Mail::to()->send() compiles (M57: a mailable is a class - the build compiles it and the kernel's Mailable base carries Laravel's fluent surface; the mailer sends the recipients, the subject and the rendered view) → the methods the kernel mailer does not implement (raw, html, queue, later) fail loud (padanan = HTTP provider; SMTP impossible, hard) | |
| Queues / Jobs | Cloudflare Queues (a limited version on the Workers free plan; the paid plan charges per operation - 10,000 operations/day included on the free plan) or D1 queue | gaps | wired (M28), the worker hands the queue adapter to the handler (a port can push); the Queue::push(SomeJob::class) facade fails loud, the job would never run at the edge: the worker has no queue handler, so nothing consumes the queue binding, and the binding carries a JSON body, not a PHP job (the runtime throws the same). A compiled job class is the supported form: Job::dispatch() writes the D1 lw_jobs row and the cron trigger drains it (M63), so the job runs after the response; the build refuses the push without a cron trigger, and refuses a push whose job class did not compile (the drain has no handler for it). The job classes compile (M63) → port |
| Scheduler | Cron Triggers → one dispatcher reads schedule | gaps | wired (M29): the build dumps the app schedule (cron expression + command) into dist/src/schedule.generated.mjs + dist/cron.json (the Cloudflare trigger list); the worker's scheduled handler queues the due events for the app runtime (the edge triggers, it does not run artisan), delivery needs the app's QUEUE producer binding (without it the event is dropped and the worker, the /__lw/cron answer and the build all say so); the trigger also drains the D1 job queue, which needs no binding. The HTTP face of the trigger (POST /__lw/cron) is closed by default: without LW_CRON_SECRET it answers 404 (indistinguishable from a missing route), with a secret the caller must present it in X-LW-Cron |
| Cache | D1 database store (Laravel parity) + KV cache-aside | runs | DatabaseCacheStore mirrors Illuminate\Cache\DatabaseStore 13.37, oracle-verified incl. raw rows (M18); KV stays for build-time/simple caching (eventual consistency trade); the Cache facade compiles to it (get/has/put/add/forever/forget/remember/pull/increment/decrement/touch/flush) |
| Storage / filesystem | Uploads → R2 or the D1 blob store; file_exists/public_path → build-time snapshot | gaps | no local FS (hard) → object storage is the padanan; wired (M28): the Storage:: facade compiles to the store (put/get/exists/delete) and the worker hands the adapter to the handler; M30: R2 when the app binds it, else the D1 blob store (one row per object in lw_blobs, no extra Cloudflare product, no card); with neither binding the build fails loud and names the missing one (no silent in-memory writes). Shadow-verified: the compiled side writes to the D1 blob store and reads it back byte-identically to the real app on the local disk. M40 part 5: the upload compiles, the multipart body is parsed at the edge, $request->file() answers Laravel's file object, $file->store()/storeAs() write to the app's default disk (the same store) and answer the disk-relative path; the file rule and the size rules (in kilobytes) work; the content type of a file (finfo) fails loud |
| HTTP client | fetch | runs | no raw sockets (hard); 6 concurrent conns → batch/queue |
| Logging | Console + optional external sink via fetch | gaps | no Log module; Log:: facade not mapped → port; console + fetch sinks only |
| Realtime / WebSockets | Polling / SSE; Durable Objects beyond | port | wall: no long-lived conn → padanan: polling/SSE first |
| i18n | Lang frozen to JSON; pluralization subset | gaps | Lang frozen to JSON; pluralization subset (see localization) |
G. Frontend / DX
| Laravel | on the edge | verdict | note |
|---|---|---|---|
| Vite build | Unchanged | runs | already working |
| Artisan | Local only; edge CLI (build/ship) | runs | |
| PHPUnit | Local unchanged; parity suite added | runs | |
| Livewire / Inertia / Filament | Out of v1; Inertia feasible (JSON port) phase 3; Livewire hard | port | documented, not promised |
| Telescope / Horizon / Debugbar | Local-only tools | n/a | N/A at edge |
The construct census
The documentation map answers “which Laravel features”. The census answers “which PHP constructs”: 118 audited, 47 compile, 64 fail loud and 7 are documented at build level. Each row carries the batch it was proven in.
The census, row by row ·where the refusals cluster ·how the audit was extracted
Source: docs/READINESS.md, MAP.md §5 — extracted from repository arfoux/laravel-workers at 1b5a50ac8 (v0.4.9)