Skip to content

fix: forget scoped instances between requests in LaravelHttpServer - #295

Merged
MrPunyapal merged 2 commits into
pestphp:5.xfrom
ChrisAbner:fix/flush-scoped-instances
Oct 28, 2026
Merged

MrPunyapal merged 2 commits into
pestphp:5.xfrom
ChrisAbner:fix/flush-scoped-instances

Conversation

@ChrisAbner

@ChrisAbner ChrisAbner commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

LaravelHttpServer::handleRequest() serves every request of a browser test on the test's own container and never forgets its scoped instances, so a scoped() binding resolved in one request is reused by the next one, like a singleton. This is what pestphp/pest#1986 hit with Inertia's SsrState, and it affects any scoped service, including Laravel's own Context. After each request, this forgets the scoped instances that request resolved and clears the facade cache, as the queue worker does between jobs. Fixes pestphp/pest#1986.

#234 by @onlime proposed app()->forgetScopedInstances() at the start of each request. It targets 4.x, no longer applies on 5.x, and has two gaps. It also drops what the test bound before visit(): a $this->mock() on a scoped binding never reaches the request, though it does with $this->get(). And a scoped service used through a facade, such as Context, still leaks through the facade's cached instance. So I compare the container's scoped instances before and after the request and forget only the ones the request created. Laravel has no public getter for which bindings are scoped, so scopedInstances() reads them through a closure bound to the container, using the same logic as Container::forgetScopedInstances(). The forget runs in a finally around handling, terminate and the streamed content, so it also happens when any of them throws.

Verification. I added six tests to LaravelHttpServerTest.php:

before after
scoped binding with state, visited twice fails: second visit renders hits: 2, binding built once passes: hits: 1 both times, built twice
test binds an instance over a scoped binding passes passes (with forgetScopedInstances() alone it fails)
Context::add() in one request, read in the next fails: value is still there passes
request throws with exception handling off fails: its instance is still bound passes
streamed response resolves a scoped binding, then throws fails: its instance is still bound passes
terminating() callback resolves a scoped binding, then throws fails: its instance is still bound passes
full suite (pest --parallel) 5 failed, 381 passed, 27 skipped 386 passed, 27 skipped

The suite, rector --dry-run, pint --test, phpstan, profanity and type coverage (100%) pass on both PHP 8.4 and 8.5, with Laravel 13.38 and Playwright 1.63 (Chromium). I also checked these cases with throwaway tests that are not in the PR. A streamed response, a scoped() declared with a typed closure, $this->mock() through Mockery, a facade mock on a scoped accessor, a mock bound between two visits, and Http::fake() after the facade cache is cleared all behave as expected. 4.x has the same bug. Ported by hand, the first version of this change passes the first four tests there on Laravel 12.70.

I also checked pestphp/pest#1986's own case with the real inertia-laravel SsrState (SSR on, a fake gateway in place of the Node server): on 5.x with inertia-laravel 3.1.1, the second visit() in a test renders the first page's HTML, and with this change it renders the right one. inertia-laravel 3.5.2 (inertiajs/inertia-laravel#924) resets that cached response itself, so on newer versions the Inertia symptom is gone either way. The scoped instances, and with them Context, still leak without this change.

Not covered: a scoped instance the test resolves itself before visit() counts as the test's own, so it stays shared across requests, as with $this->get(). After a request, the test body gets a fresh instance rather than the one the request used.

@MrPunyapal

Copy link
Copy Markdown
Member

Could you put scoped-instance cleanup in a finally block covering request handling, termination, and streamed content?

I reproduced a leak when a stream callback resolves a scoped binding and then throws: the response returns 500, but the instance remains cached because execution never reaches forgetScopedInstancesSince(). Please add a regression test for this path.

The server handles every request of a test on the same container and
never forgets its scoped instances, so a scoped() binding resolved in
one request is reused by the next one, like a singleton.

After each request, forget the scoped instances it resolved and clear
the facade cache, as the queue worker does between jobs. Instances the
test bound before the request, such as a mock, stay bound, as they do
with $this->get().

Fixes pestphp/pest#1986
@ChrisAbner
ChrisAbner force-pushed the fix/flush-scoped-instances branch from 8a47949 to 37ddb38 Compare October 27, 2026 21:28
@ChrisAbner

Copy link
Copy Markdown
Contributor Author

Done in 37ddb38. The cleanup now runs in a finally around handling, terminate and the streamed content, so it also happens when any of them throws. I reproduced your case and added a regression test for it, plus one for a terminating() callback that throws. Both fail without the change. I also rebased onto 5.x, since #267 had changed the same stream block.

@MrPunyapal
MrPunyapal merged commit 944db55 into pestphp:5.x Oct 28, 2026
2 checks passed
@MrPunyapal

Copy link
Copy Markdown
Member

Thanks

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.

Browser plugin: Laravel driver never flushes scoped() bindings, so Inertia SSR serves the first page on every later request in a test

2 participants