Skip to content

[6.x] Laravel Octane support - #20641

Merged
riasvdv merged 2 commits into
6.xfrom
octane-support
Nov 12, 2026
Merged

riasvdv merged 2 commits into
6.xfrom
octane-support

Conversation

@riasvdv

@riasvdv riasvdv commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Description

Makes Craft safe to run on Laravel Octane (Swoole and RoadRunner), where one worker boots the app once and serves many requests.

State that outlived a request, and how it's reset now:

 Octane RequestReceived / TaskReceived / TickReceived
+  PrepareCraftForNextOperation (registered after boot, so it runs after Octane's own listeners)
+    prepare()
+      forget static memos: element sources, link-type elements, rendered propagation keys, template fallbacks, layout Twig
+      ProjectConfig::reset() + Cms::setDefaultTimezone()   # pick up changes from other workers and commands
+      ProjectConfigRoutes::refresh()                      # re-register project config routes if they changed
+    TemplateRoots::resetViewFactory()                      # per-request view finder for the request's template mode

 craft:mcp:serve (stdio), on each MCP request
   forgetScopedInstances()
-  Element::forgetSources()
+  PrepareCraftForNextOperation::prepare()

Singletons that held request-scoped services now resolve them per use:

 Elements
-  __construct(ElementPlaceholders $placeholders, …)   # kept the first request's preview drafts forever
+  placeholders(): app(ElementPlaceholders::class)
 DraftActivity, ElementWriteActivity          → Sites facade
 DeprecatorServiceProvider, RouteServiceProvider → resolve Deprecator / Routes lazily

 HandleInertiaRequests
-  View::composer('app', fn …) on every request   # composers piled up; the first drained a stale stack
+  View::composer('app', HandleInertiaRequests::class) once, at boot

 Asset::displayName()       # no longer memoized ("Asset" leaked into /fr and /de)
 ErrorPages                 # replaceNamespace() instead of adding the renderer namespace again

laravel/octane is added as a dev dependency only; Craft references its events by class name, so sites without Octane are unaffected.

Breaking for subclasses: Elements::__construct() no longer takes ElementPlaceholders.

Evidence

Differential run, Octane vs. php -S: 1046 identical request sequences (CP pages, three-language site, admin and non-admin users, preview tokens, GraphQL tokens, saves) against two copies of the same seeded workbench.

Server Steps Status mismatches Unexplained body diffs
Swoole, 1 worker 1046 0 0
RoadRunner, 1 worker 1046 0 0
RoadRunner, 4 workers 1046 0 0

The remaining body diffs are random by nature: encrypted values, CSRF/preview tokens, freshly generated UUIDs, and the env-var autocomplete list (Octane's own variables).

Other checks:

  • One RoadRunner worker stayed at 209–213 MB RSS across 4830 requests.
  • A route saved and then deleted by a separate CLI process went 404 → 200 → 404 on the running worker, without a restart.

Before / after, from tests/Feature/Http/OctaneWorkerTest.php (several requests through one FakeWorker):

worker serves: GET /octane-preview?token=<preview token>, then GET /octane-preview
  before: 'previewing', 'previewing'   # anonymous request saw the draft
  after:  'previewing', 'live'

worker serves: GET /{cpTrigger}/login twice
  before: 2nd response has no Craft.registeredAssetBundles script
  after:  both do

Before / after, from tests/Feature/Mcp/StdioTest.php:

stdio: project_config_get system.name; another process renames it; project_config_get system.name
  before: 'Craft test site'
  after:  'Renamed elsewhere'

composer ci passes.

Reset Craft's request state between operations on a long-lived worker:

- Singletons resolve request-scoped services per use, instead of keeping
  the first request's, such as Elements' placeholder elements, which
  exposed a previewed draft to later anonymous requests.
- The `app` root view composer is registered once at boot, rather than
  piling up per request and dropping the CP's legacy asset bundles.
- A listener resets static memos, reloads the project config and
  timezone, re-registers project config routes, and restores the view
  finder before each Octane request, task, and tick. The stdio MCP
  server runs the same reset before each request.
- Asset::displayName() is no longer memoized across languages.

Adds laravel/octane as a dev dependency, for the Octane worker tests.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1d08b-472f-757e-8aa8-33f56bf5aab8
Co-authored-by: Amp <amp@ampcode.com>
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/ui — open Storybook

No changed components detected in this Storybook.

resources/js — open Storybook

No changed components detected in this Storybook.

@riasvdv
riasvdv merged commit a03a34b into 6.x Nov 12, 2026
22 checks passed
@riasvdv
riasvdv deleted the octane-support branch November 12, 2026 18:07
@bencroker

Copy link
Copy Markdown
Contributor

Thank you!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants