feat: response decoration api with cookie support - #152
Merged
Conversation
Add a decoration API to Response (withHeader, withHeaders, withStatus, withCookie) that preserves the concrete subclass, plus first-class cookie support, and make request-scoped singleton state explicitly clearable. Fixes a live bug: five middleware added headers by constructing a new base Response from the previous response's getters, silently downgrading a StreamingResponse to a plain Response — the SSE stream was discarded and the client received an empty 200. All six rebuild sites now decorate instead, and an architecture test fails the build if the pattern reappears. Response and StreamingResponse drop the readonly class modifier: verified on PHP 8.5.1 that `clone with` does not exist and readonly properties cannot be modified on a clone by assignment or reflection, so clone-then-assign is the only mechanism that preserves the subclass. Immutability is enforced by API design instead — private properties, no setters. The session cookie now travels on the Response rather than the SAPI, and attaches only when the session ID changed, matching what session_start() did before. Attaching unconditionally would have silently disabled the page cache, since cookie-bearing responses are never cached — a security boundary that now also covers Cookie objects, not just literal set-cookie headers. Adds ResettableInterface in core for services holding request-scoped state, implemented by Session, SessionGuard and ReadWriteConnection, plus Container::resolvedInstances() to discover them without forcing instantiation. Three verified cross-user leaks are closed: Session reusing the previous request's ID, SessionGuard caching the authenticated user, and session save handlers accumulating a shutdown function per request. Closes #150 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
Three additions driven by the RoadRunner state-leak spike, which audited every singleton, boot-time instance() binding, mutable static and process-global in the monorepo. Inertia::$shared is a container singleton whose shared props are merged into every subsequent render() and never cleared. In a booted-once process, middleware that shares the authenticated user once per request leaves that user visible to every following request's Inertia response. Inertia now implements ResettableInterface. ReadWriteConnection::reset() cleared the sticky-write flag but left a transaction open when a request threw before commit() or rollback(). That connection is pooled across requests, so the next request's writes were silently appended to the previous request's abandoned transaction. reset() now rolls back under an inTransaction() guard, with sticky-state clearing in a finally so a failing rollback cannot skip it. resolvedInstances() moves onto ContainerInterface. Application::$container is typed as the interface, so a worker could not reach the accessor added for exactly this purpose. Adding a method to the interface required updating container stubs in 14 test files across 8 packages — a BC break that belongs before 1.0 rather than in the follow-up worker PR. Closes #150 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
…ring SessionShutdownHandlerTest proves session_set_save_handler() is called once per process by defining a Marko\Session\session_set_save_handler() shim that PHP resolves ahead of the global function. The shim lived inside the test file itself, which made the test depend on load order. PHP caches the resolved target on a call site's opline the first time it executes. If any earlier test in the same process reached Session::configure() before the test file was loaded, that opline bound permanently to the global function, the spy never fired, and the assertion saw a count of 0 — a failure that depended entirely on how paratest distributed files across worker processes. It passed on this branch by luck of the current distribution and failed on roughly 83% of parallel runs on a branch that merely added unrelated test files elsewhere in the repo. Sequential runs were always clean, which is what made it look like cross-process interference rather than a load-order bug. Moving the shim into an autoload-dev files entry guarantees it is defined before any test can resolve that call site, in every process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
markshust
force-pushed
the
feature/response-decoration
branch
from
August 28, 2026 23:37
de5006e to
bed06c5
Compare
…test
The docblock assertions included `toContain('PHP-FPM')`, which is an example
runtime mentioned in passing rather than part of the contract. Rewording that
sentence — changing no behaviour and no contract — would have failed the test.
Keeps the two phrases that ARE the contract: an implementor reading nothing but
the interface must not mistake reset() for a destructive teardown. A comment
records why those two and not the rest, so the incidental assertions do not
creep back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
CI's Lint job runs `php-cs-fixer --dry-run`, which fails the build on any file the fixer would change. Two test files touched by the decoration migration had `use function` imports placed where the fixer wants them ordered differently. These were noted during the standards pass and left as "the pre-commit hook handles it" — true locally, but CI checks rather than fixes, so the build went red. Running the fixer is the whole remedy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLREGwAgqDnHQANKShZ7qL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Gives
Responsea decoration API and first-class cookie support, and makes request-scoped singleton state explicitly clearable.The live bug this fixes
StreamingResponse extends Responsecarries its payload in anSseStreamwithbody=''. Five middleware added headers by constructing a brand-new baseResponsefrom the previous response's getters:Any of them in front of an SSE route downgraded the
StreamingResponseto a plainResponse— the stream was discarded, the overriddensend()never ran, and the client got an empty 200 with correct headers.SecurityHeadersMiddlewareis exactly the kind of middleware apps register globally.All six rebuild sites across five files now decorate instead, and
tests/MiddlewareDecorationTest.phpfails the build if the pattern reappears in any middleware.Why
Responseis no longerreadonlyVerified empirically on PHP 8.5.1 before committing to the design:
clone $this with { ... }readonly class+ clone then assignCannot modify readonly propertyreadonly class+ReflectionProperty::setValueclone+ assigncloneis required becauseStreamingResponse::__construct(SseStream, int)has a different signature from its parent, sonew static(...)cannot reconstruct it. Immutability is now enforced by API design — private properties, no setters,with*()returns copies.json()/html()/redirect()deliberately keepnew self()for the same reason.Session cookie moves onto the Response
setcookie()is gone from the codebase. The cookie now travels on theResponse, which makes it visible to middleware, assertable without superglobal fixtures, and able to reach a client under a SAPI wheresetcookie()is a no-op.Three subtleties worth reviewing closely:
session.use_cookies = 0disables session ID reading, not just writing, soSessionMiddlewareseeds the ID from the inbound request cookie itself. Without this, every request would silently start a fresh empty session.setId()'s validation. That value is attacker-controlled, so it is ignored in favour of a fresh session rather than surfacing as a 500.session_start()did before. Attaching unconditionally would have silently disabled the page cache for every session-enabled app, sinceSessionMiddlewareruns insidePageCacheMiddlewareand cookie-bearing responses are never cached.tests/Integration/PageCacheSessionMiddlewareTest.phpguards this.Cross-user leaks closed
Three verified leaks that only manifest in a booted-once process, each reproduced by a failing test before being fixed:
Sessionreused the previous request's ID.save()left$this->idpopulated andstart()fed it back tosession_id(). On a singleton, an anonymous visitor following an authenticated one resumed the previous user's session.SessionGuard::$cachedUserpersisted across requests on a singleton guard, cleared only by the destructivelogout().session_set_save_handler($handler, true)ran on everystart(), registering a shutdown function per request — unbounded growth in a worker.SessionInterfaceandGuardInterfaceare byte-identical; everymarko/testingfake still satisfies them unedited.ResettableInterfaceis implemented on the concrete classes for exactly that reason.Also
Cookievalue object —rawurlencode()d values soa; bcannot inject a second attribute,SameSite=NonewithoutSecurethrows rather than emitting a cookie browsers silently drop, andlifetime => 0omitsExpiresentirely rather than sending an epochRequest::cookie()plus$_COOKIEcapture infromGlobals()Response::headerLines()— SAPI-free header emission, consumed by bothsend()implementationsSet-Cookiewould be replayed to every later visitorResettableInterfacein core, implemented bySession,SessionGuardandReadWriteConnection;Container::resolvedInstances()discovers them without forcing instantiationVerification
6972 tests passing, 0 failures. PHPStan: no errors. PHPCS: clean.
Known follow-up
Container::resolvedInstances()is on the concreteContainer, butApplication::$containeris typedContainerInterface, so it is not reachable through the interface.Containeris the only implementor, so adding it there is low-risk — but it is a BC-breaking interface change and belongs before 1.0 rather than in the follow-up worker PR. Deliberately left out of this PR's scope.Closes #150
🤖 Generated with Claude Code