Skip to content

[Bug]: withHost() rewrites request headers but not the browser's actual connection/origin - causes problems with subdomain routing, Inertia, Vite #1734

Description

@sakihl

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):

  1. GET /login -> 200, plain HTML, Inertia bootstraps.
  2. POST /login -> 302 (standard Inertia redirect flow).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions