[Bug]: `withHost()` rewrites request headers but not the browser's actual connection/origin - causes problems with subdomain routing, Inertia, Vite
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 11.7k
- Forks
- 538
- Avg merge
- 4d 11h
- Merged PRs (30d)
- 8
Description
Summary
I have come across several distinct failures when testing a Laravel app that uses Route::domain() for subdomain routing, with Inertia.js and Vite on the frontend. Each looks unrelated on the surface, and none produce a PHP-side exception, so there's no stack trace pointing at the cause. I am posting all of them together since I believe they share the same root mechanism. I apologise if that's wrong.
withHost() rewrites the Host, SERVER_NAME, and HTTP_HOST values seen by Laravel on every request, but does not change what the browser actually connects to. Per commit 8e39693, the internal HTTP server is intentionally bound with self::DEFAULT_HOST, // Always bind to 127.0.0.1 for server and LaravelHttpServer::handleRequest() separately rewrites the request's host-related headers/server vars to match whatever Playwright::host() currently holds.
So the server-side request object consistently reports the configured host, but Playwright's real connection - and therefore the page's real origin for same-origin/CORS purposes - stays at 127.0.0.1:<port> regardless.
Related: Issue #1593 (closed, "fixed" by the addition of withHost()). That issue covered the original 404-on-subdomain-route problem that withHost() solves for the initial request (see below for what still goes wrong once redirects, Inertia, or Vite assets are involved).
The workarounds needed to make the tests work (CORS config, changing redirects from absolute to relative or vice versa) seem to depend on subtle differences depending on exactly how routes are reached. I am not confident that they are enough to make future tests reliable.
Environment
- OS: Windows 11
- Pest: v4.x
- pest-plugin-browser: v4.x (Playwright-based)
- Laravel: 13.x (upgraded from earlier versions, not a clean install)
- Frontend: Inertia.js + Vue + Vite. Built assets (
npm run build, notnpm run dev). - App structure: modular monolith using interNachi\Modular, two modules each served on their own subdomain via
Route::domain() - Local dev environment: Laravel Herd, with each module's subdomain mapped to a
.testTLD (e.g.module1.mydomain.test)
Symptom 0 - withHost() pointed at a real Herd .test domain doesn't work
Reproduction setup
// routes file for module1, domain-constrained
Route::domain(config('subdomains.module1') . '.' . config('app.domain'))
->name('module1::')
->middleware(['auth:module1'])
->group(function () {
Route::get('/', fn () => redirect()->route('module1::home_page'))->name('home');
Route::get('/home-page', [HomePageController::class, 'index'])->name('home_page');
});
# .env.testing
APP_URL=http://mydomain.test
SUBDOMAIN_MODULE1=subdomain1
// Browser test
it('redirects to the login page', function () {
$page = visit('/')->withHost('subdomain1.localhost');
$page->assertPathIs('/login');
});
This doesn't reach the app at all. The plugin's internal server always binds to 127.0.0.1 by design (see the commit cited above), regardless of what host is configured - so withHost() can rewrite headers against that internal server, but it can't route the browser's connection to an external dev server like Herd.
Workaround: use *.localhost subdomains for testing instead (e.g. module1.localhost), with APP_URL=http://localhost in .env.testing. This works because *.localhost resolves to 127.0.0.1, which is where the plugin's server actually is.
# .env.testing
APP_URL=http://localhost
SUBDOMAIN_MODULE1=subdomain1
Symptom 1 - Domain-constrained route returns 404 on redirect target
In the reproduction setup above, redirect()->route('module1::home_page') generates a relative /home-page URL by default. The browser follows the redirect, but the request lands on the plugin's internal server with no Host header matching subdomain1.localhost, so the domain-constrained route never matches, leading to a NotFoundHttpException.
Confirmed via: php artisan route:list -v shows the route correctly registered under subdomain1.localhost - the route definition itself is fine.
Fix: make the redirect absolute:
Route::get('/', fn () => redirect()->route('module1::home_page', absolute: true))->name('home');
The Location header then carries the explicit host, and the browser's next request includes it, so the domain-constrained route matches.
Symptom 2 - Inertia XHR responses arrive without X-Inertia headers, client throws "plain JSON response received"
Sequence (confirmed via request/response logging middleware):
GET /login-> 200, plain HTML, Inertia bootstraps.POST /login-> 302 (standard Inertia redirect flow).GET /home-page(Inertia XHR,X-Inertia: truerequest header present) -> 200, body is a syntactically valid Inertia JSON payload, and server-side debugging confirms the response does carryX-Inertia: trueand a correctX-Inertia-Versionheader - but the browser console reports "All Inertia requests must receive a valid Inertia response, however a plain JSON response was received," and the frontend never reads the props.
Cause: the page is served from http://subdomain1.localhost:<port> (the withHost()-rewritten header), while the XHR is evaluated by the browser against a different real origin. The browser applies CORS to the XHR, and without Access-Control-Expose-Headers, custom response headers including X-Inertia are stripped before JavaScript can read them, even though the server sent them correctly.
Fix: publish config/cors.php and add:
'exposed_headers' => ['X-Inertia'],
I think has no effect on production, since same-origin requests aren't subject to CORS, but is needed for this request pattern under test.
Symptom 3 - Built Vite assets fail to load with CORS errors, depending on how the page was reached
This didn't reproduce consistently - the same /login page passed or failed depending on how the browser arrived there:
visit('/login')directly. The page's real origin ishttp://127.0.0.1:<port>. Default Vite asset URL (127.0.0.1:<port>) matches and test works.visit('/')while unauthenticated, redirecting to/loginvia an absoluteLocationheader. Playwright actually navigates to that absolute URL, so the page's real origin becomessubdomain1.localhost:<port>. The default Vite asset URL is still127.0.0.1:<port>-> cross-origin -> CORS blocked.
It seems as if page's real origin depends on whether it was reached via a direct visit() (origin = 127.0.0.1:<port>) or via an absolute redirect referencing the withHost() name (origin = that hostname, since Playwright genuinely navigates and connects there).
Console output:
Access to script at 'http://127.0.0.1:<port>/build/assets/app-XXXX.js' from origin
'http://subdomain1.localhost:<port>' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
The redirect responsible here was Laravel's guest redirect, configured in bootstrap/app.php:
->redirectGuestsTo(function (Request $request) {
// ...
return route($module . '::login'); // absolute by default
});
Fix: make this one relative instead:
return route($module . '::login', absolute: false);
This keeps Playwright's connection at 127.0.0.1:<port> for this redirect, matching where Vite's default asset URLs already point.
Note this is the opposite fix from Symptom 1, where a redirect needed to become absolute rather than relative. The difference: the Symptom 1 redirect is followed by Inertia's own JS after an XHR, which needs an explicit absolute target; the Symptom 3 redirect is a plain browser-level navigation, where an absolute URL instead moves the page to a different origin than the one Vite's default asset resolution expects. Which one is correct depends on whether Inertia's client or the browser itself follows the redirect - not something obvious from the route/controller code alone.
Symptom 4: Ziggy's route().current() returns undefined in Pest Browser, works correctly in production
Why this happens
Ziggy's route().current() checks window.location against the named routes to figure out which one matches. In a normal browser, this just works — window.location is always wherever the page actually is.
Pest Browser breaks this assumption: its internal server always binds to 127.0.0.1:<random_port> (see the main issue above), regardless of the Host header you set with withHost(). So window.location in the test browser is genuinely http://127.0.0.1:<port>/..., which won't match any Route::domain()-scoped route. route().current() returns undefined, even though the route list, the domain, and everything else Ziggy knows is correct.
Workaround
Stop relying on window.location. Pass Ziggy an explicit location, built from the request's real host, instead:
Server-side (HandleInertiaRequests), make sure Ziggy's own url and the location you send both come from the same place — the current request — not from Ziggy's own default resolution (which can also land on 127.0.0.1 in this environment):
'ziggy' => fn () => [
...(new Ziggy(url: $request->getSchemeAndHttpHost()))->toArray(),
'location' => $request->url(),
],
Client-side (app.js), pass that location into the Vue plugin instead of letting it fall back to window.location:
const ziggyConfig = props.initialPage.props.ziggy;
const app = createApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue, {
...ziggyConfig,
location: new URL(ziggyConfig.location),
});
If your app uses the @routes Blade directive as well as this manual config, remove @routes — the two can end up with different url values and silently disagree about which route matches. See tighten/ziggy#889.
If you use this explicit location pattern with Inertia, also be aware it goes stale after client-side (SPA) navigation — route().current() keeps matching against whichever page was loaded first, until a full reload. See tighten/ziggy#890 for why, and the workaround.
Takeaway
This isn't a Ziggy bug on its own - it's a consequence of Pest Browser's fixed 127.0.0.1 binding making window.location unreliable for matching against real domain-scoped routes. The workaround is the same location-override pattern used for Inertia SSR (where window.location doesn't exist at all), applied here for a different reason. That pattern brings its own pitfalls, covered in tighten/ziggy#889 and tighten/ziggy#890 above.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the browser plugin's withHost() handling and LaravelHttpServer::handleRequest(), including the DEFAULT_HOST binding described in the issue. Reproduce the provided subdomain, redirect, Inertia, Vite, and Ziggy cases, then determine the expected browser origin and verify that the relevant requests and headers behave consistently.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, laravel, php, playwright, vite
- Domain
- networking, testing, web-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 28/100