Skip to content

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.


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);

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 => true alongside CURLOPT_CUSTOMREQUEST. The CURLOPT_POST flag internally forces the method back to POST and may cause conflicts. Set only CURLOPT_CUSTOMREQUEST and CURLOPT_POSTFIELDS.


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);
<?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 sets Content-Type: application/json. No need to set it manually.


Laravel doesn’t have a dedicated helper for QUERY (like Route::get or Route::post), but Route::match() handles it in one line.

routes/api.php
use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;
Route::match(['QUERY'], '/contacts', [ContactController::class, 'search']);
<?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(),
]);
}
}

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 $routeMiddleware in the Kernel.


Symfony supports custom methods in the #[Route] attribute via the methods parameter.

<?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,
],
]);
}
}
config/routes.yaml
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.


If you use a PSR-7 framework (Slim, Mezzio, etc.) or build requests manually for testing, the method is treated as an unvalidated string.

<?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);
<?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();

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.


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.


<?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);
}
}

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