PHP curl_multi
debt(d7/e5/b3/t6)
Closest to 'only careful code review or runtime testing' (d7). The serial curl_exec loop pattern is regex-detectable by semgrep per detection_hints, but the subtle misuses (missing curl_multi_select, missing curl_multi_info_read, no concurrency cap) mostly surface via CPU profiling or code review rather than phpstan alerts.
Closest to 'touches multiple files / significant refactor in one component' (e5). The quick_fix says replace serial loops with a proper multi_exec/select loop or Guzzle Pool — that's more than a one-line swap; it restructures the HTTP-calling code and often affects error handling, but stays scoped to the HTTP client layer.
Closest to 'localised tax' (b3). curl_multi usage is confined to the HTTP-calling component; it doesn't shape the whole system, though long-running workers must be careful about handle cleanup which imposes some ongoing tax.
Closest to 'serious trap' (t6, between t5 and t7). Per misconception, devs don't even realize PHP supports concurrent HTTP in-process, and per common_mistakes the API has multiple non-obvious gotchas (busy-loop without select, silent per-handle failures via missing info_read, global vs per-handle timeout) that contradict how a simple curl_exec mental model works.
Also Known As
TL;DR
Explanation
The curl_multi_* family of functions lets a single PHP process execute many HTTP requests concurrently instead of serially. You create individual easy handles with curl_init(), attach them to a multi handle via curl_multi_add_handle(), then drive the batch with curl_multi_exec() and curl_multi_select(). Instead of waiting for each request to complete before starting the next, the sockets progress in parallel using the underlying libcurl event loop. For 20 API calls at 200 ms each, serial cURL takes ~4 s; curl_multi typically finishes in a bit over 200 ms.
This matters most for aggregation endpoints, fan-out queries to microservices, bulk webhook delivery, sitemap crawling, and any batch that hits external services. Without concurrency, PHP-FPM workers sit blocked on network I/O and the whole request budget is spent waiting. curl_multi keeps the CPU utilised and shrinks tail latency.
The API is lower-level than most PHP developers expect. You must loop while curl_multi_exec() returns CURLM_CALL_MULTI_PERFORM or reports active handles, calling curl_multi_select() to block until sockets are ready (avoid a busy-loop that burns 100% CPU). After the batch you collect responses with curl_multi_getcontent() and read per-handle info via curl_multi_info_read() to detect failures, then curl_multi_remove_handle() and curl_close() each easy handle to free resources.
Modern code usually reaches for Guzzle's Pool or Promise\Utils::settle, or Symfony HttpClient with a concurrent scheduler, which wrap curl_multi cleanly and add retries, redirects, and timeout handling. Even so, understanding the primitive helps debug high-concurrency clients, set sensible CURLMOPT_MAX_HOST_CONNECTIONS limits, and reason about connection reuse. Watch out for unbounded parallelism hammering downstream services, per-handle timeouts (CURLOPT_TIMEOUT), and DNS resolver limits on high fan-out.
Common Misconception
Why It Matters
Common Mistakes
- Busy-looping on curl_multi_exec() without curl_multi_select(), pegging a CPU core at 100%.
- Forgetting to call curl_multi_remove_handle() and curl_close() on each handle, leaking sockets and memory across long-running workers.
- Firing thousands of parallel requests with no concurrency cap, overwhelming downstream services or exhausting local file descriptors.
- Skipping curl_multi_info_read() and assuming success — individual handle failures silently return empty bodies.
- Setting a global timeout only, so one slow host stalls the whole batch instead of failing that handle.
Code Examples
<?php
// Serial: 20 requests * 200ms = ~4 seconds
$urls = [/* 20 URLs */];
$results = [];
foreach ($urls as $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$results[] = curl_exec($ch); // blocks on each request
curl_close($ch);
}
<?php
// Parallel: 20 requests complete in ~max(latency)
$urls = [/* 20 URLs */];
$mh = curl_multi_init();
$handles = [];
foreach ($urls as $i => $url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_multi_add_handle($mh, $ch);
$handles[$i] = $ch;
}
do {
$status = curl_multi_exec($mh, $active);
if ($active) {
curl_multi_select($mh, 1.0); // block until sockets ready
}
} while ($active && $status === CURLM_OK);
$results = [];
foreach ($handles as $i => $ch) {
$results[$i] = curl_multi_getcontent($ch);
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);