laravel-workers 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -2
- package/bin/laravel-workers.mjs +141 -0
- package/build.mjs +2599 -0
- package/compiler/bin/controller.mjs +92 -0
- package/compiler/bin/transpile.mjs +65 -0
- package/compiler/runtime/rt-from-context.mjs +2399 -0
- package/compiler/src/classes.mjs +1232 -0
- package/compiler/src/controller.mjs +1029 -0
- package/compiler/src/dist-swap.mjs +76 -0
- package/compiler/src/mail-reach.mjs +104 -0
- package/compiler/src/policy.mjs +345 -0
- package/compiler/src/transpiler.mjs +3094 -0
- package/compiler/src/view-name.mjs +21 -0
- package/kernel/auth.mjs +1502 -0
- package/kernel/cache.mjs +405 -0
- package/kernel/collections.mjs +1615 -0
- package/kernel/cookies.mjs +474 -0
- package/kernel/csrf.mjs +698 -0
- package/kernel/db.mjs +703 -0
- package/kernel/digest.mjs +360 -0
- package/kernel/encrypter.mjs +106 -0
- package/kernel/form-request.mjs +106 -0
- package/kernel/hash.mjs +113 -0
- package/kernel/html-entities.mjs +268 -0
- package/kernel/http.mjs +1803 -0
- package/kernel/image.mjs +341 -0
- package/kernel/mail.mjs +269 -0
- package/kernel/mailable.mjs +172 -0
- package/kernel/model.mjs +1226 -0
- package/kernel/multipart.mjs +161 -0
- package/kernel/paginator.mjs +336 -0
- package/kernel/php-json.mjs +220 -0
- package/kernel/php-serialize.mjs +245 -0
- package/kernel/queue.mjs +96 -0
- package/kernel/ratelimit.mjs +855 -0
- package/kernel/redis.mjs +264 -0
- package/kernel/resources.mjs +857 -0
- package/kernel/response.mjs +17 -0
- package/kernel/router.mjs +276 -0
- package/kernel/scheduler.mjs +236 -0
- package/kernel/service-adapters.mjs +49 -0
- package/kernel/session.mjs +934 -0
- package/kernel/storage.mjs +265 -0
- package/kernel/str.mjs +445 -0
- package/kernel/tests/auth.test.mjs +1074 -0
- package/kernel/tests/budget.test.mjs +527 -0
- package/kernel/tests/cache.test.mjs +290 -0
- package/kernel/tests/collections.test.mjs +534 -0
- package/kernel/tests/cookies.test.mjs +302 -0
- package/kernel/tests/csrf.test.mjs +597 -0
- package/kernel/tests/db.test.mjs +1069 -0
- package/kernel/tests/encrypter.test.mjs +90 -0
- package/kernel/tests/fixtures/laravel-1337-cookies.json +120 -0
- package/kernel/tests/hash.test.mjs +149 -0
- package/kernel/tests/http.test.mjs +1182 -0
- package/kernel/tests/image.test.mjs +235 -0
- package/kernel/tests/login-flow.test.mjs +509 -0
- package/kernel/tests/model.test.mjs +324 -0
- package/kernel/tests/oracle-app.mjs +32 -0
- package/kernel/tests/paginator.test.mjs +132 -0
- package/kernel/tests/php-json.test.mjs +263 -0
- package/kernel/tests/php-serialize.test.mjs +210 -0
- package/kernel/tests/ports.test.mjs +47 -0
- package/kernel/tests/ratelimit.test.mjs +975 -0
- package/kernel/tests/readiness.test.mjs +155 -0
- package/kernel/tests/redis.test.mjs +175 -0
- package/kernel/tests/registry-json.test.mjs +28 -0
- package/kernel/tests/resources.test.mjs +275 -0
- package/kernel/tests/router.test.mjs +281 -0
- package/kernel/tests/routes-json.test.mjs +32 -0
- package/kernel/tests/services.test.mjs +474 -0
- package/kernel/tests/session.test.mjs +709 -0
- package/kernel/tests/sqlite-integration.test.mjs +390 -0
- package/kernel/tests/storage.test.mjs +133 -0
- package/kernel/tests/str.test.mjs +83 -0
- package/kernel/tests/upload.test.mjs +193 -0
- package/kernel/tests/validation-messages.test.mjs +117 -0
- package/kernel/tests/validation.test.mjs +340 -0
- package/kernel/tests/validator-hooks.test.mjs +343 -0
- package/kernel/tests/validator.test.mjs +805 -0
- package/kernel/tests/vendor-lite.test.mjs +39 -0
- package/kernel/validation-messages.data.mjs +168 -0
- package/kernel/validation-messages.mjs +249 -0
- package/kernel/validation.mjs +123 -0
- package/kernel/validator.mjs +1538 -0
- package/package.json +68 -5
- package/shadow/run.mjs +784 -0
- package/shadow/scenarios/fresh-app.mjs +60 -0
- package/shadow/scenarios/lw-constructs-app.mjs +669 -0
- package/src/ports/dbcheck.mjs +22 -0
- package/src/worker.mjs +200 -0
- package/tools/_root.php +135 -0
- package/tools/bench.mjs +129 -0
- package/tools/class-dump.php +87 -0
- package/tools/compile-view.php +263 -0
- package/tools/config-dump.php +192 -0
- package/tools/constants-dump.php +46 -0
- package/tools/construct-audit.mjs +326 -0
- package/tools/constructs.json +1510 -0
- package/tools/docs-check.mjs +149 -0
- package/tools/event-dump.php +320 -0
- package/tools/formrequest-dump.php +229 -0
- package/tools/gate.mjs +36 -0
- package/tools/http-oracle.php +200 -0
- package/tools/init.mjs +73 -0
- package/tools/integrations.json +69 -0
- package/tools/mailable-views.php +116 -0
- package/tools/make-cookie-fixture.mjs +87 -0
- package/tools/make-html-entities.mjs +106 -0
- package/tools/model-dump.php +198 -0
- package/tools/php-auth-oracle.php +701 -0
- package/tools/php-cache-oracle.php +166 -0
- package/tools/php-crypto-oracle.php +113 -0
- package/tools/php-csrf-oracle.php +271 -0
- package/tools/php-http-pipeline-oracle.php +358 -0
- package/tools/php-json-oracle.php +102 -0
- package/tools/php-login-flow-oracle.php +342 -0
- package/tools/php-provision.mjs +172 -0
- package/tools/php-ratelimit-oracle.php +508 -0
- package/tools/php-runtime.mjs +342 -0
- package/tools/php-serialize-oracle.php +114 -0
- package/tools/php-session-oracle.php +266 -0
- package/tools/php-shadow-oracle.php +588 -0
- package/tools/policy-dump.php +240 -0
- package/tools/ports.mjs +51 -0
- package/tools/prerender.php +68 -0
- package/tools/readiness.mjs +255 -0
- package/tools/registry-json.mjs +22 -0
- package/tools/routes-dump.php +27 -0
- package/tools/routes-json.mjs +48 -0
- package/tools/schedule-dump.php +82 -0
- package/tools/schema-dump.php +158 -0
- package/tools/validation-messages.php +54 -0
- package/tools/vendor-lite.mjs +299 -0
- package/tools/verify-s0.php +209 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 laravel-workers contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,296 @@
|
|
|
1
|
-
#
|
|
1
|
+
# laravel-workers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> A build-time toolchain: Laravel app in, Cloudflare Workers artifact out. Blade views, routes and the D1/read paths it can verify against PHP 8.3 are compiled to dependency-free JS at build time. What it cannot verify **fails the build** — no silently-wrong output ships.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/arfoux/laravel-workers/actions/workflows/ci.yml) [](https://www.npmjs.com/package/laravel-workers) [](LICENSE)
|
|
6
|
+
|
|
7
|
+
**What it is, plainly.** A toolchain with three parts: a `compiler` (Blade → PHP → JS, byte-parity against PHP 8.3), a Workers-native `kernel` (router, D1 query builder, models, validation, services), and `build.mjs` (pipeline → `dist/kernel` + `_routes.json` + schema snapshot). Laravel stays the source of truth in dev; the edge runs compiled JS + D1, **Free-plan-safe (measured 1–6 ms)**. It is **not** a Laravel runtime and **not** PHP-in-Worker: controllers ARE transpiled (a documented subset; anything outside it fails the build and needs a `ports/*.mjs` handler).mjs`.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
PHP-in-Worker (wasm) boots Laravel per request: **measured 1008–2018 ms CPU** → 503 on Free. `laravel-workers` moves all boot to **build time** and ships a tiered Worker: `static` (0 invocations) → `snapshot` (prerendered HTML) → `compiled handler` (router → handler → D1, **measured 1–6 ms**) → `port` (escape-hatch hand-written JS). Anything uncovered **fails the build** (`--strict`).
|
|
12
|
+
|
|
13
|
+
**Status: 0.4.0 prepared, the npm publish waits on a valid npm login.** The compiled rung covers M1-M70 (the construct audit: **114 constructs - 41 compile, 63 fail loud, 10 documented at the build level**; every facade of Laravel 13 has a row, the facade coverage is 47/47): controller transpilation (M10/M17/M22/M23), the kernel layers M11-M22 (M21 `ValidationException::withMessages`, M22 multi-verb URIs), the T0-T10 hardening pass (docs/code parity, strict extra-verb gate, request-call consistency, `rt.cache()`, compiled error views, `hashed` cast + `fillable`, validation display names), M23-M25 (FormRequests, `Cache`/`Hash` facades, class constants, Eloquent writes, `auth.basic`; the general shadow harness + a green CI with the PHP oracle parity running for real) and M28-M30b (the service adapters are wired — `Storage::` on R2 **or the D1 blob store**, mail, queue, and the scheduler as Cloudflare Cron Triggers; a fully-qualified static call resolves to the global class; the shadow harness proves the object store end to end). The 7c Breeze-login E2E is green (1/1). Still open per `COMPILED.md`: the *fluent* facades (`Storage::disk()`, `Mail::to()->send()`, `Queue::push(SomeJob::class)`) need a port (the disk/mailable/job classes are not compiled), and the route-level parity tests (the 11-step web pipeline, the Breeze login flow) run against the local fixture app.
|
|
14
|
+
- **M1** View parity — Blade-compiled PHP → dependency-free ESM; `welcome` byte-identical 35,647 B (sha256 `b228c8a2…`); corpus 51/51.
|
|
15
|
+
- **M2** Router integration — `routes → router`, code-gen `controllers/middleware`, build `--strict` PASS, `nearfind-edge` live `/` + `/health` 200.
|
|
16
|
+
- **M3** D1 layer — schema snapshot (`.cache/schema.json` → `dist/schema.json`), `kernel/db.mjs` query builder, `kernel/model.mjs` Model base, mock-D1 tests 8/8, `nearfind-edge` `/dbcheck` via builder, D1 remote has `config` + `_cf_KV`.
|
|
17
|
+
- **M4** Packaging + CI — `package.json` (`laravel-workers@0.3.0`, bin CLI), `.github/workflows/ci.yml`, MIT license, `npm pack --dry-run` clean, tests green.
|
|
18
|
+
- **M5** Hash + Encrypter parity (kernel/hash.mjs bcrypt make/check/needsRehash + kernel/encrypter.mjs AES-256-CBC+HMAC WebCrypto, payload iv/value/mac/tag byte-compat, incl. MAC-tamper/wrong-key)..
|
|
19
|
+
- **M6** Validation kernel (kernel/validator.mjs, 60+ rules incl. `unique`/`exists` via D1, pipe-safe regex, nullable/sometimes, bail, 26 subtests)
|
|
20
|
+
- **M7** Services kernel (kernel/mail,queue,cache,storage,scheduler.mjs, 18 subtests)
|
|
21
|
+
- **M8** Final gates (tools/docs-check 105/105 + tools/bench + budget.test p95<=5ms hot)
|
|
22
|
+
- **M9** Zero-touch hardening (2026-10-18) — scaffold stubs now follow the controller contract (default export, `{status, headers, body}`, `laravel-workers:stub` marker); `build --strict` fails on unimplemented stubs + missing port files; `LW_CONFIG`/`LW_PORTS_DIR` let registry + ports live outside the repo; `registry.json` optional (defaults + notice); routes defined outside `routes/` are listed as skipped. Verified: stub `501` keeps the worker alive → implement → `200` via local D1; external config+ports E2E; fresh Laravel 13.37 zero-config build PASS. `tools/ports.mjs` + 5 tests → npm test **170/170**.
|
|
23
|
+
- **M10** Controller transpilation v1 (2026-10-18) — `GET`/`HEAD` routes with a resolvable controller method are compiled at build time to `dist/src/controllers/*.mjs` (handler `(ctx, env)`): scalar route params, Eloquent chains (`find`/`findOrFail`/`all`/`where*`/`orderBy*`/`limit` + `first`/`get`/`count`/`pluck`/`value`), `view()`/`redirect()`/`abort()`/`compact()`/`response()->json()`, `route()`/`url()` from the frozen name map; model metadata dumped per FQCN; unsupported constructs fail loud (LW001–LW004 with a "write a port" hint). Verified on a fresh Laravel 13.37 app end-to-end: `--strict` build (snapshot `/` + 2 controllers) and `wrangler dev` against local D1 (`/posts` list, `/posts/1` detail, `/posts/999` → 404 via `findOrFail`). npm test **195/195**, corpus 51/51.
|
|
24
|
+
- **M11** Session kernel parity (2026-10-18) — `kernel/session.mjs`: PHP `Store` semantics (flash `_flash.new`/`_flash.old` rotation, `_token`, `ageFlashData`, `migrate`/`regenerate`/`invalidate`) behind cookie + D1 handlers; byte-identical cookie payloads vs the PHP 8.3 oracle (deterministic via `freeze_ms`/`render_now`).
|
|
25
|
+
- **M12** Rate limiting parity (2026-10-18) — `kernel/ratelimit.mjs`: `RateLimiter` + `ThrottleRequests` incl. named `for()` limiters, middleware `using()` prefix, HeaderBag last-limit-wins, PHP falsy/numeric/int semantics; PHP 8.3 oracle.
|
|
26
|
+
- **M13** CSRF parity (2026-10-18) — `kernel/csrf.mjs`: `PreventRequestForgery` token resolution (session / `X-CSRF-TOKEN` / `X-XSRF-TOKEN`), `except()` (SORT_STRING dedupe), `normalizeQueryString` (hex-key parse, ksort, RFC3986), XSRF cookie shape; PHP 8.3 oracle.
|
|
27
|
+
- **M14** Auth parity (2026-10-19) — `kernel/auth.mjs`: `SessionGuard` (attempt/attemptWhen/once/validate/login/logout/logoutOtherDevices, `viaRemember`), remember-me recaller + rotation + hmac cookie, `EloquentUserProvider`, `BcryptHasher` (`$2y$`-only, `needsRehash`/`verifyConfiguration`), `AuthManager`, `auth`/`guest` middleware, auth events. `tools/php-auth-oracle.php` + `kernel/tests/auth.test.mjs`: 13 guard runs compared 1:1 (session bytes, DB rows, event stream, Set-Cookie bytes), recaller hostile-payload corpus, hasher ops. npm test **269/269**.
|
|
28
|
+
- **M15** Compiled HTTP pipeline (2026-10-19) — `kernel/http.mjs` (`handleHttp`): runs the `web` group in vendor order (`EncryptCookies → AddQueuedCookiesToResponse → StartSession → ShareErrorsFromSession → PreventRequestForgery → throttle → route`) plus `auth`/`guest` route middleware, resolves provider models through `models.generated.mjs`, emits Symfony-shaped `cache-control`/redirect bodies. `tools/php-http-pipeline-oracle.php` + `kernel/tests/http.test.mjs`: an 11-step scenario (anonymous → login → CSRF failure → logout) compared **per step** on status, headers, `Set-Cookie` attributes, body and the raw D1 session payload bytes, plus `parsePhpQuery` vs live PHP `parse_str` (29 cases; the NUL-in-key divergence is pinned fail-closed) and fail-loud guards. npm test **276/276**.
|
|
29
|
+
- **M16** Compiled controllers — request + session/auth helpers (2026-10-19) — `Request $request` injection, `session()`/`session('k')`/`old()`/`csrf_token()`/`csrf_field()`/`request()`, `auth()`/`Auth::check|guest|user|id` (guarded by await-injection; the guard resolves the user asynchronously), and a `$request->method()` allowlist (`input`/`all`/`query`/`has`/`string`/`header`/`ip`/`user`/`session`/…). `$request->validate()` and files fail loud (need D1 + a port). npm test **282/282**.
|
|
30
|
+
- **M17** POST controller actions (2026-10-19) — the build now transpiles single-method `POST` actions, not just `GET`/`HEAD` (`build.mjs` `methodsOk`); the compiled handler is registered under `METHOD uri` and the router dispatches it. A route that answers several verbs on one uri still needs a port. Verified 1:1 on the fixture app: `POST /submit` (with `Request $request`) → `302`, `location: /posts`; `GET /submit` matches nothing (no handler). npm test **282/282**.
|
|
31
|
+
- **M18** D1 cache store + `throttle` middleware (2026-10-19) — `kernel/cache.mjs` gains `DatabaseCacheStore` mirroring `Illuminate\Cache\DatabaseStore` 13.37 (values `serialize()`d via `php-serialize.mjs` with the SQLite/Postgres NUL-byte→base64 rule, expiry as integer second timestamps, `add` = `INSERT OR IGNORE`, `put` = upsert, `forever` = +315360000 s, expired = read-then-delete, `currentTime()` = `Carbon::now()->getTimestamp()`). The `RateLimiter`/`ThrottleRequests` path is now async so it can drive the D1 store (each cache op is a round trip; `await` is a no-op on the sync `ArrayCacheStore`). `tools/php-cache-oracle.php` + `kernel/tests/cache.test.mjs`: put/add/forever/get/has/increment/decrement/forget/touch/flush replayed against the real `Repository` over SQLite, comparing return values **and the raw `cache` rows** 1:1. The build now emits `middleware` into `routes.generated.mjs` — it was previously dropped, which silently downgraded every compiled route to the session-less bare path — and honours numeric `throttle:n[,m]`, failing loud on named limiters and when `cache.driver !== 'database'`. `tools/config-dump.php` now emits `cache.{default,prefix,driver,table,lock_table,serializable_classes}` and `src/worker.mjs` builds the store from the D1 binding. npm test **288/288**.
|
|
32
|
+
- **M19** Validation flow — `$request->validate()` / `validateWithBag()` (2026-10-19) — compiled controllers may now call `$request->validate($rules, $messages?, $attributes?)`; the transpiler awaits a live `rt.request().validate(...)` that runs `kernel/validator.mjs` over the request input (D1 binding passed for `unique`/`exists`). A failure throws a new `ValidationException` (`kernel/validation.mjs`, mirroring `Illuminate\Validation\ValidationException`: status 422, `errorBag`, `redirectTo`, the `summarize()` "(and N more error(s))" message) which the compiled controller rethrows so `handleHttp` renders it like `Illuminate\Foundation\Exceptions\Handler` — a 302 back to `url()->previous()` (Referer → flashed previous URL → `/`) with `_old_input` (everything except `Handler::$dontFlash` = `current_password`/`password`/`password_confirmation`) and the `errors` bag flashed in the `Store::prepareErrorBagForSerialization` json shape (`{ bag: { format: ':message', messages } }`), or a 422 `{ message, errors }` body for `expectsJson`. Non-json session serialization (the `ViewErrorBag` `serialize()` path) fails loud. `kernel/tests/validation.test.mjs` (11 tests: exception surface, `installValidation`, and the compiled pipeline redirect/422/valid + fail-loud paths) -> npm test **300/300**.
|
|
33
|
+
- **M20** `redirect()->intended()` + controller session rotation (2026-10-19) — `redirect()->intended($default = '/')` compiles to `rt.redirect(rt.session().pull("url.intended", "/"))`, mirroring `Illuminate\Routing\Redirector::intended()` (the `url.intended` key the `auth` middleware writes on a guest redirect; default `/`). Compiled controllers may now call `session()->regenerate()` / `invalidate()` / `regenerateToken()` (and through `$request->session()`); `regenerate`/`invalidate`/`migrate` are awaited against the handler while `regenerateToken()` stays sync, matching `Illuminate\Session\Store`. With `$request->validate()` (M19) and `Auth::attempt` (M16) a stock Breeze-shaped login controller now compiles end-to-end; the only remaining gap is a class-constant default such as `RouteServiceProvider::HOME` (use a string literal or a port) and FormRequests. `compiler/tests/controller.test.mjs` (+3: `intended()` default + arg, session-method await/sync codegen, and a runtime `intended()` pull + regenerate ordering test) -> npm test **303/303**.
|
|
34
|
+
- **T4/T7/T8** cache facade + compiled error views (2026-10-19) — `rt.cache()` exposes `get`/`put`/`forget`/`remember`/`has`/`pull` over the D1 `database` store; every `resources/views/errors/{code}.blade.php` is compiled at build time into `dist/src/views/errors.<code>.mjs` + context, wired through `error-renderer.mjs`, and rendered for `abort()`/HTTP errors (`419`/`429`/`500` included) when present. Verified by building the fresh fixture with a `404.blade.php` and rendering `renderErrorView(404, …, { getMessage: () => 'Missing page' })` -> the compiled HTML.
|
|
35
|
+
- **T9** `hashed` cast + build-time `fillable` (2026-10-19) — `tools/model-dump.php` now dumps `getFillable()`; `Model#set()` applies `hashed` synchronously (`makeSync`, `$2y$`) with PHP 8.3 `password_get_info` semantics: plaintext hashes, full `$2y$` with cost ≤ rounds passes through, `$2a$`/`$2b$`/truncated `$2y$` re-hash, argon2 or cost > rounds throws `RuntimeException("Could not verify the hashed value's configuration.")`, null stays null, mutators win. Rounds are baked from `hashing.bcrypt.rounds` (default 12); cost 12 measured ~679 ms CPU on Workers (matches Laravel, above the nominal Free 10 ms budget — login/reset paths only). Divergence: models without `$fillable` keep the allow-all fallback (`guarded` semantics not implemented).
|
|
36
|
+
- **T10** validation display names (2026-10-19) — `$attributes` exact key, primary wildcard key, wildcard patterns (one dot-free segment per `*`, `preg_quote`-safe for punctuation like `-`/`:`), PHP truthiness (`''`/`'0'`/null fall through), raw name for implicit wildcard fields, and `Str::snake` + `_`→space fallback; custom messages support `field.rule`, `rule`, `field`, and wildcard keys, replace `:attribute`/`:ATTRIBUTE`/`:Attribute`, and resolve size-rule arrays by attribute type (`numeric`/`array`/`string`) with wildcard arrays indexed by rule name; empty custom messages are kept (Laravel's `is_null` check). Pinned to the Laravel 13.37 `FormatsMessages` + PHP 8.3 oracle. npm test **342/342**.
|
|
37
|
+
- **M21/M22** `ValidationException::withMessages()` + multi-verb URIs (2026-10-19) — a compiled controller may throw `ValidationException::withMessages([...])` (the same exception class the kernel catches), and a URI may answer several verbs: the primary GET/POST handler plus one extra handler per verb (`login--post.mjs`), keyed `METHOD uri` in the route table.
|
|
38
|
+
- **M23** The construct closure (2026-10-19) — FormRequests compile (`rules()`/`messages()`/`attributes()` are dumped at build time, the request is validated before the method body, `validated()` returns the ruled attributes), the `Cache`/`Hash` facades compile, class constants (`RouteServiceProvider::HOME`) are dumped and baked in, Eloquent writes compile (`create`/`fill`/`save`/`delete`/`$model->attr = ...`), `response()` returns a `text/html` response, and the `auth.basic` middleware is honoured (401 + `WWW-Authenticate`). The construct-coverage app builds `--strict` with 10/10 controllers.
|
|
39
|
+
- **M24** The shadow harness (2026-10-19) — `shadow/run.mjs` + `tools/php-shadow-oracle.php` boot the real app (PHP 8.3 + sqlite) and the compiled worker and diff status/headers/body byte-wise over a scenario (four verdicts: byte-identical, shape-verified, documented divergence, unexplained). It found six real divergences (the `response()` content-type, the default `Cache-Control`, the model timestamps using the wall clock, the `auth.basic` challenge, the error body, and a missing-credentials request passing) — all fixed. Construct app: 19/21 byte-identical + 1 shape + 1 documented, 0 unexplained; fresh app: 10/10 byte-identical.
|
|
40
|
+
- **M25** CI green (2026-10-19) — the PHP oracle parity tests skip (with a reason) when no Laravel app is available, and a new CI job creates a fresh Laravel app and runs the parity for real (auth, cache, csrf, session, rate limiter).
|
|
41
|
+
- **M28** The service adapters are wired (2026-10-20) — the worker builds the storage (R2), mail (log/noop/resend) and queue (CF Queues) adapters from the app config and hands them to the handler; the `Storage::` facade compiles to the R2 store (`put`/`get`/`exists`/`delete`) and fails loud without the binding. The fluent `Mail::to()->send()` and `Queue::push(SomeJob::class)` facades still need a port (job/mailable classes are not compiled).
|
|
42
|
+
- **M29** The scheduler is wired (2026-10-20) — the build dumps the app’s schedule (`tools/schedule-dump.php`) into `dist/src/schedule.generated.mjs` plus `dist/cron.json` (the Cloudflare Cron Trigger list — Free allows 5 triggers/account); the worker exposes a `scheduled` handler (and `POST /__lw/cron`) that evaluates the due events and queues them for the app runtime — the edge triggers, it does not run artisan. Events whose cron expression Cloudflare cannot express are reported in the build log.
|
|
43
|
+
- **M30** The object store is pluggable + the FQCN fix (2026-10-20) — `Storage::` now has two backends: **R2** when the app binds it, else the **D1 blob store** (one row per object in `lw_blobs` — no extra Cloudflare product, no credit card; the build prints which store it picked and fails loud when the app binds neither). The build also emits `dist/schema.sql` (the app's tables + `lw_blobs`) so the D1 schema can be created with `wrangler d1 execute DB --file=dist/schema.sql`. A fully-qualified static call (`\Illuminate\Support\Facades\Storage::put()`, `\App\Models\Post::create()`) now resolves to the global class instead of the controller's namespace, and the `laravel-workers shadow` CLI command runs the shadow harness. The shadow harness now proves the store end to end (`POST /storage-demo`: the compiled side writes to the D1 blob store, the real app to the local disk, and both read the object back — **20/22 byte-identical, 0 unexplained**). npm test **369/369**.
|
|
44
|
+
- **M31** The packaging gate (2026-10-20) — `tools/pack-smoke.mjs` (`npm run test:pack`) packs the repo, installs the **tarball** into an empty project and runs the *installed* toolchain against a real Laravel app (the CLI build, the compiler import, the shadow harness). The published file set is proven, not assumed; the CI e2e job now installs the tarball instead of the workspace.
|
|
45
|
+
- **M32** The `Storage::` facade keeps Laravel's return values (2026-10-20) — read in the vendor: `FilesystemAdapter::put()` answers a **bool**, `delete()` answers a **bool** (`false` when the file was missing) and `get()` **throws** `UnableToReadFile` for a missing file. The compiled store now answers the same (`put` → `true`, `delete` → a bool from the D1 `meta.changes` / the memory Map / an R2 `head()` check, a missing `get` → a loud failure, not `null`). The shadow step exposes the values (`hello-shadow|yes|yes|yes|no`) and both sides are byte-identical.
|
|
46
|
+
- **M33** The helper verbs (2026-10-20) — `abort_if()`/`abort_unless()` compile (they answer null unless they abort), `now()`/`today()` follow the **request clock** (the oracle freezes Carbon; the wall clock diverged — a hidden parity bug), and the `Log::` facade compiles to the **platform log** (the Workers console, the padanan for the app log file). The shadow step proves it: `POST /verbs-demo` answers the same date on both sides. npm test **372/372**.
|
|
47
|
+
- **M35** The construct audit is generated (`npm run audit:constructs`): 31 constructs probed against the real compiler - 19 compile (and are proven by the shadow harness), 10 fail loud with their code, 2 are build-level (a Blade layout, an `OPTIONS` route). The gate (`npm run gate`) keeps the table and the code in sync. The plan: **M39** the class-bearing calls (`Event::dispatch()`, `Gate`, `Mail::to()->send()`, `Queue::push(SomeJob::class)`, `Storage::disk()` - they need the class compiler), **M40** the build-level constructs (the Blade layouts, `paginate`, the eager relations, the uploads, the `OPTIONS` route).
|
|
48
|
+
- **M34** The `new` expression (2026-10-20) — only the **PHP built-in exceptions** compile (`new RuntimeException('boom')` → an error with the same class name and message; the handler answers the same 500 as Laravel). An app class (`new App\Exceptions\Mine`) is **not** compiled: it fails loud with LW002, it never becomes a silent stub. `throw_if()`/`throw_unless()` compile too. The step found two real harness bugs: the oracle let whoops print the debug error page over its JSON (whoops exits → the whole run died), and an uncaught error on the compiled side escaped the harness — both fixed, and the 500 is now a documented divergence. npm test **375/375**.
|
|
49
|
+
- **M36** The flash (2026-10-20) — `redirect()->with()`, `->withInput()` and `->withErrors()` compile: the flash goes into the **session** (Laravel reads it on the *next* request), reusing the same error-bag shape as the validation flow. The shadow harness proves it end to end (`POST /flash-demo` → 302 → `GET /flash-target` reads back `saved|Flash me`, byte-identical on both sides). npm test **377/377**.
|
|
50
|
+
- **M37** The helper surface (2026-10-20) — `redirect()->away()`, `back()`, `env()`, `data_get()`, `tap()`, `report()`, the `cache()` helper and `session([...])` compile. `env()` is padded by the build-time config dump (there is no `.env` at the edge). The step found **three real bugs**: `config()` never reached a compiled controller (the router's context did not carry it), the D1 cache store wrote an **already-expired** row for a `put()` without a TTL (Laravel: a TTL of 0 means *forever*), and the `cache()` helper's semantics (`cache('k','v')` is a *get* with a default, not a put). npm test **379/379**.
|
|
51
|
+
- **M38** The `Str` family (2026-10-20) — `kernel/str.mjs`: 22 string helpers (`slug`, `limit`, `upper`, `lower`, `studly`, `camel`, `snake`, `kebab`, `title`, `contains`, `startsWith`, `endsWith`, `after`, `before`, `length`, `replace`, `replaceFirst`, `replaceLast`, `squish`, `ucfirst`, `ucwords`, `ascii`) — every value is **verified against the real Laravel** (`kernel/tests/str.test.mjs` carries PHP's own answers), and the shadow step proves them end to end. A helper outside the surface fails the build (LW002), it never becomes a silent stub. npm test **380/380**.
|
|
52
|
+
- **M42** A relation (or an accessor) read as a **property** (`$post->comments`) used to answer `null` - a wrong answer, silently, which is worse than a 500. Now the **build stops** (LW003): the model dump (`tools/model-dump.php`) also lists the methods the app declares on the model (the relations, the accessors), and the compiler rejects a property read on a model variable. The runtime model shim guards the same names, so a Blade view (which carries no type information) fails loud instead of rendering an empty relation. The construct audit has 32 rows now. npm test **385/385**.
|
|
53
|
+
- **M41** The last known gap is closed (2026-10-20) - a method call on a **model** (`$post->comments()`), on a **query builder** (`$q->whereLike()`) or on an **unknown variable** used to compile and answer a 500 at the edge. Now the **build stops**: the relation is LW003, an unknown variable is LW002, and a method call in a Blade view is LW002 too (the error bag is the only object with methods there). The allowed model methods are the ones the compiled model really has (`kernel/model.mjs`), so `fill()`/`get()`/`toJSON()` keep working. The audit table now has **no third state**: every construct either compiles (and is proven) or fails the build. npm test **383/383**.
|
|
54
|
+
- **M39a** The **Gate** compiles (2026-10-20) - `Gate::allows()`/`Gate::denies()` used to fail loud (`unknown class/static Illuminate\Support\Facades\Gate::allows`): the policy is a class, so the check never compiled. Now the **policy is compiled at build time**: `tools/policy-dump.php` boots the real app, asks the Gate which policy answers for a model and dumps the abilities with Laravel's own guest rules; `compiler/src/policy.mjs` compiles the ability methods and the `before()` hook into async functions (`dist/src/policies.generated.mjs`). The runtime keeps Laravel's order: the policy `before()` first (a non-null answer wins), then the ability; a guest the policy does not allow is **denied without the method being called**; a missing ability is Laravel's `false`. The global Gate registrations are not compiled and fail loud with their own reason (`Gate::before()`/`after()`, a `Gate::define()` ability, a policy with a `__call()`, `Gate::authorize()`, `Gate::any()`). The shadow harness proves it: the same policy answers the same bytes as the real Laravel, for a signed-in user **and** for a guest. npm test **405/405**.
|
|
55
|
+
- **M39b** The class-bearing calls, **bounded** (2026-10-20) - free plan first, a little tolerance for R2. `Storage::disk('<the app's default disk>')` now compiles to the compiled object store (the D1 blob store - no card; R2 when the app binds it), fluent form included, with the same return values as the flat `Storage::` calls. A **named** disk fails loud (LW002): it needs a bucket of its own, and **R2 asks for a credit card** (the free path is the D1 blob store, 1.5 MB per object). `Cache::tags()` fails loud (the D1 `database` store has no tags - in Laravel the same store throws BadMethodCallException when the route runs, so the build refuses the route *earlier*: stricter than Laravel, not a parity), and the build now **fails the build** when a compiled route uses the cache on another driver - the compiled store would read and write a different store (a silent divergence). `Event::dispatch()`, `Mail::to()->send()` and `Queue::push(SomeJob::class)` fail loud with their reason: the listeners / the mailable / the job are classes, and at the edge there is no listener runtime and no queue consumer - a dispatch or a push would be a **silent no-op**. The **file upload** (`$request->file()`) failed loud here too (the edge had no multipart parser yet): it compiles since **M40 part 5**. npm test **412/412**.
|
|
56
|
+
- **M40** The build-level constructs, part 1 (2026-10-20) - the **Blade layouts**, the **route-model binding**, the **method handling**, the **paginator** (+ the **pagination links**) and the **PHP casts** compile now. A layout (`@extends` / `@section` / `@yield`) used to be a view-level construct the compiler could not answer (`$__env->…`): the env now carries the sections (Laravel's own order), it is shared with the nested render (the layout is rendered with the env of the view that extends it), and the build compiles the **view closure** (the compiled PHP names the views it uses) so the layout is compiled and imported too. The **route-model binding** (`show(Post $post)`) used to be an unsupported parameter type: the model now comes from the route segment (a missing model is a 404, like Laravel's `ModelNotFoundException`), the binding is checked the way Laravel binds it (by name) and a model that binds on another key fails loud. The **method handling** is Laravel's `AbstractRouteCollection`: an `OPTIONS` request gets the **automatic OPTIONS response** (200, an empty body, the `Allow` header with the verbs the URI accepts) and a method that does not match answers like Laravel (405 with an `errors/405` view, else 500) - the same status Laravel answers. The **CORS** config is not compiled: the build skips the routes under a CORS path (the `Access-Control-*` headers would be missing) and says what to do. The **paginator** (`Post::paginate(10)`) answers Laravel's `LengthAwarePaginator` and its **pagination links** render the framework's `pagination::tailwind` view - compiled at build time (the view name has a `::`, not a legal Windows file name, so the compiled modules use a sanitized name), **byte-identical** to Laravel's markup; the **translations** (`__()`) are dumped at build time (the app's `lang/` + the framework's fallback). The shadow harness proves all of it byte-for-byte - and it found a **silent wrong answer** on the way: `$arr[] = $x` (the PHP array append) compiled to `$arr[null] = $x`; now it appends. What is left in this batch: the **file uploads** (the last item - part 5). npm test **432/432**.
|
|
57
|
+
- **M40 (part 5)** The **file upload** compiles (2026-10-20) - the multipart body is parsed at the edge (`kernel/multipart.mjs`: the boundary, the fields, the files, the PHP field names - `photos[]` is an array), and `$request->file('x')` answers Laravel's file object: the **name** (the basename, like Symfony's `File::getName()`), the **extension** (`pathinfo`), the **type the browser declared**, the **size** and `isValid()`. The file is in the validated data (Laravel's `all()` merges the files), the `file` rule works, and the size rules of a file are in **kilobytes** (Laravel's `(string) ($file->getSize() / 1024)`, compared exactly) with Laravel 13's own message ("... must not be greater than 0 kilobytes."). `$file->store()`/`$file->storeAs()` write to the app's **default disk** (the D1 blob store on the free plan, R2 when the app binds it) and answer Laravel's disk-relative path (`trim($path.'/'.$name, '/')`); a **named** disk fails loud (it needs a bucket of its own). What cannot be answered like PHP **fails loud**: the content type of a file (`getMimeType()`, `guessExtension()`) and the `mimes`/`mimetypes`/`image`/`dimensions` rules read the file's **bytes** (finfo) - the edge has only the type the browser declared, so the build stops (LW002/LW003) instead of answering differently. The shadow harness proves the whole path **byte-identical** (the metadata, the store round trip and the 422 of a too-big file). npm test **449/449**.
|
|
58
|
+
- **M39c** The **class compiler**: the event listeners compile (2026-10-20) — `Event::dispatch()` used to fail loud because the listeners are classes and at the edge there is no listener runtime (a dispatch that answers nothing is a *silent* no-op). Now the build **compiles the classes**: `tools/event-dump.php` boots the real app and asks Laravel itself which listener answers which event (the explicit `$listen` **and** the auto-discovery of `app/Listeners`), `compiler/src/classes.mjs` compiles the constructor (the promoted properties) and the public methods (through the ordinary transpiler, so `$this` is the instance), and the dispatch calls them **synchronously**, like Laravel (every listener runs, only an answer of `false` stops the chain, and the dispatch answers the array of the responses). The classes ship in `dist/src/classes.generated.mjs`. What still fails loud: a **queued** listener (`ShouldQueue`: Laravel runs it later in a worker; the edge has no consumer), a dispatch of an event **name** (no class, no listener) — and `Queue::push()` / `Mail::to()->send()` stay fail-loud on purpose: a queued job would never run at the edge (the worker has no queue handler) and a mailable renders a Blade view (the compiled rung cannot prove the rendered payload is byte-identical). The **constructor** of a compiled class may have a body (the assignments land on the instance, like `$this->x = ...`). The shadow proves it: the compiled listener writes to the post exactly like Laravel's (`POST /event-demo` — byte-identical). npm test **463/463**.
|
|
59
|
+
- **M44** The **service injection of the listeners** (2026-10-21) - a listener whose constructor asks for a **service** used to fail loud ("the edge cannot inject one"). Now the build knows what the constructor wants: `tools/event-dump.php` asks the **real container** (the dump carries each constructor's parameters) and the build compiles the **dependency** - an app service the build compiled (the edge builds it) or a framework service (`kernel/service-adapters.mjs`: the mailer, the logger - the worker hands the live one down, `compiler/runtime/rt-from-context.mjs`). A type written short (`Mailer`) resolves through the file's `use` statements and its namespace; a name several compiled classes share fails loud; the async methods of an injected service are **awaited** in the compiled body (a promise must never leak into the response) and an app service (a compiled class) gets the runtime as its first argument. A service the build does not compile still fails loud, and it names the class. The construct audit: **53 constructs** (33 compile, 17 fail loud, 3 build level).
|
|
60
|
+
- **M45** The **reasons** of the two refusals, honest (2026-10-21) - `Queue::push()` and `Cache::tags()` failed loud, but each reason was blunt or misleading. The queue: 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 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. The cache: the D1 `database` store has no tags - in Laravel this route 500s with `BadMethodCallException("This cache store does not support tagging.")`; the build refuses the route before it ships, which is stricter than Laravel, not a parity. Both refusals hold in the **compiled classes** too, and the class path now carries the fail-loud code like the controller path (two new construct rows, two new class tests). The construct audit: **55 constructs** (33 compile, 19 fail loud, 3 build level).
|
|
61
|
+
- **M46** The **reasons**, corrected (2026-10-21) - M45's queue reason claimed a paid product; that is not what the project decided (only **R2** asks for a credit card, CF Queues is free at 10k ops/day). The message now follows the **runtime's own wording** (`rt.queue()` throws the same): 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 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 claim "the edge can push the job" goes too: `rt.queue().push()` throws even with a binding injected. `Cache::tags()`: in Laravel this route 500s with `BadMethodCallException("This cache store does not support tagging.")`; the build refuses the route before it ships - stricter than Laravel, not a parity. The dead code goes too: `QUEUE_ASYNC` and the unreachable `Queue` branch are deleted (nothing could reach them), so no reader believes `Queue::push` compiles. And the **class path carries the fail-loud code** now (like the controller path), so the two class construct rows can name `LW002`.
|
|
62
|
+
- **M47** The **audit** + the honesty fixes (2026-10-21) - a deep audit of the compiler, the runtime, the kernel, the build and the proof surface found three silent-wrong-answer bugs and three honesty gaps. The **dispatch** stopped at the first listener that answered; Laravel's `Dispatcher::dispatch()` runs *every* listener, only an answer of `false` stops the chain, and the dispatch answers the array of the responses - a second listener was silently skipped and the return value was wrong. A **circular service dependency** recursed forever (a stack overflow with no cause) - now a named `circular dependency detected [A -> B -> A]`. The **arithmetic** was JavaScript's: a non-numeric string in `+` answered 0 where PHP 8 throws a `TypeError`, a division by zero answered `Infinity` where PHP throws a `DivisionByZeroError`, and the modulo did not truncate. The **construct audit** now checks the *reason* a construct fails loud (19 rows record it) - a construct that fails loud for another reason is drift. **Every test file runs now** (`compiler/tests/rt-str.test.mjs` was not in the explicit `npm test` list, so it never ran). The **tiers** are three levels, not two: free plan (no card), Workers paid plan (a subscription), and a payment method (R2 asks for a card). The **sweep plan** (M48+, one milestone per Laravel page group) is in `COMPILED.md` §8b. npm test **488/488**.
|
|
63
|
+
- **M48** The **JSON bytes are PHP's** (2026-10-21) - the audit found the JSON answers were JavaScript's: `response()->json($x)` and `json_encode($x)` used a plain `JSON.stringify`, so the slashes were not escaped and the non-ASCII was not escaped - every JSON response with a URL or a non-ASCII character was a different body than Laravel's. The kernel's PHP-faithful encoder (`kernel/php-json.mjs`) is used now (the response helper, `json_encode()` and the two error bodies). npm test **491/491**.
|
|
64
|
+
- **M49** The **registry grows** (2026-10-21) - the sweep is an enumerable number now: every facade of the vendored `Illuminate/Support/Facades` (Laravel 13, **47**) has a row in `tools/constructs.json` and the audit prints the coverage (47/47). The audit also checks the *reason* a construct fails loud. Two more silent no-ops are refused at build time: a **broadcast listener** (`ShouldBroadcast` - the edge has no broadcast driver, so the notification would never be sent) and an **after-commit listener** (`ShouldDispatchAfterCommit` - the edge has no transaction). `Event::dispatchUntil()` fails loud with the halt semantics named, an unmapped facade no longer blames a model (`DB::select()` used to say "is not a registered Eloquent model"), and a **circular service dependency** is caught at build time with Laravel's wording. npm test **493/493**.
|
|
65
|
+
- **M51** The **audit's P0s** (2026-10-21) - the parallel audit (the real compiler against PHP 8.3.30) found six constructs that compiled to a DIFFERENT result than Laravel: a **foreach by reference** (the write-back was lost - now it fails loud), the **array spread** (a nested array - now it compiles), **match()** (the int/float distinction was lost - a float literal stays a float now), a **constructor that needs an await** (a JS SyntaxError, so the whole worker failed to import while the build said OK - now it fails loud), a **declared class property** (dropped, so the instance answered null where PHP answers 0 - the declarations compile now) and a **`= null` default** (refused with a wrong reason - it compiles now). npm test **499/499**.
|
|
66
|
+
- **M50** The **arithmetic's last PHP 8 gaps** (2026-10-21) - probed against PHP 8.3 itself: array + array is the union (`[1] + [2]` => `[1]`, the left keys win; the compiled side answered 0), and the division/modulo by zero throws the PHP class name (`DivisionByZeroError`) so it survives to the 500. npm test **493/493**.
|
|
67
|
+
- **M52** The **elvis operator and ?? evaluate once** (2026-10-21) - the audit found two operators that ran a side effect Laravel never runs: the elvis emitted the test expression twice (`$i=1; $x = $i++ ?: 5` => PHP 2,1, the compiled side 3,2) and `??` evaluated both sides eagerly. Both follow PHP now. npm test **499/499**.
|
|
68
|
+
- **M53** The **shadow proves the dispatch runs every listener** (2026-10-21) - the fixture app has two listeners on the same event now, so the answer carries both effects: the shadow proves that every listener ran (38/44 byte-identical, 0 unexplained).
|
|
69
|
+
- **M54** The **class path** (2026-10-21) - a listener that uses a **trait**, one that **extends** a class and a class with a **magic accessor** compiled "OK" and were wrong at request time; all three fail loud now, and every class-path refusal carries a **code** like the controller path. npm test **502/502**.
|
|
70
|
+
- **M55** A **break inside a loop breaks the loop** (2026-10-21) - the audit found the switch's break label leaking into a nested loop, so a break inside the loop broke the SWITCH (the code after the loop was skipped). PHP: `foreach ([1] as $x) { switch ($x) { case 1: foreach ([7,8] as $y) { if ($y==8) { break; } echo $y; } echo 'A'; break; } echo 'L'; }` => 7AL - the inner break leaves the inner foreach only. Every loop now carries its own label. npm test **503/503**.
|
|
71
|
+
- **M56** The **continue in a switch + the increment** (2026-10-21) - a `continue` inside a switch continued the LOOP (PHP leaves the switch), an increment on a property was not a valid JS assignment target (a 500 at request time while the build said OK - it fails loud now) and the increment on a string answered NaN where PHP bumps the last byte ("a" -> "b", "z" -> "aa"). npm test **507/507**.
|
|
72
|
+
- **M57** The **mail construct** (2026-10-21) - a mailable is a class, so the build compiles it: the dump takes the app's `Mail` classes, the class compiler emits the class and the kernel's new `Mailable` base carries Laravel's fluent surface (`subject()`, `view()`, `with()`, `from()`, `to()`, `cc()`). `Mail::to($u)->send(new OrderMail())` compiles and the mailer sends the payload Laravel sends: the recipients, the subject and the **rendered view** (the mailable's data goes into the view - the unit tests lock the shape; the byte-for-byte proof against the real app's mail is the next stage). The methods the kernel mailer does not implement (`raw`, `html`, `queue`, `later`) fail loud - a mail that silently lost its `cc` would be a wrong answer. The mailer's pending also had a real bug (the recipients were the setters, so the payload was empty) - fixed. npm test **510/510**.
|
|
73
|
+
- **M58** The **mailable's correctness** + the audit's last findings (2026-10-21) - the review found real defects and fixed them: the chained `Mail::to($u)->send($m)` was **not awaited** (a floating promise), Laravel's Mailable has a **field and a method with the same name** (JavaScript shadows one with the other), Laravel **appends** the pending's recipients to the mailable's own, the view data is Laravel's `buildViewData()` (the public properties + the `with()` data), the mailable's `build()` is **protected** (it is compiled now), the payload is built in **one** place, every class-path refusal carries a **code**, and the audit's last three findings (`in_array` over an assoc array, the 2^53 compare, the negative string offset) are fixed. npm test **514/514**.
|
|
74
|
+
- **M59** The **mailable's 1:1 details** (2026-10-21) - four Laravel details were wrong and are fixed: the mailable's `from` is a list (the payload's from is flattened like `to`), the **`textView`** is rendered too (the plain-text part), the payload's fallbacks are **type-checked** (a prototype method is not a value), and Laravel keeps the **last** occurrence of a recipient at its own position. The mailable's `build()` is **protected** in Laravel, so a mailable's methods compile now (with a test that compiles a mailable SOURCE); the **negative string offset** (`$s[-1]`) compiles (`rt.strOffset` - PHP counts from the end); and the reason for a **model-typed constructor parameter** says what is true. npm test **516/516**.
|
|
75
|
+
|
|
76
|
+
- **M60** The **mail payload is proven byte-for-byte** (2026-10-21) - the proof is the shadow: the PHP oracle captures Laravel's `MessageSent` event (the from/to/cc/bcc/replyTo/subject/html/text) and the harness compares those fields with the kernel mailer's outbox - the `POST /mail-demo` step (a mailable + its own view) matches on all 8 fields. Three real gaps were fixed to get there: the dump took the mailables inside the constructor closure, the build did not compile a mailable's view, and the handler did not get the class registry. The shadow: **39/45 byte-identical, 0 unexplained**.
|
|
77
|
+
|
|
78
|
+
- **M61a** The **validation rules take Laravel's parameters** (2026-10-21) - Laravel parses a rule's parameters with `str_getcsv` (`ValidationRuleParser::parseParameters`), the edge split them with a plain `,`: a **quoted** parameter (the shape Laravel's own `Rule::in(['admin','user'])` normalizes to - `in:"admin","user"`) was compared with its quotes still on, so every valid value was rejected (a 422 where Laravel answers 200). The kernel parses them like PHP now (a quoted field keeps its commas, a doubled quote is one quote, nothing is trimmed - Laravel never trims) - probed against PHP 8.3 itself: 500 random parameter strings, 0 differences.
|
|
79
|
+
- **M61b** The **router matches Laravel's route resolution** (2026-10-21) - the optional route parameter was compiled after a mandatory separator, so `/users/{id?}` never matched `/users` (a 404 where Laravel answers 200); Symfony's `RouteCompiler` puts the separator inside the optional group, and the kernel does too now. The path is decoded before the route matches it (Laravel's `UriValidator`), so an encoded slash splits the segments. Probed against the vendored Symfony: 8 URIs x 18 paths, 0 differences.
|
|
80
|
+
- **M61c** The **model's `save()` is Laravel's** (2026-10-21) - the compiled save always wrote the row and bumped `updated_at`; Laravel writes only when the model is dirty (`Model::save()` -> `isDirty() ? performUpdate() : true`) and `updateTimestamps()` lives inside the update, so an unchanged save writes nothing. The kernel keeps Eloquent's original snapshot, writes the dirty attributes only and answers a bool.
|
|
81
|
+
- **M61d** The **transpiler awaits the async calls** (2026-10-21) - a terminal query on a variable (`$q = Post::where(...); $q->get()`) floated a promise (the value was a Promise, not Laravel's Collection: `count()` answered 0, the foreach ran zero times); the async model methods (`increment`, `load`, ...) floated and took the first argument as the db binding (the counter was never written); `related` does not exist in Laravel's Model and now fails loud (LW003); a call to a class's own method is awaited now.
|
|
82
|
+
- **M61e** **Every method of a class compiles** (2026-10-21) - the class compiler dropped a class's own protected/private helper, so the compiled body called a method that does not exist at the edge (a TypeError at request time, a green build). Laravel calls the helper from inside the class, so every method of the class compiles now.
|
|
83
|
+
- **M61f** The **Blade runtime answers like Laravel** (2026-10-21) - `@json` dropped Laravel's JSON_HEX_* flags (every `@json` body differed), `@yield`'s default and `@section`'s inline content were not escaped (Laravel escapes both), and `@foreach` over a Collection ran the body once (the runtime's own Collection was not iterable). The new test compiles the PHP that Laravel's own Blade compiler emits.
|
|
84
|
+
- **M61g** The **FormRequest dump refuses the hooks the edge does not run** (2026-10-21) - Laravel 13's FormRequest has five more overridable hooks (`withValidator`, `after`, `validator`, `validationData`, `passedValidation`) that the compiled rung silently ignored; the dump refuses each one now with a precise reason (what Laravel does, what the edge does instead, the working path), and each refusal is a row in the construct audit.
|
|
85
|
+
- **M61h** The **Str family answers like Laravel** (2026-10-21) - nine helpers were wrong: `title` (the word rule of `mb_convert_case(MB_CASE_TITLE)`), `ucwords` (Laravel's separator pattern), `contains`/`startsWith`/`endsWith` (an empty needle is false), `snake`/`kebab` (`ctype_lower`, not a lowercase guard), `slug` (Laravel's `'@' -> 'at'` dictionary), `limit` (the **display width**, so a CJK glyph is two columns), and `ucfirst`/`camel` (the first CHARACTER, `mb_ucfirst`/`mb_lcfirst`). Probed against the vendored Laravel: 42 strings x 25 helpers + every ASCII character x 12 helpers, 0 differences.
|
|
86
|
+
- **M61i** **`where('col', null)` is a where-null clause** (2026-10-21) - the compiled `where` bound a NULL and rendered `col = ?`, which matches no row in SQL, so the edge answered an empty set where Laravel answers the rows. Laravel reads a NULL value as `IS NULL` (`!=`/`<>` -> `IS NOT NULL`) and the kernel does the same now; `IS`/`IS NOT` left the operator list (SQLite has no such operator - Laravel reads them as values).
|
|
87
|
+
- **M61j** The **model's clock and its increment are Laravel's** (2026-10-21) - the model's `now()` was an arrow field, so it captured the base class and the request clock was ignored (the timestamps carried the wall clock); it is a static method now, so the compiled model class binds. And `increment()` was a read-modify-write plus a full save (it rewrote every column, bumped `updated_at` and answered the new value) - Laravel writes one atomic statement, leaves the other columns alone and answers the affected-row count.
|
|
88
|
+
- **M61k** The **value a controller returns is converted like Laravel** (2026-10-21) - the compiled handler returned the raw value to the kernel, which accepts only `{status, body}`: `return "hello";` was a 500 where Laravel answers 200. Laravel's `Router::toResponse` converts it (a string is the body, an array or an object is JSON, null is an empty 200, a model that was just created is a 201) and the compiled body does that now; a response the controller built itself passes through untouched.
|
|
89
|
+
- **M61l** **A model that overrides the route binding fails loud** (2026-10-21) - Laravel binds the model through the model's own `resolveRouteBinding()`, so an app that overrides it can scope the lookup; the compiled binding is a primary-key lookup, so such an app bound a different row at the edge (silently, with a green build). The build stops now (LW003) and names the method.
|
|
90
|
+
- **M61m** The **model's casts are PHP's casts** (2026-10-21) - the bool cast answered false for `'false'` (PHP: true), the int/float casts turned a non-numeric id into NaN (PHP takes the leading digits) and kept `1.9` as `1.9`, the decimal cast rounded on the binary double (`'2.675'` -> `'2.67'` where Laravel's BigDecimal says `'2.68'`), and the timestamp cast was NaN for a date string. All four are PHP's now (probed against PHP 8.3).
|
|
91
|
+
- **M61n** The **cron matcher answers like the cron library** (2026-10-21) - the day-of-month and the day-of-week were ANDed (cron ORs them when both are restricted: `0 0 1 * 1` fires on the 1st AND on every Monday), the tokens `?`, `L`, `<dow>L` and `<dow>#<n>` were not implemented (an unknown token became the field's minimum, so `0 0 L * *` fired on the 1st), and the month and weekday shared one alias table. Probed against `dragonmantank/cron-expression`.
|
|
92
|
+
- **M61o** The **model's JSON dates and its soft delete are Laravel's** (2026-10-21) - the model's JSON carried the raw DB strings where Laravel serialises the timestamps through `Carbon::toJSON()` (`2024-01-01T00:00:00.000000Z`), so every model's JSON body differed; and the soft delete wrote only `deleted_at` where Laravel's `runSoftDelete()` writes `updated_at` too (the restore bumps it as well).
|
|
93
|
+
- **M61p** The **controller resolves the uses and the route parameters like PHP** (2026-10-21) - a grouped import (`use App\Models\{Post};`) lost its prefix (so the route Laravel serves was refused), and the implicit binding only accepted the exact parameter name (Laravel also matches the snake_case form: `show(Post $blogPost)` binds on `/posts/{blog_post}`). Both are resolved like PHP now.
|
|
94
|
+
- **M61q** The **validator's remaining Laravel semantics** (2026-10-21) - `required` did not treat a whitespace-only string as empty; `in`/`not_in` compared with JS's `String()` instead of PHP's `(string)` cast (and missed Laravel's array branch); the dependent rules compared as strings where Laravel compares strictly (loose only for a bool/null, with the bool/null conversions); the parameter count was never checked (Laravel throws); and the rule name was not trimmed (Laravel trims it). All five are Laravel's now (probed against the real validator).
|
|
95
|
+
- **M61r** The **model's created flag and the appended dates** (2026-10-21) - Laravel sets `wasRecentlyCreated` in the insert paths, so a controller returning a model it just saved answers 201; the kernel only set it in `create()`. The appended attributes go through the same date serialisation as the timestamps now.
|
|
96
|
+
- **M61s** The **query builder accepts Laravel's operators** (2026-10-21) - `<=>` and `IS`/`IS NOT` are in Laravel's operator list, so the edge refused queries Laravel runs (`where('c','<=>',null)` is `IS NULL` there, `where('c','is',5)` renders `c is ?`). The list is Laravel's now.
|
|
97
|
+
- **M61t** The **optional route segment uses Symfony's requirement** (2026-10-21) - the kernel's `[^/]*` could capture an empty optional parameter where Symfony's `[^/]++` refuses the group (`/users/%2F` decodes to `/users//`: the edge answered 200, Laravel answers 404).
|
|
98
|
+
- **M61u** The **method override follows Symfony** (2026-10-21) - the edge preferred the `_method` parameter over the `X-HTTP-Method-Override` header (Symfony reads the header first) and accepted any value (Symfony throws for a non-A-Z override, which Laravel answers as a 500).
|
|
99
|
+
- **M61v** **A route that instantiates a class that did not compile is stopped** (2026-10-21) - the build compiled a route that sends a mailable the edge cannot compile (Laravel 13's modern API): it only logged a warning and the route shipped, so the edge answered a 500 at request time. The route is stopped now with the class's own reason; the mailable's view is also read in every shape (single or double quotes, `->markdown()`).
|
|
100
|
+
- **M61w** The **`filled` message and the blocked classes** (2026-10-21) - a `filled` failure was reported with the `required` rule, so the 422 body carried the wrong sentence (Laravel's `validation.filled`); and the blocked-class map only held the event classes, so a mailable that did not compile could still be instantiated at request time instead of being refused with its reason.
|
|
101
|
+
- **M62a** **A rule object is normalized at build time** (2026-10-21) - the FormRequest dump refused every rule object, but Laravel's validator reads the object's string form, so the edge can have the same string (`Rule::in(['admin','user'])` -> `in:"admin","user"`, which the kernel parses exactly like Laravel). The conditional objects (`Rule::requiredIf(true)`, `Rule::when(...)`) stay refused: their string form is the condition evaluated at build time, so the edge would bake the build-time branch into the rule.
|
|
102
|
+
- **M62b** The **`unique` rule reads the `NULL` placeholders like Laravel** (2026-10-21) - a normalized rule object writes the absent column and the absent id as the string `NULL`, so the kernel would have queried a column named NULL (Laravel falls back to the attribute's name) and added an `id != 'NULL'` (Laravel means "no exception"). Both are Laravel's now, and M62a's normalization got its regression tests.
|
|
103
|
+
- **M62b2** The **`unique` resolver: the wildcard column + the bracketed id** (2026-10-21) - probed against the real Laravel: the column of a wildcard attribute is the last dot segment (Laravel: `id`, the edge: `items.0.id` - a column that does not exist), and a bracketed id (`[id]`) is the field's value in the data (the edge bound the literal string).
|
|
104
|
+
- **M62c** The **request's `merge()` and the input union** (2026-10-21) - the kernel's request had no `merge()` (the FormRequest's `prepareForValidation()` needs it: Laravel replaces the value, it is not a deep merge, and the dot notation is honoured), and the input Laravel reads is the input source UNION the query bag - the edge read the source alone, so a query parameter was invisible to the validator.
|
|
105
|
+
- **M62d** The **FormRequest's `prepareForValidation()` is compiled** (2026-10-21) - the hook Laravel runs before the validation was refused by the dump, so the app could change the validated input and the edge never saw it. The FormRequest is a compiled class now (the kernel's new `kernel/form-request.mjs` base), the handler runs the hook before the validation, and the shadow proves it (the fixture's hook fills the missing title, so the step now passes the validation on both sides).
|
|
106
|
+
- **M62e** The **query builder refuses a bad order direction** (2026-10-21) - `orderBy('title', 'sideways')` answered ASC at the edge (a different row order, silently); Laravel throws, so the edge fails loud now.
|
|
107
|
+
- **A57** The **query builder's two Laravel edges** (2026-10-21) - `orderBy()` with a direction that is not asc/desc answered ASC at the edge (Laravel throws), and `sum()` answered null on an empty set where Laravel answers 0.
|
|
108
|
+
- **A58** The **hash surface is Laravel's** (2026-10-21) - `Hash::isHashed()` answered true for a `$2a$`/`$2b$` or a truncated digest where Laravel's `password_get_info()` says it is not a hash; and the async `Hash::make()` wrote bcryptjs's `$2a$` prefix where PHP writes `$2y$` (so the edge's own hashes were not recognised).
|
|
109
|
+
- **A58b** **A cache TTL of 0 forgets the key** (2026-10-21) - `Cache::put($k, $v, 0)` kept the row for ten years at the edge; Laravel forgets the key (`Repository::put()`). No ttl still means forever.
|
|
110
|
+
- **A58c** **The paginator's fragment is Laravel's** (2026-10-21) - Laravel stores the fragment without the '#' and `buildFragment()` adds it; the edge appended it as it was, so `fragment('list')` produced a URL without the '#'.
|
|
111
|
+
- **A58d** **The paginator's query string is PHP's** (2026-10-21) - Laravel builds it with `http_build_query(..., PHP_QUERY_RFC3986)` (booleans as 1/0, arrays in the bracket form, nulls skipped); the edge built its own, so the URL bytes differed.
|
|
112
|
+
- **A58e** **A Date is bound with the grammar's date format** (2026-10-21) - Laravel binds a DateTimeInterface as `Y-m-d H:i:s`; the edge bound an ISO string, so `where('created_at', $date)` never matched the stored column.
|
|
113
|
+
- **A58f** **The multipart delimiter needs its CRLF** (2026-10-21) - PHP's parser looks for CRLF + `--boundary`; the edge looked for the bare bytes, so a file whose content contains the boundary truncated the upload.
|
|
114
|
+
- **A58g** **`response()->json($model)` answers the model's JSON** (2026-10-21) - Laravel's Model is JsonSerializable, so json_encode answers its toArray(); the edge threw "unsupported value (Model)" and answered a 500.
|
|
115
|
+
- **A58h** **`find([1, 2])` returns the collection** (2026-10-21) - Laravel's find() with an array of ids returns a collection of the found models; the edge bound the array as one value, so the query matched nothing.
|
|
116
|
+
- **A67** **A mailable's view is attached per route** (2026-10-21) - the dump attached every mailable's view to every route, so one view that failed to compile skipped every route (the app shipped without routes); now only the views of the mailables a route actually sends are attached.
|
|
117
|
+
|
|
118
|
+
## Supported vs unsupported
|
|
119
|
+
|
|
120
|
+
Compiles today (byte-parity corpus + kernel tests):
|
|
121
|
+
- **Blade → PHP** is compiled by the real Laravel Blade compiler at build time; the transpiler consumes the resulting PHP subset: `echo`, `if/elseif/else`, `for`/`while`/`foreach` (incl. `key => value`), PHP comparison/logic semantics (`==`/`!=`/`===`/`!==`/`<`/`>`/`<=`/`>=`, `&&`/`||`/`and`/`or`/`!`), ternary/`?:`, `??`/`??=`, `switch` (loose, fallthrough), `match` (strict), `isset()`/`empty()`, function declarations (defaults, recursion), string interpolation (`"$x"`, `"{$a['k']}"`, escapes), string concat, `e()`, `sprintf`, `str_replace`, `strtolower`/`strtoupper`/`trim`/`strlen`/`substr`/`str_repeat`/`str_pad`/`ucfirst`/`lcfirst`, `str_contains`/`str_starts_with`/`str_ends_with`, `count`/`in_array`/`implode`/`explode`/`number_format`/`nl2br`, `array_keys`/`array_values`/`array_merge`/`array_key_exists`, `ltrim`/`rtrim`/`ucwords`/`strrev`/`strpos`/`strrpos`/`substr_count`/`strip_tags`/`htmlspecialchars`, `is_null`/`is_bool`/`is_int`/`is_float`/`is_string`/`is_array`/`is_object`/`is_numeric`, `abs`/`round`/`floor`/`ceil`/`intval`/`floatval`/`max`/`min`, `json_encode`/`json_decode`, `array_sum`/`array_slice`/`array_reverse`/`array_unique`/`array_count_values`/`array_flip`/`array_filter`/`array_map` (named-function callbacks).
|
|
122
|
+
- **Kernel**: router (domain rules + `{param}`/`{param?}`/`{param*}`, `X-HTTP-Method-Override`) + response, D1 query builder (where/or + `where(fn)` grouping, join/group/having/distinct, `whereDate`/`whereYear`/`whereMonth`/`whereDay`/`whereTime`, `sum`/`avg`/`min`/`max`, `upsert`/`insertGetId`/`truncate`/`increment`/`updateOrInsert`/`chunk`, D1-safe param binding: boolean→1/0, Date→ISO, undefined→NULL), Model base + relations (`hasMany`/`hasOne`/`belongsTo`, `create`/`firstOrCreate`/`updateOrCreate` honouring `fillable` + mutators + casts, `refresh`/`increment`, casts incl. `json`/`decimal:N`, accessors/mutators `getFooAttribute`/`setFooAttribute`, `hidden`/`visible`/`appends`, `softDeletes` with `trashed`/`restore`/`forceDelete`/`withTrashed`/`onlyTrashed`, `findOrFail`/`firstOrFail`, declared relations with `with()` eager loading + `withCount()` counters + `withSum()`/`withAvg()`/`withMin()`/`withMax()` aggregate attributes + `whereHas()`/`whereDoesntHave()`/`has(rel,'>=',n)` correlated `EXISTS`/`COUNT`, `related()`/`load()` lazy loading, local scopes via `query(db).scope('name')`), bcrypt hash incl. `Hash::isHashed()`, AES encrypter, validator (70+ rules, nested dot paths + `items.*.name` wildcards, `exclude*`/`missing*`/`present*` conditionals), mail/queue/cache/storage/scheduler adapters.
|
|
123
|
+
- **The Gate (M39a)**: `Gate::allows()`/`Gate::denies()` compile — the **policy** is compiled at build time (`tools/policy-dump.php` → `compiler/src/policy.mjs` → `dist/src/policies.generated.mjs`): the ability methods and the `before()` hook become async functions, and the runtime keeps Laravel's rules (the `before()` answer wins, a guest the policy does not allow is denied without the call, a missing ability is `false`).
|
|
124
|
+
- **The object store (M30/M39b)**: `Storage::put()/get()/exists()/delete()` and `Storage::disk('<the app\'s default disk>')` compile to the compiled store — the D1 blob store (no Cloudflare product, no card) or R2 when the app binds it.
|
|
125
|
+
- **The file upload (M40 part 5)**: a `multipart/form-data` body is parsed at the edge and `$request->file('x')` answers Laravel's file object (the name, the extension, the type the browser declared, the size, `isValid()`); the file is in the validated data, the `file` rule works and the size rules of a file are in **kilobytes**; `$file->store()`/`$file->storeAs()` write to the app's **default disk** and answer the disk-relative path. The content type of a file (`getMimeType()`) and the `mimes`/`mimetypes`/`image`/`dimensions` rules read the file's **bytes** (finfo) — the edge cannot sniff the content like PHP, so they fail loud (LW002/LW003).
|
|
126
|
+
- **The event dispatch (M39c)**: `Event::dispatch(new X(...))` compiles — the **listeners** are classes, so the build compiles them (`tools/event-dump.php` → `compiler/src/classes.mjs` → `dist/src/classes.generated.mjs`: the constructor with the promoted properties + the public methods) and the dispatch calls them **synchronously**, like Laravel (every listener runs, only an answer of `false` stops the chain, and the dispatch answers the array of the responses).
|
|
127
|
+
- **The Blade layouts (M40)**: `@extends` / `@section` / `@yield` compile — the sections travel to the layout (the env is shared with the nested render) and the build compiles the view closure (the layout is imported too).
|
|
128
|
+
- **The route-model binding (M40)**: `show(Post $post)` compiles — the model comes from the route segment (a missing model is a 404, like Laravel's `ModelNotFoundException`).
|
|
129
|
+
- **The method handling (M40)**: an `OPTIONS` request gets Laravel's **automatic OPTIONS response** (200, an empty body, the `Allow` header with the verbs the URI accepts) and a method that does not match answers like Laravel (405 with an `errors/405` view, else 500). A **CORS** config is not compiled: the build skips the routes under a CORS path (the `Access-Control-*` headers would be missing).
|
|
130
|
+
- **The paginator (M40)**: `Post::paginate(10)` compiles to Laravel's `LengthAwarePaginator` — the items, the total, the pages, the first/last item, the previous/next URLs and the URL window (`elements()`); the page comes from the request (`?page=N`). The **pagination links** (`$paginator->links()`) render the framework's `pagination::tailwind` view — compiled at build time, **byte-identical** to Laravel's markup. The **translations** (`__()`) are dumped at build time (the app's `lang/` + the framework's fallback).
|
|
131
|
+
- **The PHP casts (M40)**: `(string)`, `(int)`, `(bool)` and `(float)` compile with PHP's own semantics; the other casts fail loud.
|
|
132
|
+
|
|
133
|
+
Fails loud (build error or `LW002`), use a port instead:
|
|
134
|
+
- Blade **components** (`<x-...>`) that compile to `$__env->…` (the layouts compile — M40).
|
|
135
|
+
- Controller code outside the compiled subset: an action with a resolvable class method is transpiled for `GET`/`POST`/`PUT`/`PATCH`/`DELETE` (one handler per verb; the automatic `OPTIONS` response is Laravel's — M40; the uploaded files compile — M40 part 5). `Request`/FormRequest injection, `session()`/`old()`/`csrf_token()`/`csrf_field()`/`request()`/`auth()`/`Auth::*`, `$request->validate()`/`validateWithBag()`, `response()`/`response()->json()`, the `Cache`/`Hash` facades, class constants (`RouteServiceProvider::HOME`), `redirect()->intended()` + session rotation, and the Eloquent writes (`create`/`fill`/`save`/`delete`/`$model->attr = ...`) are supported at runtime `Request $request` injection plus `session()`/`old()`/`csrf_token()`/`csrf_field()`/`request()`/`auth()`/`Auth::*` and `$request->validate()`/`validateWithBag()` are supported at runtime (`auth`/`guest` route middleware is honoured by `handleHttp`; numeric `throttle:n[,m]` runs on the D1-backed `database` cache store, and the build fails loud on named limiters — `throttle:name` needs its `AppServiceProvider` definition — and when `cache.driver !== 'database'`). A failed `validate()` renders like Laravel: 302 back with flashed `errors` + `_old_input`, or 422 JSON for `expectsJson` (json session serialization; the `ViewErrorBag` `serialize()` path fails loud). Eloquent relations, `$this`, a FormRequest whose rules cannot be dumped, and unknown constructs fail loud — write a `ports/*.mjs` handler.
|
|
136
|
+
- The class-bearing calls: `Mail::to()->send()` (the mailable is a class), `Queue::push(SomeJob::class)` (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 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), a **named** `Storage::disk()` (it needs a bucket of its own: R2 asks for a credit card; the free path is the D1 blob store), `Cache::tags()` (the D1 `database` store has no tags — in Laravel this route 500s with `BadMethodCallException("This cache store does not support tagging.")`, so the build refuses the route before it ships: stricter than Laravel, not a parity) and the Gate forms that are not a policy check — `Gate::authorize()` (it throws a 403), `Gate::any()`/`none()`, `Gate::define()`/`before()`/`after()` (the global Gate registrations) — all fail loud with their own reason (LW002). The **content type of an uploaded file** (`$file->getMimeType()`) and the `mimes`/`mimetypes`/`image`/`dimensions` **rules** fail loud too (LW002/LW003): they read the file's bytes (finfo), and the edge cannot sniff the content like PHP — the upload itself compiles (M40 part 5). The **event dispatch of an event *name*** (`Event::dispatch('post.created')`) fails loud too: without a class the edge cannot know which listener answers.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
## What's inside
|
|
140
|
+
|
|
141
|
+
- `compiler/` — **M1 view compiler** + **M10 controller compiler**: `bin/transpile.mjs` (php-parser 3.7.0, codes `LW001`–`LW004` fail-loud), `bin/controller.mjs`, `src/transpiler.mjs`, `src/controller.mjs`, `src/policy.mjs` (M39a: the policy compiler), `runtime/rt-from-context.mjs`, `tests/` (corpus 51, parity, smoke, controller, policy).
|
|
142
|
+
- `kernel/` — Workers-native runtime: `router.mjs` + `response.mjs` (W3), `db.mjs` + `model.mjs` (M3 D1 builder + Model), `hash.mjs` + `encrypter.mjs` (M5), `validator.mjs` (M6) + `{mail,queue,cache,storage,scheduler}.mjs` (M7), `session.mjs` + `csrf.mjs` + `ratelimit.mjs` + `auth.mjs` (M11–M14 compiled-rung layers, PHP-8.3-oracle-verified), `cache.mjs` `DatabaseCacheStore` (M18, D1-backed `database` cache for `throttle`), `validation.mjs` `ValidationException` + session error-bag shape (M19, drives `handleHttp`'s redirect-back/422), `http.mjs` (M15 compiled HTTP pipeline), budget gate (M8).
|
|
143
|
+
- `tools/` — build-time PHP: `compile-view.php` (S0 dump), `prerender.php`, `model-dump.php` (M10 Eloquent metadata), `constants-dump.php` + `formrequest-dump.php` (M23 class constants + FormRequest rules), `policy-dump.php` (M39a: the Gate/policy metadata — the abilities, the guest rules, the global Gate callbacks), `php-shadow-oracle.php` (the M24 shadow harness oracle), `verify-s0.php`, `schema-dump.php`, `docs-check.mjs` + `bench.mjs` (M8 gates; `pragma_table_info` fallback, D1 AUTH-safe), `pack-smoke.mjs` (M31: packs the repo, installs the tarball into an empty project and builds a real app with it - the published file set is proven, not assumed), `constructs.json` + `construct-audit.mjs` (the **construct audit**: every construct an app can meet is probed against the real compiler; the table in `COMPILED.md` is generated from it and `check:docs` fails when it drifts - the docs cannot lie about what compiles).
|
|
144
|
+
- `shadow/` — the proof machine (M24): `run.mjs` replays a scenario against the real app (PHP oracle) and the compiled worker and diffs status/headers/body byte-wise; `scenarios/<app>.mjs` is the per-app corpus. `npm run test:shadow -- --app <app-root>` or `laravel-workers shadow --app <app-root>`.
|
|
145
|
+
- `build.mjs` — app build pipeline (copies into a Laravel app's build dir; `dist/kernel` layout + `dist/schema.json` snapshot + `dist/schema.sql` (the same schema as SQL, for `wrangler d1 execute`) + `routes.generated.mjs` route table with names/domains and a `routeNames` map + `public/_routes.json` for Cloudflare Static Assets).
|
|
146
|
+
- `MAP.md` — the map (+105/105 Laravel 13.x docs verdicts, tolerance model, budgets, prior-art policy).
|
|
147
|
+
|
|
148
|
+
## Requirements
|
|
149
|
+
|
|
150
|
+
Versions actually tested (byte-parity corpus + CI + live deploy):
|
|
151
|
+
|
|
152
|
+
| Component | Tested with | Requirement |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| Laravel | `13.37.0` (nearFind, app-breeze), `11.34.2` (blogkit) | 10–13 as source of truth |
|
|
155
|
+
| PHP — build time only | `8.3.30` (ZTS x64, Windows) | **≥ 8.3**; parity oracle is 8.3 (other minors untested) |
|
|
156
|
+
| Composer | none needed | vendor is provisioned automatically via composer-free vendor-lite (no `composer install`), or the app's own `vendor/` when present |
|
|
157
|
+
| Node | `22.18.0` | **≥ 22** (`engines`) |
|
|
158
|
+
| npm | `10.9.3` | ≥ 10 |
|
|
159
|
+
| Cloudflare Workers | `wrangler` `4.151.1` | 4.x; `compatibility_date` `2026-08-01` (live corpus) / `2026-10-01` (`init` template) |
|
|
160
|
+
|
|
161
|
+
- Laravel app as source of truth (Vite unchanged, sessions/CSRF off by default in this pipeline).
|
|
162
|
+
- `php-parser@3.7.0` (npm) for the compiler, `bcryptjs@2.4.3` for `kernel/hash` (pure JS, no native).
|
|
163
|
+
- Cloudflare account for deploy (D1 binding id `ad5876a0-c5eb-493c-917e-5e8f0e543200` in this repo's test Wrangler). **Free plan safe — measured 1–6 ms CPU.**
|
|
164
|
+
|
|
165
|
+
### The Cloudflare tiers we target (in order)
|
|
166
|
+
|
|
167
|
+
1. **Free plan first** — the D1 blob store (`lw_blobs`, 1,5 MB per object, no extra product, **no credit card**) is the default object store; the Workers free limits (10 ms CPU/req, 50 subrequests, 100k D1 writes/day, 5M reads/day) are the budget the kernel is measured against.
|
|
168
|
+
2. **R2 when the app really needs it** — a **named** disk (its own bucket), an object above 1,5 MB, or many objects. R2's free tier (10 GB-month, 1M Class A, 10M Class B, free egress) is generous, but **R2 asks for a payment method** — so the build never turns it on by itself: it prints the `wrangler.jsonc` snippet and the operator decides.
|
|
169
|
+
3. **The paid plan, only when the free path really cannot do it** — if an app needs more than the free quotas (or a paid-only Cloudflare feature), that is a *deliberate operator decision*: the toolchain stays free-plan-shaped by default, says exactly what the app needs, and the operator opts into the paid plan. Nothing in the compiler assumes a paid plan, and no construct is blocked *because* the free plan is too small — the build only fails loud when the edge genuinely cannot answer like PHP.
|
|
170
|
+
|
|
171
|
+
**Three levels, not two — "paid" is not the same as "needs a credit card"** (the Cloudflare docs, checked 2026-10-21; the pages were last updated 2026-04-21):
|
|
172
|
+
- the **free plan** (no card, no subscription): Workers, D1, KV, the D1 blob store, a *limited* version of Queues (10,000 operations/day included, 128 KB per message, 24 h retention on the free plan) and Durable Objects (100k requests + 100k row writes per day);
|
|
173
|
+
- the **Workers paid plan** (a subscription): Dynamic Workers (only on the paid plan) and the full Queues (billed per operation);
|
|
174
|
+
- a **payment method** (a credit card): R2 — even its free tier asks for one. That is the only product in the list that needs a card.
|
|
175
|
+
|
|
176
|
+
## Quick start
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# 1) As an npm package in a real Laravel app (recommended):
|
|
180
|
+
cd my-laravel-app # artisan + vendor + public/build there
|
|
181
|
+
npm i laravel-workers
|
|
182
|
+
npx laravel-workers init # writes registry.json + wrangler.jsonc, updates .gitignore
|
|
183
|
+
# no composer install needed - the build provisions the app's PHP vendor itself (composer-free vendor-lite)
|
|
184
|
+
npx laravel-workers build --strict # dist/ (cwd-based, no edge/ required); --scaffold for port stubs
|
|
185
|
+
# local preview/deploy: npx wrangler deploy
|
|
186
|
+
|
|
187
|
+
# 2) From checkout (tests / legacy edge layout):
|
|
188
|
+
git clone https://github.com/arfoux/laravel-workers
|
|
189
|
+
cd laravel-workers
|
|
190
|
+
npm run setup # root + compiler deps
|
|
191
|
+
|
|
192
|
+
npm test # 359/359 - router + db/model + sqlite + hash + encrypter + validator + services + routes-json + registry-json + ports + budget + the PHP oracle parity - router + db/model + sqlite + hash + encrypter + validator + services + routes-json + registry-json + ports + budget
|
|
193
|
+
npm run test:corpus # 51/51 — M1 transpiler corpus
|
|
194
|
+
npm run test:hash # 7/7 — hash + encrypter only
|
|
195
|
+
|
|
196
|
+
# verify/build against the parent Laravel checkout (legacy edge/ also works):
|
|
197
|
+
npx laravel-workers verify # VERIFY-S0 15/15 (else SKIP)
|
|
198
|
+
npx laravel-workers build # --strict build; reads registry.json / edge/registry.json from app cwd
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## CLI
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
laravel-workers test # all kernel tests (380)
|
|
205
|
+
laravel-workers test:db # db/model only
|
|
206
|
+
laravel-workers test:sqlite # real in-memory SQLite integration (node:sqlite)
|
|
207
|
+
laravel-workers test:hash # hash + encrypter only
|
|
208
|
+
laravel-workers test:validator # validator only
|
|
209
|
+
laravel-workers test:services # mail/queue/cache/storage/scheduler
|
|
210
|
+
laravel-workers test:budget # budget gate only
|
|
211
|
+
laravel-workers test:corpus # transpiler corpus (51)
|
|
212
|
+
laravel-workers bench # micro-bench (bench.mjs)
|
|
213
|
+
laravel-workers check:docs # docs-check 105/105
|
|
214
|
+
laravel-workers init # scaffold registry.json + wrangler.jsonc (needs Laravel app root)
|
|
215
|
+
laravel-workers build # --strict (needs Laravel app); --scaffold generates + registers port stubs
|
|
216
|
+
laravel-workers verify # php tools/verify-s0.php (SKIP when no parent vendor/)
|
|
217
|
+
laravel-workers shadow # the shadow harness: the real app (PHP oracle) vs the compiled worker
|
|
218
|
+
|
|
219
|
+
# npm aliases do the same:
|
|
220
|
+
npm test # kernel
|
|
221
|
+
npm run test:corpus
|
|
222
|
+
npm run build
|
|
223
|
+
npm run verify
|
|
224
|
+
# also: npm run bench / npm run check:docs / npm run test:budget / npm run test:services / npm run test:pack / npm run audit:constructs etc.
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Zero-touch / CI (Workers Builds)
|
|
228
|
+
|
|
229
|
+
Keep the repo pure Laravel — config and ports can live outside it:
|
|
230
|
+
|
|
231
|
+
- `LW_CONFIG=../edge-config/registry.json` — registry path override (relative to the app root; a set-but-missing file **fails the build**).
|
|
232
|
+
- `LW_PORTS_DIR=../edge-ports` — port directory override, checked before `src/ports`, `ports/`, `edge/src/ports` (a set-but-missing dir **fails the build**).
|
|
233
|
+
- `registry.json` is **optional**: without it the build uses defaults (`autoSnapshot` on, no ports/ignore). Unhandled routes still fail `--strict`, and a registered port without a matching file fails the build. Static entries must prerender clean; a static entry that reads a D1 binding the build cannot reach fails the build — a D1 app needs a local sqlite fallback for the build (config/database.php).
|
|
234
|
+
- `--scaffold` stubs carry a `laravel-workers:stub` marker and return `501`; `build --strict` keeps failing until every stub is implemented.
|
|
235
|
+
- Building while `wrangler dev` is running is safe: if the OS locks `dist/`, the build wipes its contents (keeping the dir shells) and the dev server hot-reloads the fresh output.
|
|
236
|
+
|
|
237
|
+
Workers Builds (Dashboard → Workers → connect the GitHub repo) — config is injected in the build command, nothing is committed:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
touch database/database.sqlite
|
|
241
|
+
cat > registry.json <<'JSON'
|
|
242
|
+
{ "autoSnapshot": true, "ports": {} }
|
|
243
|
+
JSON
|
|
244
|
+
cat > wrangler.jsonc <<'JSONC'
|
|
245
|
+
{ "name": "my-app", "main": "./dist/src/worker.mjs", "compatibility_date": "2026-10-01",
|
|
246
|
+
"assets": { "directory": "./dist/public", "binding": "ASSETS" },
|
|
247
|
+
"d1_databases": [ { "binding": "DB", "database_name": "my-app", "database_id": "<the id from: npx wrangler d1 create my-app>" } ],
|
|
248
|
+
"observability": { "enabled": true } }
|
|
249
|
+
JSONC
|
|
250
|
+
cp .env.example .env && php artisan key:generate
|
|
251
|
+
php artisan migrate --force
|
|
252
|
+
npm run build
|
|
253
|
+
npx -y laravel-workers@0.4.0 build --strict
|
|
254
|
+
# the D1 schema: the app's tables (+ lw_blobs when the app uses Storage::)
|
|
255
|
+
npx wrangler d1 execute DB --file=dist/schema.sql --remote
|
|
256
|
+
# deploy command: npx wrangler deploy
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The build image must provide PHP >= 8.3 (Composer is NOT needed anymore - the build provisions vendor itself via composer-free vendor-lite).
|
|
260
|
+
|
|
261
|
+
## Architecture (30 s)
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
Laravel app (dev truth)
|
|
265
|
+
└─ edge/tools/*.php S0 dump routes / Blade-compiled HTML+context / schema / config (PHP boots here ONLY)
|
|
266
|
+
└─ edge/build.mjs S1 transpile Blade→JS, routes→router, schema→schema.json + generated controllers
|
|
267
|
+
└─ bundler S2 kernel + generated → worker ESM
|
|
268
|
+
└─ golden tests S3 Laravel render vs compiled render byte-equal
|
|
269
|
+
└─ --strict gate S4 uncovered route → fail
|
|
270
|
+
└─ prerender S5 clean GET → static snapshot (0-invocation tier)
|
|
271
|
+
└─ wrangler deploy S6 deploy → Workers + D1
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Docs & checks
|
|
275
|
+
|
|
276
|
+
- `MAP.md` — auto-snapshot semantics, D1 max-queries note, Unknowns-to-verify, and Appendix C (105 docs pages → verdict; no blanks, releases must re-check `laravel/docs@13.x` — W8).
|
|
277
|
+
- `compiler/tests/run-corpus.mjs` — oracle; `compiler/tests/parity-m1` + `tools/verify-s0.php` — green in M1.
|
|
278
|
+
- `kernel/tests/{router,db,hash,encrypter,validator,services,routes-json,registry-json,ports,budget}.test.mjs` — M5 proves `make`/`check`/`encryptString`/`decryptString` round-trip, tamper, wrong-key, unicode.
|
|
279
|
+
|
|
280
|
+
## Philosophy
|
|
281
|
+
|
|
282
|
+
- **Laravel stays Laravel** — this is the toolchain, not app code; no fork of framework.
|
|
283
|
+
- **Coverage enforced** — unhandled route = warning; `--strict` = failure. No silent gaps.
|
|
284
|
+
- **Free-plan-first** (`10ms` CPU, `50` subrequests, `5M` D1 reads/day) — Paid is headroom, never a fallback.
|
|
285
|
+
- **Honest claims** — "approach in progress" until M1–M5 measured; prior-art scan stays clean before any "discovery" claim.
|
|
286
|
+
|
|
287
|
+
## Roadmap (living)
|
|
288
|
+
|
|
289
|
+
- **M6** Validation kernel — done (M6).
|
|
290
|
+
- **M7** Services kernel — done (M7).
|
|
291
|
+
- **M8** Final gates — done: docs-check 105/105 + bench/budget p95 ≤5ms + CI + pack green.
|
|
292
|
+
- **M9** Zero-touch hardening — done: stubs fail loud, `LW_CONFIG`/`LW_PORTS_DIR`, optional registry, fresh-Laravel e2e job in CI (strict build → dist asserts → `wrangler dev` runtime smoke).
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT — see [LICENSE](LICENSE). Trademarks belong to their owners.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
const appDir = process.cwd();
|
|
9
|
+
const cmd = process.argv[2] ?? 'test';
|
|
10
|
+
|
|
11
|
+
const run = (command, args, options = {}) => {
|
|
12
|
+
const { cwd = pkgDir, ...rest } = options;
|
|
13
|
+
const res = spawnSync(command, args, { cwd, stdio: 'inherit', ...rest });
|
|
14
|
+
if (res.error) {
|
|
15
|
+
console.error(res.error.message);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
if (res.status !== 0) process.exit(res.status ?? 1);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (cmd === 'test') {
|
|
22
|
+
run(process.execPath, ['--test', 'kernel/tests/router.test.mjs', 'kernel/tests/db.test.mjs', 'kernel/tests/sqlite-integration.test.mjs', 'kernel/tests/hash.test.mjs', 'kernel/tests/encrypter.test.mjs', 'kernel/tests/validator.test.mjs', 'kernel/tests/services.test.mjs', 'kernel/tests/routes-json.test.mjs', 'kernel/tests/registry-json.test.mjs', 'kernel/tests/ports.test.mjs', 'kernel/tests/budget.test.mjs', 'compiler/tests/controller.test.mjs'], { cwd: pkgDir });
|
|
23
|
+
} else if (cmd === 'test:db') {
|
|
24
|
+
run(process.execPath, ['--test', 'kernel/tests/db.test.mjs'], { cwd: pkgDir });
|
|
25
|
+
} else if (cmd === 'test:sqlite') {
|
|
26
|
+
run(process.execPath, ['--test', 'kernel/tests/sqlite-integration.test.mjs'], { cwd: pkgDir });
|
|
27
|
+
} else if (cmd === 'test:hash') {
|
|
28
|
+
run(process.execPath, ['--test', 'kernel/tests/hash.test.mjs', 'kernel/tests/encrypter.test.mjs'], { cwd: pkgDir });
|
|
29
|
+
} else if (cmd === 'test:validator') {
|
|
30
|
+
run(process.execPath, ['--test', 'kernel/tests/validator.test.mjs'], { cwd: pkgDir });
|
|
31
|
+
} else if (cmd === 'test:services') {
|
|
32
|
+
run(process.execPath, ['--test', 'kernel/tests/services.test.mjs'], { cwd: pkgDir });
|
|
33
|
+
} else if (cmd === 'test:budget') {
|
|
34
|
+
run(process.execPath, ['--test', 'kernel/tests/budget.test.mjs'], { cwd: pkgDir });
|
|
35
|
+
} else if (cmd === 'test:corpus') {
|
|
36
|
+
run(process.execPath, ['compiler/tests/run-corpus.mjs'], { cwd: pkgDir });
|
|
37
|
+
} else if (cmd === 'bench') {
|
|
38
|
+
run(process.execPath, ['tools/bench.mjs'], { cwd: pkgDir });
|
|
39
|
+
} else if (cmd === 'check:docs') {
|
|
40
|
+
run(process.execPath, ['tools/docs-check.mjs'], { cwd: pkgDir });
|
|
41
|
+
} else if (cmd === 'init') {
|
|
42
|
+
const initPath = join(pkgDir, 'tools', 'init.mjs');
|
|
43
|
+
if (!existsSync(initPath)) {
|
|
44
|
+
console.error('laravel-workers init: tools/init.mjs not found in package — reinstall laravel-workers.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
let cwd = appDir;
|
|
48
|
+
if (!existsSync(join(appDir, 'artisan')) && existsSync(join(dirname(pkgDir), 'artisan'))) {
|
|
49
|
+
cwd = dirname(pkgDir);
|
|
50
|
+
}
|
|
51
|
+
run(process.execPath, [initPath], { cwd });
|
|
52
|
+
} else if (cmd === 'build') {
|
|
53
|
+
const buildPath = join(pkgDir, 'build.mjs');
|
|
54
|
+
if (!existsSync(buildPath)) {
|
|
55
|
+
console.error('laravel-workers build: build.mjs not found in package — reinstall laravel-workers. Run from your Laravel app root via npx laravel-workers build.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
let cwd = appDir;
|
|
59
|
+
if (existsSync(join(appDir, 'artisan'))) {
|
|
60
|
+
cwd = appDir;
|
|
61
|
+
} else if (existsSync(join(dirname(pkgDir), 'artisan'))) {
|
|
62
|
+
cwd = dirname(pkgDir);
|
|
63
|
+
} else {
|
|
64
|
+
cwd = appDir;
|
|
65
|
+
}
|
|
66
|
+
if (!existsSync(join(cwd, 'artisan'))) {
|
|
67
|
+
console.error('laravel-workers build: artisan not found — run from your Laravel app root via npx laravel-workers build (cwd must contain artisan).');
|
|
68
|
+
}
|
|
69
|
+
run(process.execPath, [buildPath, '--strict', ...process.argv.slice(3)], { cwd });
|
|
70
|
+
} else if (cmd === 'shadow') {
|
|
71
|
+
// The shadow harness: the real app (PHP oracle) vs the compiled worker.
|
|
72
|
+
const shadowPath = join(pkgDir, 'shadow', 'run.mjs');
|
|
73
|
+
if (!existsSync(shadowPath)) {
|
|
74
|
+
console.error('laravel-workers shadow: shadow/run.mjs not found in package - reinstall laravel-workers.');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const args = process.argv.slice(3);
|
|
78
|
+
if (!args.includes('--app') && !process.env.LW_SHADOW_APP) {
|
|
79
|
+
console.error('laravel-workers shadow: --app <laravel-app-root> is required (or set LW_SHADOW_APP).');
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
run(process.execPath, [shadowPath, ...args], { cwd: appDir });
|
|
83
|
+
} else if (cmd === 'vendor:ensure') {
|
|
84
|
+
// Build a (or reuse the cached) vendor tree the build needs to boot the app.
|
|
85
|
+
// Content-addressed by composer.lock, so a second project with the same lock
|
|
86
|
+
// costs nothing and a fresh clone never runs a full `composer install`.
|
|
87
|
+
const litePath = join(pkgDir, 'tools', 'vendor-lite.mjs');
|
|
88
|
+
if (!existsSync(litePath)) {
|
|
89
|
+
console.error('laravel-workers vendor:ensure: tools/vendor-lite.mjs not found in package — reinstall laravel-workers.');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
run(process.execPath, [litePath, ...process.argv.slice(3)], { cwd: appDir });
|
|
93
|
+
} else if (cmd === 'php:ensure') {
|
|
94
|
+
// Resolve (or provision) the interpreter that can boot THIS app. The build
|
|
95
|
+
// does this automatically; the explicit command exists for CI prewarming and
|
|
96
|
+
// for operators who want the download to happen outside a build.
|
|
97
|
+
const { resolvePhp } = await import(pathToFileURL(join(pkgDir, 'tools', 'php-runtime.mjs')).href);
|
|
98
|
+
const { provisionPhp } = await import(pathToFileURL(join(pkgDir, 'tools', 'php-provision.mjs')).href);
|
|
99
|
+
let requirement = null;
|
|
100
|
+
try {
|
|
101
|
+
requirement = JSON.parse(readFileSync(join(appDir, 'composer.json'), 'utf8')).require?.php || null;
|
|
102
|
+
} catch {
|
|
103
|
+
requirement = null;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const r = resolvePhp({ requirement });
|
|
107
|
+
console.log(`PHP ${r.version} (${r.bin})${requirement ? ` — satisfies ${requirement}` : ''}`);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (!requirement) {
|
|
110
|
+
console.error(e.message);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
console.log(e.message.split('\n')[0]);
|
|
114
|
+
console.log(`provisioning PHP ${requirement}...`);
|
|
115
|
+
try {
|
|
116
|
+
const p = await provisionPhp(requirement);
|
|
117
|
+
console.log(`PHP ${p.version} at ${p.bin}${p.cached ? ' (cached)' : ' (downloaded, sha256 verified)'}`);
|
|
118
|
+
console.log(` -> use it: PHP_BIN="${p.bin}" npx laravel-workers build --strict`);
|
|
119
|
+
} catch (e2) {
|
|
120
|
+
console.error(e2.message);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} else if (cmd === 'verify') {
|
|
125
|
+
const php = process.env.PHP_BIN ?? 'php';
|
|
126
|
+
const verifyPath = join(pkgDir, 'tools', 'verify-s0.php');
|
|
127
|
+
if (!existsSync(verifyPath)) {
|
|
128
|
+
console.error('laravel-workers verify: tools/verify-s0.php not found in package.');
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
const vendorInApp = existsSync(join(appDir, 'vendor', 'autoload.php'));
|
|
132
|
+
const vendorInParent = existsSync(join(dirname(pkgDir), 'vendor', 'autoload.php'));
|
|
133
|
+
if (!vendorInApp && !vendorInParent) {
|
|
134
|
+
console.log('VERIFY-S0: SKIP (no Laravel vendor/ found — run from your Laravel app root via npx laravel-workers verify).');
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
run(php, [verifyPath], { cwd: appDir, env: { ...process.env, LW_APP_ROOT: appDir } });
|
|
138
|
+
} else {
|
|
139
|
+
console.error(`laravel-workers: unknown command "${cmd}". Try: test | test:db | test:sqlite | test:hash | test:validator | test:services | test:budget | test:corpus | bench | check:docs | init | build | verify | vendor:ensure | php:ensure`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|