The HTTP QUERY method is a new addition to the HTTP spec (RFC 10008) designed for exactly one thing: queries. It is a safe, cacheable method that carries its parameters in the request body, giving you the expressiveness of a POST payload without giving up the read-only semantics of GET—and without cramming complex filters into a URL that may hit length limits along the way.
Laravel 13.19 added Http::query() to the HTTP client along with query() and queryJson() testing helpers. A first-class Route::query() helper has been merged for Laravel 14, but you don't have to wait: Laravel's router already accepts custom verbs today via Route::match(). In this tutorial, we'll use it to build a small search endpoint backed by Laravel Scout's database driver.
A single search string like ours doesn't strictly need QUERY—the method starts paying for itself as the request grows into something GET can't carry comfortably:
- Structured filters with ranges, arrays, and and/or groups (product or listing search)
- Geospatial queries that send polygon coordinates ("search within this map area")
- Batch lookups that fetch hundreds of records by ID
- Searches on sensitive values like emails or phone numbers, which don't belong in URLs, access logs, or browser history
- Reporting endpoints with date ranges and group-bys that are reads but get modeled as
POST /reports/runtoday
Setting Up
Starting from a fresh Laravel 13 application, install the API routes file and Scout:
php artisan install:apicomposer require laravel/scout
Scout's database driver needs no external search service—it uses WHERE LIKE queries against your existing database, which is a good fit for small-to-medium datasets. Enable it in your .env file:
SCOUT_DRIVER=database
Next, create an Article model with a migration and factory:
php artisan make:model Article -mf
The migration defines a title and body:
Schema::create('articles', function (Blueprint $table) { $table->id(); $table->string('title'); $table->text('body'); $table->timestamps();});
Add the Searchable trait to the model and define which columns Scout should search:
namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Laravel\Scout\Searchable; class Article extends Model{ use HasFactory, Searchable; protected $fillable = ['title', 'body']; public function toSearchableArray(): array { return [ 'title' => $this->title, 'body' => $this->body, ]; }}
Defining a QUERY Route
Laravel's router doesn't support Route::query() in Laravel 13 (it will be available starting in Laravel 14). However, Laravel's router registers routes for any verb you pass to Route::match(), including QUERY.
Add the following to routes/api.php:
use App\Models\Article;use Illuminate\Http\Request;use Illuminate\Support\Facades\Route; Route::match(['QUERY'], '/articles/search', function (Request $request) { $validated = $request->validate([ 'search' => ['required', 'string'], 'per_page' => ['sometimes', 'integer', 'between:1,50'], ]); return Article::search($validated['search']) ->paginate($validated['per_page'] ?? 15);});
Note that validation and $request->input() read from the JSON request body just like they would for a POST request—no extra work required. Running php artisan route:list confirms the route is registered with the QUERY verb:
QUERY api/articles/search
A request to this endpoint looks like a POST with GET semantics—the search criteria travel in the body:
QUERY /api/articles/search HTTP/1.1Content-Type: application/jsonAccept: application/json {"search": "scout", "per_page": 10}
Testing the Endpoint
Laravel 13.19's queryJson() testing helper mirrors postJson() and friends, so exercising the endpoint feels familiar:
namespace Tests\Feature; use App\Models\Article;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class ArticleSearchTest extends TestCase{ use RefreshDatabase; public function test_it_searches_articles_with_the_query_method(): void { Article::factory()->create(['title' => 'Getting Started With Laravel Scout']); Article::factory()->create(['title' => 'Queues in Depth']); $this->queryJson('/api/articles/search', ['search' => 'scout']) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.title', 'Getting Started With Laravel Scout'); } public function test_it_validates_the_search_term(): void { $this->queryJson('/api/articles/search', []) ->assertUnprocessable() ->assertJsonValidationErrors('search'); }}
On the consuming side, Laravel's HTTP client can call QUERY endpoints with the Http::query() method added in 13.19:
use Illuminate\Support\Facades\Http; $response = Http::acceptJson()->query('https://example.com/api/articles/search', [ 'search' => 'scout',]); $articles = $response->json('data');
A Gotcha: PHP's Built-in Server
If you develop with php artisan serve, requests using the QUERY method get a 501 Not Implemented response before they ever reach Laravel—the built-in server hard-codes the request methods it accepts. Nginx-based environments like Laravel Herd and Valet pass the method through to your application. Laravel's HTTP testing helpers avoid the issue entirely since they dispatch requests through the framework directly.
Using QUERY on Web Routes
Our endpoint lives in routes/api.php, so CSRF protection never enters the picture. If you register a QUERY route in routes/web.php, however, you'll hit a 419 error: Laravel 13's PreventRequestForgery middleware only treats GET, HEAD, and OPTIONS as read requests, so QUERY gets the same token check as POST. RFC 10008 defines QUERY as a safe method, and Laravel's next major release exempts it accordingly—but until then, you have two options.
The first option backports the upcoming behavior by extending the middleware and adding QUERY to its list of read verbs:
namespace App\Http\Middleware; use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as Middleware; class PreventRequestForgery extends Middleware{ /** * Determine if the HTTP request uses a 'read' verb. * * QUERY is a safe method (RFC 10008), so treat it like GET. * Laravel 14 ships this behavior; delete this override after upgrading. */ protected function isReading($request) { return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS', 'QUERY']); }}
Then swap it into the web middleware group in bootstrap/app.php:
use App\Http\Middleware\PreventRequestForgery;use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as BasePreventRequestForgery; ->withMiddleware(function (Middleware $middleware): void { $middleware->web(replace: [ BasePreventRequestForgery::class => PreventRequestForgery::class, ]);})
Every QUERY route is now CSRF-exempt while POST, PUT, PATCH, and DELETE keep full protection, matching what Laravel 14 will do out of the box. When you upgrade, delete the class and the replace line and nothing else changes.
The second option opts a single route out of the middleware:
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; Route::match(['QUERY'], '/articles/search', $handler) ->withoutMiddleware(PreventRequestForgery::class);
This works fine for a one-off endpoint, but you'll need to remember it on every QUERY route you add, and it removes the middleware from the route entirely rather than classifying the verb as a read. Since the middleware override is a two-file change that disappears cleanly on upgrade, we'd reach for it first.
One note on verifying either approach: the CSRF middleware skips verification when the application environment is testing, so a feature test won't hit the 419 unless you reassign the environment first (for example, $this->app['env'] = 'production'; in your test's setUp() method).
Finally, remember what the exemption implies: the framework is trusting your QUERY handlers to be read-only, just as it trusts your GET handlers. Don't mutate state in a QUERY route.
Before You Ship QUERY to Production
Laravel handles QUERY well—the routes even work with php artisan route:cache—but the framework is only one hop in the chain. A few things to check before committing to the method in a production API:
- Every intermediary must pass the method through. We already saw PHP's built-in server return a 501; CDNs, WAFs, and load balancers can be similarly opinionated about verbs they don't recognize. Test the full path from client to app, not just the app.
- Browser callers have caveats.
fetch()sends QUERY without complaint, but HTML forms only speak GET and POST, and QUERY is not a CORS-safelisted method—every cross-origin request will trigger a preflight. - The caching benefit is still on paper. The RFC defines QUERY as cacheable, but browsers and CDNs don't implement QUERY caching yet, so don't adopt the method for that win alone today.
- Spec tooling is catching up. OpenAPI only gained a first-class
queryoperation in version 3.2, released in September 2025—generators and SDK toolchains still targeting 3.0 or 3.1 can't describe the endpoint, and some HTTP client libraries hard-code the verbs they accept.
None of these are dealbreakers for an internal API where you control both ends—which is also the easiest place to try QUERY first.
What's Next
When the next major version of Laravel ships, the Route::match(['QUERY'], ...) call can become Route::query('/articles/search', ...) and the CSRF override above can be deleted—the rest of the code in this tutorial stays the same.
Between routing, the HTTP client, and the testing helpers, Laravel covers both ends of the QUERY method today. You can learn more about the QUERY method in RFC 10008 and follow the routing support in Pull Request #60655.