skip to content

Why does Inertia's Laravel adapter turn a 302 into a 303 after a PUT, PATCH or DELETE visit, and what breaks without it?

level: middleimportance: must knowfreq 48%

answer

  1. redirects are followed inside the XHR
  2. 302 keeps PUT, PATCH and DELETE
  3. 303 always follows with GET
  4. POST already switches on 302
  5. global EnsureGetOnRedirect since 3.0.5

basics

~20 s

Inertia 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 s

After `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
<?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

for a junior

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.

for a middle

Explain how browsers follow 302 and 303 inside an XHR, why POST is exempt, and that only X-Inertia requests are converted.

for a senior

Diagnose 405s after mutations, know the 3.0.5 global middleware that covers redirects produced outside HandleInertiaRequests, and test the status.

for a principal

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.