Every Laravel app with a serious search screen ends up with the same route: Route::post('/products/search', ...). The filters are too big or too nested for a query string, and GET with a body is not something you can count on. So we use POST, and every tool between the client and the app now thinks that request changes data.
Laravel 13.35 gives you another option.
What changed
Laravel v13.35.0, released on 6 October 2026, includes “Add QUERY route method support”. The PR adds a query() method to the router and the Route facade, and adds QUERY to Router::$verbs:
// src/Illuminate/Routing/Router.php, v13.35.0
public static $verbs = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'QUERY'];
The testing helpers query() and queryJson() landed earlier in 13.x (PR #60662), so you can test these routes the same way you test the others.
What QUERY means
The HTTP QUERY method draft describes a method that is safe and idempotent like GET, but sends its query in the request body like POST. It is still a draft, so treat the details as subject to change.
That one property matters more than it sounds. POST tells retries, proxies, logs, and other developers “this may change something”. A search does not change anything. With POST you are sending a message that is false, and every layer has to be told about it by hand.
The route
Moving an endpoint is small. Before:
Route::post('/products/search', SearchProductsController::class);
After, on Laravel 13.35 or later:
use App\Http\Controllers\SearchProductsController;
use Illuminate\Support\Facades\Route;
Route::query('/products/search', SearchProductsController::class);
The controller does not change. $request->input() reads the JSON body as it does for POST, and $request->query() still reads the query string. The framework’s own tests cover both: a JSON body read with input('filters.name'), and a query string mixed with body data.
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class SearchProductsController
{
public function __invoke(Request $request)
{
$validated = $request->validate([
'filters.brand' => ['array'],
'filters.brand.*' => ['string'],
'filters.price_max' => ['nullable', 'numeric'],
]);
return Product::query()
->when($validated['filters']['brand'] ?? null, fn ($q, $brands) => $q->whereIn('brand', $brands))
->when($validated['filters']['price_max'] ?? null, fn ($q, $max) => $q->where('price', '<=', $max))
->paginate(25);
}
}
And the test:
public function test_search_filters_by_brand(): void
{
Product::factory()->create(['brand' => 'acme']);
Product::factory()->create(['brand' => 'other']);
$this->queryJson('/api/products/search', [
'filters' => ['brand' => ['acme']],
])
->assertOk()
->assertJsonCount(1, 'data');
}
On the client, fetch accepts any method name:
const res = await fetch('/api/products/search', {
method: 'QUERY',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ filters: { brand: ['acme'], price_max: 120 } }),
});
Three things to check first
1. CSRF still applies on web routes
The Laravel PR made a deliberate choice: QUERY is still checked for a CSRF token. The PR explains that the spec calls QUERY safe, but your handler is app code, and nothing forces it to be free of side effects. So a QUERY route in the web group needs a token, just like POST. If you call it from your own frontend with the usual X-XSRF-TOKEN or X-CSRF-TOKEN header, nothing changes. If you want to skip the check for one route, the PR points to preventRequestForgery(except: [...]).
I agree with this choice. It means moving from POST to QUERY on a web route does not open a new hole.
2. Route::any() now matches QUERY
Because QUERY was added to Router::$verbs, every Route::any() route and every Route::redirect() now also answers QUERY requests. The route:list command also reads ANY from the verbs list now. If you have an any() route that writes data, it now takes one more method. Search your routes for any( after you upgrade.
3. Cross-origin calls need a preflight
QUERY is not one of the methods a browser sends without asking first, so a cross-origin call triggers a CORS preflight. If your config/cors.php lists methods by hand, add QUERY to allowed_methods. The default ['*'] already covers it.
Why now, before caches support it
This is the part you can argue with. Most CDNs and reverse proxies do not cache QUERY today, and some older proxies or WAF rules may reject a method they do not know. So why switch?
Because the main gain is not caching. It is that the request tells the truth. For endpoints where you own the client and the server, like an admin search, an internal API, or a service to service call, you can test the whole path today. You get a method name that tells retry logic it is safe to retry, makes logs easier to read, and stops a reviewer from asking “does this POST write anything?”.
For public endpoints behind infrastructure you do not control, wait. Test that every hop passes QUERY through before you move.
Key takeaways
- Laravel 13.35 adds
Route::query(). Your controller code stays the same. - Use it for read only search endpoints where you control both ends.
- Keep CSRF on, audit
Route::any(), and allowQUERYin CORS. - Keep POST for public endpoints until every proxy in the path accepts QUERY.
How many of your POST routes only read?