Skip to content

fix!: render layouts innermost so route middleware headers and cookies survive - #522

Merged
markshust merged 1 commit into
developfrom
feature/layout-preserve-response-headers
Oct 6, 2026
Merged

markshust merged 1 commit into
developfrom
feature/layout-preserve-response-headers

Conversation

@markshust

@markshust markshust commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • LayoutMiddleware is global, so it wrapped every route middleware and replaced a successful controller result with the rendered layout, dropping Set-Cookie, cache and custom headers that route middleware or the controller set.
  • marko/routing gains #[RunsInnermost] for global middleware: marked classes run after route middleware, directly around the controller (declaration order kept among them; #[WithoutMiddleware] still excludes them). Same shape as the existing #[RunsOnUnmatched].
  • LayoutMiddleware is marked #[RunsInnermost], so route middleware now decorates the rendered layout response, and route-middleware denials (auth redirect, #[Can] 403) answer before any layout renders. Headers and cookies the controller put on its own result are copied onto the layout response (layout wins on conflicts; Content-Type/Content-Length always describe the layout body).
  • The fix: pass inner denials through layout middleware #440 pass-through of non-2xx / Location results is unchanged.
  • Docs: layout page rewritten for the new ordering; routing page gets an "Innermost Global Middleware" section, stack-order sentence, and API reference line.

Deviation from the suggested fix

The issue suggested a "result decorator" hook collected from the container. The container has no tagged-service collection, so that would need a new module.php key plumbed through core's manifest parser, module cache and discovery cache. An attribute on the global middleware class gets the same innermost placement with a change confined to Router::middlewareFor(). It also keeps LayoutMiddleware in globalMiddleware, so existing #[WithoutMiddleware(LayoutMiddleware::class)] exclusions keep working and no module entry disappears.

Breaking

Route middleware on a #[Layout] route now sees the rendered layout response from $next() instead of the raw controller response, and LayoutMiddleware now runs after route middleware instead of before it. Middleware that inspected the controller's raw body on a layout route will see the layout HTML instead. There is no global switch back to the old order. A route that needs it can exclude the layout with #[WithoutMiddleware(LayoutMiddleware::class)] and attach its own route middleware that calls LayoutProcessorInterface::process() at the position it wants.

Test plan

  • RunsInnermostTest (routing): marked global middleware runs after route middleware and directly around the controller, declaration order is kept among marked middleware, and #[WithoutMiddleware] still excludes it
  • LayoutMiddlewareRouterTest: a cookie, Cache-Control and a custom header set by route middleware are present on a layout-rendered 2xx; controller-set headers and cookies are carried over with the layout winning conflicts; the existing fix: pass inner denials through layout middleware #440 tests (302 auth redirect, thrown 403, controller 404) still pass with no layout render
  • LayoutMiddlewareTest: LayoutMiddleware carries #[RunsInnermost]; non-2xx pass-through dataset unchanged
  • composer test, cs:check, phpstan green locally

Fixes #512

🤖 Generated with Claude Code

https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1

…s survive

LayoutMiddleware is global, so it wrapped every route middleware and
replaced a successful controller result with a freshly rendered layout,
dropping any Set-Cookie, cache or custom header that route middleware or
the controller put on the response.

Routing gains a #[RunsInnermost] attribute for global middleware: marked
classes run after route middleware, directly around the controller, in
declaration order, and #[WithoutMiddleware] still excludes them.
LayoutMiddleware carries it, so route middleware now decorates the
rendered layout response, and denials from route middleware answer before
any layout renders. Headers and cookies the controller set on its own
result are copied onto the layout response (the layout wins on
conflicts; Content-Type/Content-Length describe the layout body). The
#440 pass-through of non-2xx results is unchanged.

Fixes #512

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1
@github-actions github-actions Bot added bug Something isn't working breaking Introduces a breaking change and removed bug Something isn't working labels Oct 6, 2026
@markshust
markshust merged commit 5b98d71 into develop Oct 6, 2026
6 checks passed
@markshust
markshust deleted the feature/layout-preserve-response-headers branch October 6, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Introduces a breaking change bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move layout rendering innermost so headers and cookies set by route middleware survive a 2xx

1 participant