In Laravel, why do redirects after a POST default to 302, when would you send 303 instead, and how do you do it?
answer
- which method the browser uses next
- Laravel builders default to 302
- 303 always means: follow with GET
- fetch keeps DELETE/PUT on a 302
- pass the status: to_route($name, $params, 303)
basics
~20 sLaravel's redirect builders default to 302. Browsers turn a 302 after a form POST into a GET, so it works for HTML forms, but only 303 guarantees a GET for any method; pass it as the status argument.
solid answer
~40 sEvery Redirector builder, and `to_route()` and `back()`, takes a `$status` that defaults to **302 Found**. After a classic HTML form POST, browsers follow a 302 with a GET, so post-redirect-get works, and Laravel's method spoofing (`@method('DELETE')` sends a real POST with `_method`) keeps that true for delete and update forms. The gap appears when JavaScript sends a real `PUT`, `PATCH` or `DELETE`: under the Fetch rules a 302 changes only a POST into a GET, so the browser re-sends the `DELETE` to the redirect target, which usually has no such route and answers 405. **303 See Other** always means "fetch the target with GET", so use it for redirects after non-GET actions called from JavaScript: `to_route('basket.show', [], 303)`, `redirect('/basket', 303)` or `back(303)`.
code
php · 15 lines<?php
use App\Models\BasketItem;
use Illuminate\Http\RedirectResponse;
class BasketItemController
{
// Called by fetch(url, { method: 'DELETE' }) from the basket page.
public function destroy(BasketItem $item): RedirectResponse
{
$item->delete();
return to_route('basket.show', [], 303); // follow-up is always a GET
}
}go deeper
Remember that Laravel redirects are 302 by default and that you can pass 303 as the status argument to redirect(), to_route() or back().
Explain why browsers turn a POST under 302 into GET, why that is not true for DELETE or PUT sent by fetch, and how method spoofing keeps forms on POST.
Use 303 on every handler reached by real non-GET requests from JavaScript, and avoid redirects for JSON clients, returning explicit statuses instead.
Set a convention for redirect statuses across web and JavaScript-driven screens so the same controller behaves correctly for forms and fetch calls.
## What the status code tells the browser A redirect is a 3xx status plus a `Location` header. The status tells the client **how** to follow it, in particular which HTTP method to use for the next request: | Status | Meaning | Method on the follow-up request | |---|---|---| | 301 Moved Permanently | the URL moved for good | POST may become GET; other methods are kept | | 302 Found | temporary, "look over there" | POST may become GET; other methods are kept | | 303 See Other | "the result is over there, fetch it" | always GET (HEAD stays HEAD) | | 307 Temporary Redirect | temporary | method and body kept | | 308 Permanent Redirect | permanent | method and body kept | In practice every browser turns a POST that receives a 301 or 302 into a GET, which is why 302 has worked for form handling for decades. The Fetch standard makes the rule explicit: that rewrite applies to **POST only**. ## What Laravel sends Every builder on `Illuminate\Routing\Redirector` (`to()`, `route()`, `action()`, `away()`, `back()`, `guest()`, `intended()`) has `$status = 302` in its signature, and so do the `redirect()`, `back()` and `to_route()` helpers. Nothing in the framework switches to 303 because the request was a POST. ## Where 302 is fine - **Classic forms.** A `<form method="POST">` for "add to basket" posts, receives 302, and the browser GETs the basket page. Refreshing that page does not resubmit the form. - **Spoofed methods.** HTML forms cannot send DELETE, so a Blade form uses `@method('DELETE')`, which adds a hidden `_method` field. The wire method is still POST, so a 302 still becomes a GET. ## Where 302 breaks A bookshop's basket page uses JavaScript: clicking the bin icon calls `fetch('/basket/items/42', { method: 'DELETE' })`. The controller removes the item and returns `to_route('basket.show')`, a 302. Following the Fetch rules, the browser repeats the **DELETE** against `/basket`. There is no `DELETE /basket` route, so the user gets a 405 Method Not Allowed, or worse, a route that does accept DELETE there runs a second destructive action. The fix is a 303: 1. `return to_route('basket.show', [], 303);` 2. or `return redirect()->route('basket.show', status: 303);` 3. or `return back(303);` With 303 the browser always follows with GET, whatever the original method. Front-end adapters that make these requests for you often convert the status for PUT, PATCH and DELETE responses automatically; how Inertia does that is covered with Inertia's navigation. ## Choosing a status in a Laravel app - Normal form POST handlers: the 302 default is fine, and changing it gains little. - Handlers reached by real PUT, PATCH or DELETE requests from JavaScript: return 303. - A permanent URL move: 301, or 308 if the method must be kept. - Preserving a POST body through a redirect (rare, for example an API moved to a new path): 307 or 308. - API clients that expect JSON usually should not be redirected at all; return a JSON response with the right status instead. ## Checking it in practice The browser's network panel shows each hop with its method, which is the quickest way to see a DELETE repeated against the redirect target. On the server side, a 405 for `DELETE /basket` right after a `DELETE /basket/items/42` in the access log is the typical fingerprint. A feature test can assert the status directly: `->assertStatus(303)` together with `->assertRedirect(route('basket.show'))`. If a whole group of controllers is only ever called from JavaScript, it is worth wrapping the redirect in one helper or a response macro so that nobody has to remember the third argument. Conversely, changing every classic form handler to 303 is harmless but rarely worth the churn, since browsers already treat a 302 after POST as "GET the target". ## What interviewers want to hear The default is 302; browsers convert a POST under 302 into a GET, which is why it mostly works; 303 is the code that means "GET the result" for any method; and Laravel lets you pass it as an argument on every builder. Mentioning method spoofing and the fetch-with-DELETE failure shows you have debugged it.
- Why doesn't a Blade form using `@method('DELETE')` hit the same problem with a 302?`@method('DELETE')` only adds a hidden `_method` field. The browser still sends a real POST, and Laravel reads the field to route it as DELETE. Because the wire method is POST, the browser follows a 302 with a GET as usual.
- When would you choose 307 or 308 instead of 303?When the follow-up request must keep the original method and body, for example an API endpoint that moved and should still receive the same POST. 307 is temporary and 308 permanent. 303 is the opposite choice: you want the client to stop and simply GET the result page.
saying these in an interview costs you the question
- Laravel automatically sends 303 when redirecting after a POST.
- A 302 always makes the browser switch to GET, whatever the original method.
- 303 keeps the original method and request body.
- to_route() and back() cannot take a custom status code.
- Method spoofing makes the browser send a real DELETE request.