pestphp / pestphp/pest

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

Open
#1,734 4 comments 1 reaction 0 assignees View on GitHub

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, 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.