[13.x] Add QUERY route method support
Contributed by joelbutcher
Part of the framework v13.35.0 release
Framework 13.x now supports the HTTP QUERY method as a first-class routing verb, backported from #60655. You can register routes with Route::query(), dispatch to them with the standardized QUERY verb, and take advantage of a request() body that GET-style safety conventions traditionally didn't allow. The backport intentionally diverges from its master counterpart in two ways: it leaves the Registrar contract untouched, and it keeps QUERY requests subject to CSRF verification. For anyone upgrading from v13.34.0, the change is purely additive—no contract implementations need updating, and no existing routes change behavior unless they used Route::any() or Route::redirect().
What the QUERY method is for
QUERY is a newer HTTP method designed for safe, repeatable requests that still need to carry a body. A search endpoint with complex filters is the canonical use case: the request is safe (like GET), but the payload doesn't fit cleanly in a query string. In Laravel, such a route is registered like any other verb:
use App\Http\Controllers\SearchProductsController;
Route::query('/products/search', SearchProductsController::class);
Inside the controller, the request is read like any other, whether the body is form-encoded or JSON:
// QUERY /search?term=framework with body: {"filter": "published"}
Route::query('/search', function () {
return [
'term' => request()->query('term'), // query string
'filter' => request()->input('filter'), // request body
];
});
The PR's integration test confirms this works even with php artisan route:cache, so production deployments that rely on cached routes get the same behavior.
What changed under the hood
The diff touches four areas. Router::$verbs now includes QUERY, which is what makes the verb recognized across the framework. Router::query() was added alongside get(), post(), and friends, and the Route facade docblock reflects it for IDE support. RouteRegistrar::$passthru gained query, so registrar-style registration works too:
Route::middleware('throttle:search')->query('/products/search', SearchProductsController::class);
Finally, route:list got a QUERY color entry, and its ANY detection now builds the verb list from Router::$verbs rather than a hardcoded string—so the console output stays accurate as verbs evolve.
Two deliberate differences from the master PR
The master version of this feature (#60655) made two choices that this backport reverses, and both matter for a safe upgrade.
First, the contract. An earlier attempt (#60650) was redirected to master because it modified the Registrar contract, which would have been a breaking change for anyone who implemented that contract themselves. This backport keeps the contract untouched, so custom router implementations continue to work without any changes.
Second, CSRF. #60655 adds QUERY to PreventRequestForgery::isReading(), effectively treating it like GET for token checking. This version does not. The reasoning: while QUERY is safe by specification, the route handler is still your application code, and nothing guarantees it is side-effect free. Browsers do preflight cross-origin QUERY requests, but that protection only helps applications whose CORS configuration is strict—an app allowing credentialed requests from broad origins would otherwise expose every web-middleware QUERY route. Verifying the token like other body-carrying methods keeps CSRF protection independent of CORS configuration. If a specific route really is side-effect free, you can opt out per route using the preventRequestForgery(except: [...]) API.
Ripple effects: any(), redirect(), and route:list
Because QUERY now lives in Router::$verbs, two existing registration methods quietly expand their coverage. Route::any() now also matches QUERY requests, and Route::redirect() responds to them as well. In practice this means middleware and controllers attached via any() will run for QUERY where they previously would not have fired at all. That's usually desirable—the point of any()—but it's worth auditing if you relied on QUERY requests falling through to a 405 or fallback route. The integration tests explicitly cover this: an any() route answers a dispatched QUERY request.
Upgrade impact and testing
For an application on v13.34.0, there is nothing to migrate: no contract changes, no middleware defaults changed, and no existing route definitions are affected unless they use any() or redirect(). The query() and queryJson() testing helpers already landed on 13.x earlier (#60662), so they pair naturally with the new registration method, mirroring postJson():
public function test_search_accepts_query_method(): void
{
$response = $this->queryJson('/products/search', ['filters' => ['name' => 'laravel']]);
$response->assertOk();
}
The PR also adds test coverage confirming that a QUERY route reports QUERY as its method, that JSON bodies are parsed into input(), and that the route cache round-trips QUERY definitions correctly.
Takeaways
Route::query()registers a route for the HTTPQUERYmethod; the verb is now inRouter::$verbs.QUERYrequests carry a body (form or JSON) while staying safe/idempotent by intent—ideal for search endpoints.- Unlike the
masterPR, theRegistrarcontract is unchanged andQUERYremains CSRF-verified; opt out per route if needed. Route::any()andRoute::redirect()now also matchQUERYrequests.- No upgrade steps are required coming from v13.34.0;
query()/queryJson()test helpers and route caching work out of the box.