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, not npm 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
.test TLD (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: true request header present) -> 200, body is a syntactically valid Inertia JSON payload, and server-side debugging confirms the response does carry X-Inertia: true and a correct X-Inertia-Version header - 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 is http://127.0.0.1:<port>. Default Vite asset URL (127.0.0.1:<port>) matches and test works.
visit('/') while unauthenticated, redirecting to /login via an absolute Location header. Playwright actually navigates to that absolute URL, so the page's real origin becomes subdomain1.localhost:<port>. The default Vite asset URL is still 127.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.
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 theHost,SERVER_NAME, andHTTP_HOSTvalues 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 withself::DEFAULT_HOST, // Always bind to 127.0.0.1 for serverandLaravelHttpServer::handleRequest()separately rewrites the request's host-related headers/server vars to match whateverPlaywright::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 thatwithHost()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
npm run build, notnpm run dev).Route::domain().testTLD (e.g.module1.mydomain.test)Symptom 0 -
withHost()pointed at a real Herd.testdomain doesn't workReproduction setup
This doesn't reach the app at all. The plugin's internal server always binds to
127.0.0.1by design (see the commit cited above), regardless of what host is configured - sowithHost()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
*.localhostsubdomains for testing instead (e.g.module1.localhost), withAPP_URL=http://localhostin.env.testing. This works because*.localhostresolves to127.0.0.1, which is where the plugin's server actually is.Symptom 1 - Domain-constrained route returns 404 on redirect target
In the reproduction setup above,
redirect()->route('module1::home_page')generates a relative/home-pageURL by default. The browser follows the redirect, but the request lands on the plugin's internal server with noHostheader matchingsubdomain1.localhost, so the domain-constrained route never matches, leading to aNotFoundHttpException.Confirmed via:
php artisan route:list -vshows the route correctly registered undersubdomain1.localhost- the route definition itself is fine.Fix: make the redirect absolute:
The
Locationheader 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-Inertiaheaders, 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>(thewithHost()-rewritten header), while the XHR is evaluated by the browser against a different real origin. The browser applies CORS to the XHR, and withoutAccess-Control-Expose-Headers, custom response headers includingX-Inertiaare stripped before JavaScript can read them, even though the server sent them correctly.Fix: publish
config/cors.phpand add: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
/loginpage 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 thewithHost()name (origin = that hostname, since Playwright genuinely navigates and connects there).Console output:
The redirect responsible here was Laravel's guest redirect, configured in
bootstrap/app.php:Fix: make this one relative instead:
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()returnsundefinedin Pest Browser, works correctly in productionWhy this happens
Ziggy's
route().current()checkswindow.locationagainst the named routes to figure out which one matches. In a normal browser, this just works —window.locationis 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 theHostheader you set withwithHost(). Sowindow.locationin the test browser is genuinelyhttp://127.0.0.1:<port>/..., which won't match anyRoute::domain()-scoped route.route().current()returnsundefined, even though the route list, the domain, and everything else Ziggy knows is correct.Workaround
Stop relying on
window.location. Pass Ziggy an explicitlocation, built from the request's real host, instead:Server-side (
HandleInertiaRequests), make sure Ziggy's ownurland thelocationyou send both come from the same place — the current request — not from Ziggy's own default resolution (which can also land on127.0.0.1in this environment):Client-side (
app.js), pass thatlocationinto the Vue plugin instead of letting it fall back towindow.location:If your app uses the
@routesBlade directive as well as this manual config, remove@routes— the two can end up with differenturlvalues and silently disagree about which route matches. See tighten/ziggy#889.If you use this explicit
locationpattern 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.1binding makingwindow.locationunreliable for matching against real domain-scoped routes. The workaround is the samelocation-override pattern used for Inertia SSR (wherewindow.locationdoesn'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.