Why does Inertia's Laravel adapter turn a 302 into a 303 after a PUT, PATCH or DELETE visit, and what breaks without it?
answer
- redirects are followed inside the XHR
- 302 keeps PUT, PATCH and DELETE
- 303 always follows with GET
- POST already switches on 302
- global EnsureGetOnRedirect since 3.0.5
basics
~20 sInertia visits are XHRs, and a 302 after PUT, PATCH or DELETE is followed with the same method, so the redirect target receives a DELETE. The adapter rewrites 302 to 303 See Other, which the browser always follows with GET.
solid answer
~40 sAfter `router.delete('/invoices/7')` the controller typically returns `redirect()->route('invoices.index')`, a `302`. The browser follows redirects inside the XHR itself, and for a `302` it only switches the method to `GET` when the original was `POST`; a `PUT`, `PATCH` or `DELETE` is replayed against the new URL. The list route then gets a `DELETE`, which usually means a `405 Method Not Allowed` or, worse, another destructive action. `303 See Other` always means "follow with GET", so the Laravel adapter rewrites a `302` to `303` when the request carries `X-Inertia` and its method is `PUT`, `PATCH` or `DELETE`. In inertia-laravel 3 this happens in `Inertia\Middleware` and, since 3.0.5, also in a global `EnsureGetOnRedirect` middleware the service provider pushes onto the kernel, so redirects from middleware that runs outside your `HandleInertiaRequests` are converted too.
code
php · 20 lines<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class InvoiceController extends Controller
{
public function destroy(Invoice $invoice): RedirectResponse
{
Gate::authorize('delete', $invoice);
$invoice->delete();
// A plain 302; the Inertia adapter sends 303 for this DELETE visit.
return redirect()->route('invoices.index');
}
}go deeper
Recall that after an Inertia PUT, PATCH or DELETE the redirect must be followed with GET, and the adapter makes that happen with a 303.
Explain how browsers follow 302 and 303 inside an XHR, why POST is exempt, and that only X-Inertia requests are converted.
Diagnose 405s after mutations, know the 3.0.5 global middleware that covers redirects produced outside HandleInertiaRequests, and test the status.
Make redirect-after-mutation a documented convention and ensure custom middleware and non-Inertia clients follow compatible status rules.
## The redirect after a mutation The usual Laravel pattern after changing data is post/redirect/get: the action updates or deletes, then redirects to a page to show. With Inertia the mutation is an XHR visit: ```jsx router.delete(`/invoices/${invoice.id}`) ``` and the action ends with `return redirect()->route('invoices.index');`, which is a `302 Found`. ## Why a 302 is not enough The browser follows redirects inside an XHR on its own; the Inertia client only sees the final response. How it follows depends on the status: | Status | Method used for the follow-up request | |---|---| | `302` after `POST` | `GET`, by long-standing browser behaviour | | `302` after `PUT`, `PATCH`, `DELETE` | the **same** method | | `303 See Other` | always `GET` | So a `302` after `DELETE /invoices/7` makes the browser send `DELETE /invoices`. That route usually has no `DELETE` handler, so the user sees a `405`; if one exists, the app performs a second destructive action. ## What the adapter does `Inertia\Middleware::handle()` checks every Inertia response: if the status is `302` and the request method is `PUT`, `PATCH` or `DELETE`, it calls `setStatusCode(303)`. The method test uses `$request->method()`, which honours Laravel's `_method` spoofing, so a spoofed `PUT` form post is covered too. In inertia-laravel **3.0.5** a second place was added. The adapter's service provider pushes `Inertia\Middleware\EnsureGetOnRedirect` onto the global middleware stack, and it applies the same rule to any `302` answering an Inertia `PUT`, `PATCH` or `DELETE` request. That matters because Laravel's middleware priority sorting can run some middleware outside `HandleInertiaRequests`, so their redirects never pass through it. The Inertia 3 upgrade guide describes the symptom with rate-limited `PUT`, `PATCH` and `DELETE` requests receiving a `302` and the browser retrying the original method, and credits a middleware-priority change; in the pinned 3.4 source that change was reverted in 3.0.4 and the global middleware took its place. ## What this means for your code - Return ordinary redirects: `redirect()->route(...)`, `back()`, `to_route(...)`. You do not need `redirect(..., 303)` yourself. - `POST` is left alone because browsers already switch to `GET` on a `302`. - A `301` or `307` is not rewritten. A `307` explicitly preserves the method, so do not use it after a mutation. - Uploads with `PUT` or `PATCH` are sent as `POST` with a `_method` field in Laravel apps; the rule still applies because the spoofed method is what `$request->method()` returns. ## Diagnosing a missing 303 Symptoms: a `405` in the network tab right after a successful delete, or a second request with the same method to the redirect target. Check: 1. The first response's status: `302` means no conversion happened. 2. Whether the request carried `X-Inertia`; plain `fetch` calls from custom code do not, and the adapter only converts Inertia requests. 3. The installed inertia-laravel version; before 3.0.5 only `HandleInertiaRequests` converted, so a redirect returned by middleware that ran around it could slip through. ## Common misconceptions - **"The server should always send 303."** Only the rewrite for Inertia `PUT`, `PATCH` and `DELETE` requests is needed; classic HTML forms keep working with `302`, because browsers submit them as `POST`. - **"The client turns the method into GET."** The Inertia client never sees the `302`; the browser follows it inside the XHR, which is why the fix has to be a status the browser understands. - **"`back()` avoids the problem."** `back()` is also a `302`, so it needs the same conversion, which the adapter provides. ## Testing it A feature test can pin the behaviour: ```php $this->delete(route('invoices.destroy', $invoice), [], ['X-Inertia' => 'true']) ->assertStatus(303) ->assertRedirect(route('invoices.index')); ``` The Inertia header matters: without it the request is not an Inertia visit and the `302` is left as is, which is correct for a classic HTML form.
- Why is POST not converted to 303 as well?Browsers already follow a `302` after a `POST` with a `GET`, a long-standing behaviour the HTTP specification allows. Only `PUT`, `PATCH` and `DELETE` keep their method on a `302`, so those are the methods the adapter rewrites. Converting `POST` would be harmless but unnecessary.
- Would returning redirect()->route('invoices.index', status: 307) be a good idea after a delete?No. A `307` tells the browser to repeat the request with the same method and body at the new URL, so the list route would receive the `DELETE`. The adapter only rewrites `302`, so a `307` passes through untouched. After a mutation, return an ordinary redirect and let the adapter produce `303`.
saying these in an interview costs you the question
- Browsers always follow any redirect with GET.
- You must return redirect(..., 303) yourself after every Inertia DELETE.
- The adapter converts 302 to 303 for POST requests too.
- The conversion applies to every request, Inertia or not.
- A 307 is a safe redirect after a delete.