PHP curl_multi_getcontent Function
last modified November 14, 2026
The PHP curl_multi_getcontent function returns the response body
of a cURL handle that was run as part of a multi handle. In this article we
show how to read the results of parallel requests, how to check whether a
transfer succeeded, and how to process responses as soon as they arrive.
The examples were run with PHP 8.5 and the cURL extension against the httpbin.org testing service.
Basic definition
The function has the following signature:
curl_multi_getcontent(CurlHandle $handle): ?string
When we run a single request with curl_exec, the function returns
the body directly. With the multi interface, curl_multi_exec only
drives the transfers; it does not return any data. The bodies stay stored in the
individual easy handles, and curl_multi_getcontent retrieves them.
The function returns the body only when the handle has the
CURLOPT_RETURNTRANSFER option set. Otherwise it returns
null. A transfer that failed returns an empty string.
Reading parallel responses
The first example downloads two resources in parallel and prints their bodies.
<?php
declare(strict_types=1);
$urls = [
'https://httpbin.org/robots.txt',
'https://httpbin.org/user-agent',
];
$mh = curl_multi_init();
$handles = [];
foreach ($urls as $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_USERAGENT, 'zetcode-demo/1.0');
curl_multi_add_handle($mh, $ch);
$handles[$url] = $ch;
}
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh);
} while ($running > 0);
foreach ($handles as $url => $ch) {
echo "== $url\n";
echo curl_multi_getcontent($ch), "\n";
curl_multi_remove_handle($mh, $ch);
}
curl_multi_close($mh);
We create one easy handle per URL and add each one to the multi handle. We keep the handles in an array keyed by URL, because we need them again after the transfers finish.
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh);
} while ($running > 0);
This is the standard loop that runs all transfers. The
curl_multi_exec function does as much work as it can without
blocking and stores the number of unfinished transfers in
$running. The curl_multi_select function waits
until there is activity on one of the connections, so the loop does not spin
at full CPU.
echo curl_multi_getcontent($ch), "\n"; curl_multi_remove_handle($mh, $ch);
Once the loop ends, every body is ready. We read each one with
curl_multi_getcontent and then detach the handle from the
multi handle with curl_multi_remove_handle.
$ php basic.php
== https://httpbin.org/robots.txt
User-agent: *
Disallow: /deny
== https://httpbin.org/user-agent
{
"user-agent": "zetcode-demo/1.0"
}
The CURLOPT_RETURNTRANSFER option
The CURLOPT_RETURNTRANSFER option decides whether cURL keeps
the response body or writes it straight to the output. This example leaves
the option out.
<?php
declare(strict_types=1);
$mh = curl_multi_init();
$ch = curl_init('https://httpbin.org/robots.txt');
curl_multi_add_handle($mh, $ch);
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh);
} while ($running > 0);
$content = curl_multi_getcontent($ch);
echo 'curl_multi_getcontent returned: ';
var_dump($content);
curl_multi_remove_handle($mh, $ch);
curl_multi_close($mh);
Without the option, cURL prints the body to the standard output as soon as
it arrives, and nothing is stored in the handle. That is why the body appears
in the output before our own message, and why
curl_multi_getcontent returns null. Always set
CURLOPT_RETURNTRANSFER to true on handles whose
content you want to read.
$ php no_returntransfer.php User-agent: * Disallow: /deny curl_multi_getcontent returned: NULL
Decoding JSON responses
APIs usually return JSON. The content returned by
curl_multi_getcontent is a plain string, so we decode it
ourselves.
<?php
declare(strict_types=1);
$mh = curl_multi_init();
$handles = [];
foreach (['php', 'python', 'rust'] as $lang) {
$ch = curl_init('https://httpbin.org/get?lang=' . $lang);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $ch);
$handles[$lang] = $ch;
}
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh);
} while ($running > 0);
foreach ($handles as $lang => $ch) {
$data = json_decode(curl_multi_getcontent($ch), true, flags: JSON_THROW_ON_ERROR);
printf("%-6s -> %s\n", $lang, $data['url']);
curl_multi_remove_handle($mh, $ch);
}
curl_multi_close($mh);
We send three requests with different query strings. The httpbin.org
/get endpoint echoes the request back as JSON, including the
full URL.
$data = json_decode(curl_multi_getcontent($ch), true, flags: JSON_THROW_ON_ERROR);
The JSON_THROW_ON_ERROR flag makes json_decode
throw a JsonException on invalid input instead of silently
returning null. The results are printed in the order of the
$handles array, not in the order the responses arrived.
$ php json_responses.php php -> https://httpbin.org/get?lang=php python -> https://httpbin.org/get?lang=python rust -> https://httpbin.org/get?lang=rust
Checking the result of each transfer
The content alone does not tell us whether a request succeeded: an HTTP error and a network failure can both produce an empty body. This example checks each transfer properly.
<?php
declare(strict_types=1);
$urls = [
'https://httpbin.org/status/200',
'https://httpbin.org/status/404',
'https://httpbin.org/html',
'https://nonexistent.invalid/',
];
$mh = curl_multi_init();
foreach ($urls as $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_multi_add_handle($mh, $ch);
}
$results = [];
do {
curl_multi_exec($mh, $running);
while ($info = curl_multi_info_read($mh)) {
$ch = $info['handle'];
$url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
if ($info['result'] !== CURLE_OK) {
$results[$url] = 'failed: ' . curl_strerror($info['result']);
} else {
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = curl_multi_getcontent($ch);
$results[$url] = "HTTP $code, body length " . strlen($body);
}
curl_multi_remove_handle($mh, $ch);
}
if ($running > 0) {
curl_multi_select($mh);
}
} while ($running > 0);
curl_multi_close($mh);
foreach ($urls as $url) {
echo "$url: {$results[$url]}\n";
}
The curl_multi_info_read function returns a message for each
finished transfer. Its result element holds the cURL result
code of that transfer.
if ($info['result'] !== CURLE_OK) {
$results[$url] = 'failed: ' . curl_strerror($info['result']);
}
In the multi interface this result code is the reliable way to detect a
network failure. The curl_error and curl_errno
functions are meant for curl_exec; for a handle run by a multi
handle they report no error even when the transfer failed. The
curl_strerror function turns the code into a readable message.
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $body = curl_multi_getcontent($ch);
A transfer that completed can still carry an HTTP error status, so we read
the status code with curl_getinfo. The output shows that both
the 200 and the 404 status endpoints return an empty body; only the status
code tells them apart.
$ php status_check.php https://httpbin.org/status/200: HTTP 200, body length 0 https://httpbin.org/status/404: HTTP 404, body length 0 https://httpbin.org/html: HTTP 200, body length 3741 https://nonexistent.invalid/: failed: Could not resolve hostname
Processing responses as they complete
Waiting for all transfers before reading any of them is simple, but the slowest request then delays everything. This example reads each response as soon as its transfer finishes.
<?php
declare(strict_types=1);
$mh = curl_multi_init();
foreach ([3, 1, 2] as $delay) {
$ch = curl_init("https://httpbin.org/delay/$delay");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_PRIVATE, "delay $delay s");
curl_multi_add_handle($mh, $ch);
}
do {
curl_multi_exec($mh, $running);
while ($info = curl_multi_info_read($mh)) {
$ch = $info['handle'];
$label = curl_getinfo($ch, CURLINFO_PRIVATE);
$data = json_decode(curl_multi_getcontent($ch), true);
echo "finished: $label, url {$data['url']}\n";
curl_multi_remove_handle($mh, $ch);
}
if ($running > 0) {
curl_multi_select($mh);
}
} while ($running > 0);
curl_multi_close($mh);
The httpbin.org /delay/n endpoint waits n seconds
before it responds. We start the requests in the order 3, 1, 2.
curl_setopt($ch, CURLOPT_PRIVATE, "delay $delay s");
The CURLOPT_PRIVATE option attaches our own label to the handle.
We read it back with curl_getinfo($ch, CURLINFO_PRIVATE) when the
transfer finishes, so we don't need a separate array that maps handles to
requests.
while ($info = curl_multi_info_read($mh)) {
$ch = $info['handle'];
...
curl_multi_remove_handle($mh, $ch);
}
After every call to curl_multi_exec we drain the queue of
finished transfers. Each response is decoded and printed right away, and
its handle is removed from the multi handle. The responses arrive in the
order of their delays, not in the order the requests were started.
$ php as_completed.php finished: delay 1 s, url https://httpbin.org/delay/1 finished: delay 2 s, url https://httpbin.org/delay/2 finished: delay 3 s, url https://httpbin.org/delay/3
Source
PHP curl_multi_getcontent documentation
PHP curl_multi_info_read documentation
In this article we have used the PHP curl_multi_getcontent
function to read the responses of parallel cURL requests. See also the
curl_multi_exec,
curl_multi_remove_handle and
curl_getinfo tutorials.
Author
List all PHP cURL tutorials.