PHP
PHP is arguably the fastest language to adopt custom HTTP methods — curl takes any string in CURLOPT_CUSTOMREQUEST, Guzzle delegates to PSR-7, and both major frameworks already support flexible routing. Here’s everything you need to ship QUERY in production today.
Native PHP (curl)
Section titled “Native PHP (curl)”The most direct approach. CURLOPT_CUSTOMREQUEST sets the method, and CURLOPT_POSTFIELDS carries the body — exactly like you already do for custom DELETE or PATCH requests.
<?php
$payload = json_encode([ 'filter' => [ 'city' => 'Berlin', 'active' => true, ], 'limit' => 25, 'offset' => 0,]);
$ch = curl_init('https://api.example.com/contacts');
curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'QUERY', CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'Content-Length: ' . strlen($payload), ],]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);curl_close($ch);
$data = json_decode($response, true);Why this works
Section titled “Why this works”CURLOPT_CUSTOMREQUEST only changes the method string in the request — libcurl doesn’t validate against a list of “official” methods. CURLOPT_POSTFIELDS sends the body regardless of method. This combo works on any PHP 7.4+.
Gotcha: Don’t use
CURLOPT_POST => truealongsideCURLOPT_CUSTOMREQUEST. TheCURLOPT_POSTflag internally forces the method back to POST and may cause conflicts. Set onlyCURLOPT_CUSTOMREQUESTandCURLOPT_POSTFIELDS.
Guzzle
Section titled “Guzzle”Guzzle accepts any HTTP method as the first argument to $client->request(). No hacks required.
<?php
use GuzzleHttp\Client;
$client = new Client([ 'base_uri' => 'https://api.example.com', 'timeout' => 10.0,]);
$response = $client->request('QUERY', '/contacts', [ 'json' => [ 'filter' => [ 'city' => 'Tokyo', 'role' => 'engineer', ], 'limit' => 50, ],]);
$status = $response->getStatusCode(); // 200$body = json_decode($response->getBody(), true);With error handling
Section titled “With error handling”<?php
use GuzzleHttp\Client;use GuzzleHttp\Exception\ClientException;use GuzzleHttp\Exception\ServerException;
$client = new Client(['base_uri' => 'https://api.example.com']);
try { $response = $client->request('QUERY', '/products', [ 'json' => [ 'filter' => ['category' => 'electronics', 'max_price' => 500], 'sort' => ['price' => 'asc'], 'limit' => 20, ], 'headers' => [ 'Accept' => 'application/json', ], ]);
$products = json_decode($response->getBody(), true);
} catch (ClientException $e) { // 4xx — invalid request (e.g. missing Content-Type) $error = json_decode($e->getResponse()->getBody(), true);
} catch (ServerException $e) { // 5xx — server problem error_log('API failed: ' . $e->getMessage());}Note: Guzzle’s
'json'option automatically setsContent-Type: application/json. No need to set it manually.
Laravel
Section titled “Laravel”Laravel doesn’t have a dedicated helper for QUERY (like Route::get or Route::post), but Route::match() handles it in one line.
Defining the route
Section titled “Defining the route”use App\Http\Controllers\ContactController;use Illuminate\Support\Facades\Route;
Route::match(['QUERY'], '/contacts', [ContactController::class, 'search']);Controller
Section titled “Controller”<?php
namespace App\Http\Controllers;
use App\Models\Contact;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;
class ContactController extends Controller{ public function search(Request $request): JsonResponse { // QUERY body comes from content — not from query string parameters $filters = $request->json()->all();
$query = Contact::query();
if ($city = data_get($filters, 'filter.city')) { $query->where('city', $city); }
if (data_get($filters, 'filter.active') !== null) { $query->where('active', data_get($filters, 'filter.active')); }
$limit = data_get($filters, 'limit', 25); $offset = data_get($filters, 'offset', 0);
$contacts = $query->limit($limit)->offset($offset)->get();
return response()->json([ 'data' => $contacts, 'total' => $query->count(), ]); }}Middleware: validate Content-Type
Section titled “Middleware: validate Content-Type”The RFC requires rejecting QUERY requests without Content-Type. A simple middleware handles this:
<?php
namespace App\Http\Middleware;
use Closure;use Illuminate\Http\Request;
class ValidateQueryContentType{ public function handle(Request $request, Closure $next) { if ($request->method() === 'QUERY' && !$request->hasHeader('Content-Type')) { return response()->json([ 'error' => 'Content-Type header is required for QUERY requests', ], 415); // Unsupported Media Type }
return $next($request); }}Tip: On Laravel 11+, register the middleware directly in
bootstrap/app.php. On older versions, use$routeMiddlewarein the Kernel.
Symfony
Section titled “Symfony”Symfony supports custom methods in the #[Route] attribute via the methods parameter.
Controller
Section titled “Controller”<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController{ #[Route('/api/products', name: 'product_search', methods: ['QUERY'])] public function search(Request $request): JsonResponse { $filters = json_decode($request->getContent(), true);
if (!$request->headers->has('Content-Type')) { return $this->json(['error' => 'Content-Type required'], 415); }
// Search logic with filters... $category = $filters['filter']['category'] ?? null; $maxPrice = $filters['filter']['max_price'] ?? null;
$products = $this->searchProducts($category, $maxPrice);
return $this->json([ 'data' => $products, 'meta' => [ 'total' => count($products), 'limit' => $filters['limit'] ?? 25, ], ]); }}YAML configuration (alternative)
Section titled “YAML configuration (alternative)”product_search: path: /api/products controller: App\Controller\ProductController::search methods: [QUERY]Note: Symfony doesn’t filter HTTP methods against an internal allowlist — any string passes through. The method arrives in
$request->getMethod()exactly as sent.
PSR-7 (ServerRequest)
Section titled “PSR-7 (ServerRequest)”If you use a PSR-7 framework (Slim, Mezzio, etc.) or build requests manually for testing, the method is treated as an unvalidated string.
Creating a QUERY ServerRequest
Section titled “Creating a QUERY ServerRequest”<?php
use GuzzleHttp\Psr7\ServerRequest;use GuzzleHttp\Psr7\Utils;
$body = json_encode([ 'filter' => ['status' => 'pending'], 'limit' => 10,]);
$request = new ServerRequest('QUERY', 'https://api.example.com/orders');$request = $request ->withHeader('Content-Type', 'application/json') ->withBody(Utils::streamFor($body));
// In your handler:echo $request->getMethod(); // "QUERY"$filters = json_decode((string) $request->getBody(), true);Slim Framework — routing
Section titled “Slim Framework — routing”<?php
use Psr\Http\Message\ResponseInterface as Response;use Psr\Http\Message\ServerRequestInterface as Request;use Slim\Factory\AppFactory;
$app = AppFactory::create();
$app->map(['QUERY'], '/orders', function (Request $request, Response $response) { $filters = json_decode((string) $request->getBody(), true);
// Process the search... $results = searchOrders($filters);
$response->getBody()->write(json_encode($results)); return $response->withHeader('Content-Type', 'application/json');});
$app->run();Detecting QUERY on the Server
Section titled “Detecting QUERY on the Server”Not every PHP server setup will deliver the body of a custom method without configuration. Here’s how to make sure it works:
<?php
// Reads the body regardless of method — works on PHP-FPM, Apache mod_php, etc.$method = $_SERVER['REQUEST_METHOD']; // "QUERY"$body = file_get_contents('php://input');$data = json_decode($body, true);
if ($method === 'QUERY') { if (empty($_SERVER['CONTENT_TYPE'])) { http_response_code(415); echo json_encode(['error' => 'Content-Type header required']); exit; }
// Process the query... header('Content-Type: application/json'); echo json_encode(processQuery($data));}Apache: If you’re running
mod_security, you may need to add QUERY to the allowed methods list. Nginx doesn’t restrict methods by default.
Laravel HTTP Client (Guzzle wrapper)
Section titled “Laravel HTTP Client (Guzzle wrapper)”If you already use Laravel’s Http facade for external calls:
<?php
use Illuminate\Support\Facades\Http;
$response = Http::send('QUERY', 'https://api.example.com/contacts', [ 'json' => [ 'filter' => ['active' => true, 'city' => 'London'], 'limit' => 30, ],]);
if ($response->successful()) { $contacts = $response->json('data');}The Http::send() method accepts any verb — Guzzle underneath doesn’t validate.
Testing with PHPUnit
Section titled “Testing with PHPUnit”<?php
namespace Tests\Feature;
use Tests\TestCase;
class ContactQueryTest extends TestCase{ public function test_query_returns_filtered_contacts(): void { $response = $this->json('QUERY', '/api/contacts', [ 'filter' => ['city' => 'Berlin', 'active' => true], 'limit' => 10, ]);
$response->assertStatus(200) ->assertJsonStructure(['data', 'total']) ->assertJsonCount(10, 'data'); }
public function test_query_without_content_type_returns_415(): void { $response = $this->call('QUERY', '/api/contacts', [], [], [], [ 'CONTENT_TYPE' => '', ], '{"filter":{}}');
$response->assertStatus(415); }}Quick Reference
Section titled “Quick Reference”| Approach | How to use QUERY |
|---|---|
| Native PHP (curl) | CURLOPT_CUSTOMREQUEST => 'QUERY' + CURLOPT_POSTFIELDS |
| Guzzle | $client->request('QUERY', $uri, ['json' => ...]) |
| Laravel (route) | Route::match(['QUERY'], ...) |
| Laravel (client) | Http::send('QUERY', $url, ['json' => ...]) |
| Symfony | #[Route('/path', methods: ['QUERY'])] |
| PSR-7 / Slim | $app->map(['QUERY'], ...) or new ServerRequest('QUERY', ...) |
| Raw server | $_SERVER['REQUEST_METHOD'] + php://input |