With Inertia on Laravel, why does redirect()->away() to an external payment portal break an Inertia visit, and what does Inertia::location do instead?
answer
- the XHR follows 3xx on its own
- cross-origin target, no X-Inertia reply
- 409 Conflict plus X-Inertia-Location
- client sets window.location
- plain redirect for non-Inertia requests
basics
~20 sAn Inertia visit is an XHR, so the browser follows the redirect inside it and gets a cross-origin or non-Inertia response the client cannot render. Inertia::location answers 409 with X-Inertia-Location, and the client performs a full window.location visit.
solid answer
~40 sInertia visits are XHR requests, and the browser follows a `302` inside the XHR transparently. When the target is another site, the follow-up request is cross-origin: it is blocked unless that site allows it, and even then the reply has no `X-Inertia` header, so the client reports an error instead of navigating and the address bar never changes. `Inertia::location($url)` handles this: for a request carrying `X-Inertia` it returns an empty `409 Conflict` with the URL in `X-Inertia-Location`, and the client responds with `window.location = url`, a real top-level navigation. For a direct browser request it returns an ordinary redirect instead (`Redirect::away($url)`, or the `RedirectResponse` you passed). The same tool leaves the SPA for in-app pages that are not Inertia pages, such as a Blade-rendered report.
code
php · 22 lines<?php
namespace App\Http\Controllers;
use App\Models\Lease;
use App\Services\RentCheckout;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class RentPaymentController extends Controller
{
public function store(Lease $lease, RentCheckout $checkout): Response
{
Gate::authorize('pay', $lease);
$url = $checkout->startSession($lease);
// Inertia visit: 409 + X-Inertia-Location; direct hit: normal redirect.
return Inertia::location($url);
}
}go deeper
Recall that external redirects from an Inertia request need Inertia::location, which makes the browser do a full page visit.
Explain that the XHR follows 3xx on its own, why 409 with X-Inertia-Location survives, and that non-Inertia requests get a normal redirect.
Recognise the symptoms in production, such as CORS errors or an error dialog after a form post, and test the 409 contract for payment and login hand-offs.
Decide where the app must leave the SPA, such as payment, identity or separate shells, and make those boundaries explicit in routing conventions.
## Why a normal redirect fails After the first load, every Inertia visit (a `<Link>` click, a form post, `router.visit()`) is an **XHR request**. Two facts about XHR decide what happens to a redirect: 1. The browser **follows `3xx` responses automatically** inside the XHR; the client code only ever sees the final response. 2. The page's address bar does not change because of an XHR; only the Inertia client moves it, based on the page object it receives. So `return redirect()->away('https://pay.example.com/session/abc')` after the rent-payment form posts does this: - The browser re-issues the request against the payment site from the XHR. - That is a **cross-origin** request; unless the other site sends permissive CORS headers it is blocked, and the client reports a network error. - If it does get through, the reply is that site's HTML, which has no `X-Inertia` response header. The client treats any such response as an HTTP exception and, unless you cancel the `httpException` event, shows it in an error dialog. Either way the tenant never lands on the payment page. ## What `Inertia::location` returns `Inertia::location($url)` checks the request first: | Request | Response | |---|---| | carries `X-Inertia` (an Inertia visit) | empty body, status `409 Conflict`, header `X-Inertia-Location: $url` | | no `X-Inertia` (direct browser hit) | a normal redirect: `Redirect::away($url)`, or your own `RedirectResponse` as given | `409` is used because the browser does not auto-follow it, so the response reaches the client untouched. The client recognises `409` plus `X-Inertia-Location` and performs `window.location = url`, a full top-level navigation that loads the destination as a normal document. The method also accepts a `RedirectResponse`; it reads the target with `getTargetUrl()`. That is handy when a package hands you a ready-made redirect to an external provider. The `inertia_location($url)` helper is a shortcut for the same call. ## When to reach for it - **External hand-offs** decided on the server: a hosted payment page, an identity provider's login screen, a signed document service. - **Leaving the Inertia app inside your own domain**: a Blade-rendered printable statement, a page served by another root view with different assets, or a route whose response is not an Inertia page. - **After non-GET requests too**: `location()` does not care about the method, so a `POST` that creates a checkout session can answer with it and the client still does a `GET` navigation. For links whose destination is known in advance, a plain `<a href>` is simpler; `Inertia::location` is for destinations the server decides. ## Related `409` signals The adapter reuses the same status for other control responses, which is why a `409` in the network tab is not an error: - A redirect whose target contains a `#fragment` is turned into `409` with `X-Inertia-Redirect`, and the client makes a fresh Inertia `GET` to it. - The asset-version check also answers with `409` and `X-Inertia-Location` for the current URL, forcing a full reload. A `409` control response never carries `X-Inertia`, because it tells the client to navigate rather than render. ## Common mistakes - **Adding CORS headers on the payment site.** Even if the XHR then succeeds, the client receives foreign HTML without `X-Inertia` and shows an error; the browser still has not navigated. - **Returning `Inertia::location()` from a page the user opened directly.** That is fine: without the `X-Inertia` header the method returns an ordinary redirect, so one controller serves both cases. - **Using it for every redirect.** Redirects inside the Inertia app should stay ordinary `redirect()->route(...)` responses; the client follows them as normal visits and keeps the SPA experience. - **Treating the `409` as a failure in monitoring.** Control responses are expected traffic; alert on `5xx`, not on `409` from Inertia routes. ## Checking it In a feature test, send the header and assert the contract: `$this->post(route('rent.pay', $lease), [], ['X-Inertia' => 'true'])->assertStatus(409)->assertHeader('X-Inertia-Location', $checkoutUrl);`. Without the header, the same route should answer with an ordinary redirect to the provider.
- Why a 409 status rather than a 302 with a special header?The browser follows `3xx` responses inside an XHR before any client code sees them, so a special header on a `302` would be lost when the redirect is followed. A `409 Conflict` is not followed automatically, so it reaches the Inertia client, which reads `X-Inertia-Location` and navigates the whole window.
- Can Inertia::location be used for a page in the same Laravel app?Yes. It is the way to leave the Inertia app for any response that is not an Inertia page, such as a Blade-rendered printable statement or an area served by a different root view. The client performs a full navigation, so that page loads as a normal document with its own assets.
A receptionist cannot put your call through to a number outside the building's switchboard, so instead of trying and dropping the line, she hands you a note with the number and you dial it yourself. The 409 is the note; window.location is you dialling.
saying these in an interview costs you the question
- Laravel refuses to redirect to other domains unless they are whitelisted.
- A 409 from an Inertia app always means an asset-version mismatch.
- Inertia::location returns a 409 even for direct, non-Inertia browser requests.
- The fix is to add CORS headers so the XHR can load the payment page.
- Inertia::location only works after GET requests.