🕮6 min read · 1,116 words
HTTP has had the same core methods since the 1990s — GET, POST, PUT, PATCH, DELETE. They’ve served the web well. But one scenario has always been awkward: complex searches that need a request body.
RFC 10008, published in 2026, formalizes the HTTP QUERY method — a new HTTP verb specifically designed for safe, idempotent requests that carry a body. Here’s what it is, why it exists, and what it means for your API design.
The Problem QUERY Solves
Consider a complex search request. You want to search for orders that:
- Were placed between July 1–25, 2026
- Have status “pending” or “processing”
- Are from clients in Madhya Pradesh or Maharashtra
- Have a total value over ₹50,000
- Include specific product categories
- Are assigned to specific sales representatives
How do you send this in a GET request? You encode it as query parameters:
GET /api/orders?date_from=2026-07-01&date_to=2026-07-25&status[]=pending&status[]=processing&states[]=MP&states[]=MH&min_value=50000&categories[]=electronics&categories[]=furniture&reps[]=user_42&reps[]=user_67
# This URL is 250+ characters and becomes completely unreadable
# Some proxies and servers truncate URLs at 2048 characters
# Complex nested filters become nearly impossible
The alternative — use POST:
POST /api/orders/search
Content-Type: application/json
{
"filters": {
"date_range": { "from": "2026-07-01", "to": "2026-07-25" },
"status": ["pending", "processing"],
"client_states": ["MP", "MH"],
"min_value": 50000,
"categories": ["electronics", "furniture"],
"assigned_reps": ["user_42", "user_67"]
}
}
This works but it’s semantically wrong. POST means “create something” or “perform an action with side effects.” A search has no side effects — it’s a read-only operation. Using POST for reads violates REST semantics, confuses caching layers, and creates problems for documentation tools.
This is the gap HTTP QUERY fills.
What RFC 10008 Defines
The HTTP QUERY method has these characteristics:
- Safe: like GET — it doesn’t modify server state
- Idempotent: like GET — calling it multiple times returns the same result
- Has a request body: unlike GET — it can carry a structured query payload
- Cacheable: responses can be cached like GET responses
- Not a side-effect operation: unlike POST — it’s purely for retrieving data
QUERY /api/orders HTTP/1.1
Host: api.softcrony.com
Content-Type: application/json
Accept: application/json
{
"filters": {
"date_range": { "from": "2026-07-01", "to": "2026-07-25" },
"status": ["pending", "processing"],
"client_states": ["MP", "MH"],
"min_value": 50000,
"categories": ["electronics", "furniture"],
"assigned_reps": ["user_42", "user_67"]
},
"sort": { "field": "created_at", "direction": "desc" },
"pagination": { "page": 1, "per_page": 20 }
}
The response is identical to what you’d return from a GET — just data, no side effects:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60
{
"data": [...],
"meta": {
"total": 147,
"page": 1,
"per_page": 20
}
}
How It Differs from GET and POST
| Property | GET | POST | QUERY |
|---|---|---|---|
| Safe (no side effects) | ✅ | ❌ | ✅ |
| Idempotent | ✅ | ❌ | ✅ |
| Request body | ❌ (technically allowed but discouraged) | ✅ | ✅ |
| Cacheable | ✅ | ❌ (by default) | ✅ |
| Semantic meaning | Retrieve resource | Create/action | Query/search |
| Bookmarkable | ✅ | ❌ | ❌ (body not in URL) |
Implementing HTTP QUERY in Laravel
Laravel’s router doesn’t natively support QUERY yet, but you can handle it:
// routes/api.php — register QUERY method route
use Illuminate\Support\Facades\Route;
Route::match(['QUERY'], '/orders', [OrderController::class, 'query']);
Route::match(['QUERY'], '/products', [ProductController::class, 'query']);
Route::match(['QUERY'], '/clients', [ClientController::class, 'query']);
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\OrderResource;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class OrderController extends Controller
{
/**
* Handle HTTP QUERY requests for searching orders.
*/
public function query(Request $request): AnonymousResourceCollection
{
$validated = $request->validate([
'filters' => 'array',
'filters.date_range' => 'array',
'filters.date_range.from' => 'date',
'filters.date_range.to' => 'date',
'filters.status' => 'array',
'filters.status.*' => 'in:pending,processing,completed,cancelled',
'filters.client_states' => 'array',
'filters.min_value' => 'numeric|min:0',
'filters.max_value' => 'numeric',
'filters.categories' => 'array',
'filters.assigned_reps' => 'array',
'sort' => 'array',
'sort.field' => 'string|in:created_at,total_value,client_name',
'sort.direction' => 'in:asc,desc',
'pagination' => 'array',
'pagination.page' => 'integer|min:1',
'pagination.per_page' => 'integer|min:1|max:100',
]);
$filters = $validated['filters'] ?? [];
$sort = $validated['sort'] ?? ['field' => 'created_at', 'direction' => 'desc'];
$pagination = $validated['pagination'] ?? ['page' => 1, 'per_page' => 20];
$query = Order::query()
->with(['client', 'items', 'assignedRep'])
->when(isset($filters['date_range']), function ($q) use ($filters) {
$q->whereBetween('created_at', [
$filters['date_range']['from'],
$filters['date_range']['to'],
]);
})
->when(!empty($filters['status']), function ($q) use ($filters) {
$q->whereIn('status', $filters['status']);
})
->when(!empty($filters['client_states']), function ($q) use ($filters) {
$q->whereHas('client', function ($q) use ($filters) {
$q->whereIn('state', $filters['client_states']);
});
})
->when(isset($filters['min_value']), function ($q) use ($filters) {
$q->where('total_value', '>=', $filters['min_value']);
})
->when(isset($filters['max_value']), function ($q) use ($filters) {
$q->where('total_value', '<=', $filters['max_value']);
})
->when(!empty($filters['categories']), function ($q) use ($filters) {
$q->whereHas('items.product', function ($q) use ($filters) {
$q->whereIn('category', $filters['categories']);
});
})
->when(!empty($filters['assigned_reps']), function ($q) use ($filters) {
$q->whereIn('assigned_rep_id', $filters['assigned_reps']);
})
->orderBy($sort['field'], $sort['direction']);
$results = $query->paginate(
$pagination['per_page'],
['*'],
'page',
$pagination['page']
);
return OrderResource::collection($results);
}
}
Client-Side Implementation
// JavaScript — sending HTTP QUERY requests
async function searchOrders(filters, sort, pagination) {
const response = await fetch('/api/orders', {
method: 'QUERY',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ filters, sort, pagination }),
});
if (!response.ok) throw new Error('Search failed');
return response.json();
}
// Usage
const results = await searchOrders(
{
date_range: { from: '2026-07-01', to: '2026-07-25' },
status: ['pending', 'processing'],
min_value: 50000,
},
{ field: 'created_at', direction: 'desc' },
{ page: 1, per_page: 20 }
);
Fallback for Older Clients
Some HTTP clients and proxies don’t support custom methods yet. A safe fallback pattern:
// Support both QUERY and POST with method override header
// routes/api.php
Route::match(['QUERY', 'POST'], '/orders/search', [OrderController::class, 'query'])
->middleware('api');
// Client — use X-HTTP-Method-Override for compatibility
async function searchOrders(filters) {
// Try QUERY first
try {
return await fetch('/api/orders', {
method: 'QUERY',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filters),
});
} catch {
// Fallback to POST with method override
return await fetch('/api/orders/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-HTTP-Method-Override': 'QUERY',
},
body: JSON.stringify(filters),
});
}
}
Caching HTTP QUERY Responses
Since QUERY is safe and idempotent, responses can be cached. The cache key includes the request body:
<?php
// Laravel middleware — cache QUERY responses
class CacheQueryResponses
{
public function handle(Request $request, Closure $next)
{
if ($request->method() !== 'QUERY') {
return $next($request);
}
// Cache key = route + body hash
$cacheKey = 'query:' . $request->path() . ':' . md5($request->getContent());
if (Cache::has($cacheKey)) {
return response()->json(Cache::get($cacheKey))
->header('X-Cache', 'HIT');
}
$response = $next($request);
Cache::put($cacheKey, $response->getData(), 60); // 60 seconds
return $response->header('X-Cache', 'MISS');
}
}
Browser and Tool Support in 2026
| Tool/Environment | QUERY Support |
|---|---|
| fetch() API (browsers) | ✅ Works — fetch allows any method string |
| Axios | ✅ Works — pass method: ‘QUERY’ |
| Postman | ✅ Supported in custom method field |
| curl | ✅ curl -X QUERY … |
| Laravel HTTP client | ✅ Http::send(‘QUERY’, $url, […]) |
| Some enterprise proxies | ⚠️ May block unknown methods — test your stack |
| AWS API Gateway | ⚠️ Requires explicit QUERY method configuration |
| Cloudflare | ✅ Passes through unknown methods by default |
When to Use QUERY vs GET vs POST
Use GET when: The search fits comfortably in URL parameters. Simple filters — status, date, category. URLs should be bookmarkable and shareable.
Use QUERY when: Complex nested filters that don’t fit in a URL. Structured search payloads with multiple filter types. You want proper REST semantics for read-only operations. You need response caching for expensive search operations.
Use POST when: The operation has side effects (creating, triggering, updating). You’re working with systems that can’t support custom HTTP methods. The “search” also triggers logging, analytics, or other state changes.
RFC 10008 is a recent standard and real-world adoption will take time. But the pattern it formalizes — safe body-carrying requests — is one that good API designers have been working around for years. Now there’s a proper name and spec for it.
If you’re designing a new API and want to get the HTTP semantics right from the start, our backend team at Softcrony is happy to help with API architecture.
Leave a comment