
TLDR: To call the You.com Web Search API from PHP, POST a JSON body to https://ydc-index.io/v1/search with your key in the X-API-Key header using the cURL extension, then read results.web from json_decode with JSON_THROW_ON_ERROR. Set both cURL timeouts, because the transfer timeout defaults to none and the connect timeout to 300 seconds. Check the HTTP status yourself, and retry only 429, 5xx, and transport failures.
The You.com docs ship an official Python SDK and mention no PHP package (checked October 2026), so a PHP application calls the documented REST contract directly. The contract is small, and PHP's cURL extension covers it. What deserves care is the PHP-specific behavior around it: timeouts, JSON error handling, environment variables under PHP-FPM, and what a blocked worker costs.
This guide is the PHP companion to the cURL guide, which shows the raw HTTP contract from a terminal, and the Python guide, which covers the official SDK. For the endpoint, limits, and pricing in one place, read the You.com Web Search API reference article; for the response fields in depth, read the JSON response guide.
What do you need before you call the Web Search API from PHP?
You need an API key from the You.com platform, PHP 8.2 or later, and the cURL extension. As of October 2026, php.net lists 8.2, 8.3, 8.4, and 8.5 as the supported branches, and the samples use readonly properties (PHP 8.1) and non-capturing catch blocks (PHP 8.0). The cURL extension is not guaranteed to be present: PHP's installation notes say you compile it in with --with-curl, so check extension_loaded('curl') on the host that serves the code before you assume it is there.
The docs authenticate with an X-API-Key header, not Authorization: Bearer, and name the variable YDC_API_KEY. Read it with getenv, which returns false, not an empty string, when the variable does not exist. Because PHP converts false to an empty string, concatenating the result into an X-API-Key header line sends an empty key, and the docs list a missing or invalid key as a 401. The samples fail fast instead.
Under PHP-FPM, the variable can be missing from your code even when your shell has it. The pool setting clear_env defaults to yes, which clears the worker environment before the pool's own env[...] lines are applied. Either set clear_env = no or pass the variable with an env[YDC_API_KEY] line in the pool configuration. Read it with getenv rather than $_ENV, because PHP creates $_ENV only when variables_order includes E, per the core ini reference.
The request is POST https://ydc-index.io/v1/search with a JSON body, and only query is required. GET still works, but it no longer receives new features, and the extraction and knowledge parameters exist on POST only, per the Web Search API overview.
How do you make the first request?
Put the client in its own file so the decoding logic can be tested without a network. The file below separates transport (ydc_search) from interpretation (ydc_decode), which turns a status, a body, and a Retry-After value into decoded JSON or an exception:
<?php
declare(strict_types=1);
final class YdcApiException extends RuntimeException
{
public function __construct(
public readonly int $status,
public readonly string $body,
public readonly ?int $retryAfterSeconds = null,
) {
parent::__construct("You.com API returned HTTP {$status}");
}
}
final class YdcTransportException extends RuntimeException
{
}
/**
* Turns a status, body, and Retry-After value into decoded JSON or an exception.
* It never touches the network, so tests can call it with fixtures.
*
* @return array<string, mixed>
*/
function ydc_decode(int $status, string $body, ?int $retryAfterSeconds = null): array
{
if ($status < 200 || $status > 299) {
throw new YdcApiException($status, substr($body, 0, 500), $retryAfterSeconds);
}
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : [];
}
/**
* results.web is optional, so a missing key is an empty list, not an error.
*
* @param array<string, mixed> $decoded
* @return list<array<string, mixed>>
*/
function ydc_web_results(array $decoded): array
{
return $decoded['results']['web'] ?? [];
}
/** Retry-After is either a number of seconds or an HTTP date. */
function ydc_parse_retry_after(string $value): ?int
{
$value = trim($value);
if ($value !== '' && ctype_digit($value)) {
return (int) $value;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : max(0, $timestamp - time());
}
/**
* @param array<string, mixed> $request
* @return array<string, mixed>
*/
function ydc_search(array $request, string $apiKey): array
{
$retryAfter = null;
$ch = curl_init('https://ydc-index.io/v1/search');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($request, JSON_THROW_ON_ERROR),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . $apiKey,
],
CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$retryAfter): int {
if (stripos($line, 'Retry-After:') === 0) {
$retryAfter = ydc_parse_retry_after(substr($line, strlen('Retry-After:')));
}
return strlen($line);
},
]);
$body = curl_exec($ch);
if ($body === false) {
throw new YdcTransportException('cURL error ' . curl_errno($ch) . ': ' . curl_error($ch));
}
return ydc_decode(curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body, $retryAfter);
}
Several settings in ydc_search exist because cURL's defaults do not suit an API call. CURLOPT_POST makes cURL send a form content type by default, so the JSON content type goes in the header list explicitly. CURLOPT_RETURNTRANSFER makes curl_exec return the body as a string; without it, curl_exec prints the body and returns true, per the curl_exec manual. CURLOPT_TIMEOUT defaults to 0, which the constants reference describes as functions that never time out during transfer, and CURLOPT_CONNECTTIMEOUT defaults to 300 seconds, so the sample sets 15 and 5. Those two values are examples, not a measured latency or a service guarantee. cURL also ignores the HTTP status unless CURLOPT_FAILONERROR is set, so curl_exec returns a body for a 401 or a 429 and the code reads CURLINFO_RESPONSE_CODE itself.
Three PHP details are easy to miss. First, json_encode and json_decode signal failure with false and null unless you pass JSON_THROW_ON_ERROR, which throws JsonException (PHP 7.3 and later) per the json_decode manual. A null is also the valid decoding of the JSON text null, so a silent null cannot tell a broken body from an empty one. Second, the sample leaves out curl_close, which has had no effect since PHP 8.0 and is deprecated as of PHP 8.5, so new code should not call it. Third, max_execution_time is no safety net for a stalled call: the set_time_limit manual says it does not count time spent outside the script's own execution, such as system calls, stream operations, and database queries, on non-Windows platforms. Set the cURL timeouts.
The CURLOPT_HEADERFUNCTION callback collects Retry-After while the response streams in, and the manual says it should return the number of bytes written, which the sample does by returning the length of the header line. The value can be a number of seconds or an HTTP date, per section 10.2.3 of RFC 9110, so ydc_parse_retry_after handles both and returns null when it cannot parse the value. The command-line entry point reads the key, treats both false and a blank string as missing, makes one attempt, and prints the top hits:
<?php
declare(strict_types=1);
require __DIR__ . '/ydc_search.php';
$apiKey = getenv('YDC_API_KEY');
if ($apiKey === false || trim($apiKey) === '') {
fwrite(STDERR, "Set YDC_API_KEY before running.\n");
exit(1);
}
try {
$response = ydc_search(
['query' => 'php curl timeouts', 'count' => 5, 'freshness' => 'year'],
$apiKey,
);
} catch (YdcApiException $e) {
fwrite(STDERR, "HTTP {$e->status}: {$e->body}\n");
exit(2);
} catch (YdcTransportException | JsonException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(3);
}
$hits = ydc_web_results($response);
if ($hits === []) {
echo "No web results.\n";
}
foreach ($hits as $i => $hit) {
printf("%d. %s\n %s\n", $i + 1, $hit['title'] ?? '(untitled)', $hit['url'] ?? '');
}
What does the response contain?
The response is a JSON object whose results.web and results.news keys are both optional, and the docs tell you to check before iterating. ydc_web_results does that with ?? [], which also covers a missing results key. Each web hit can carry url, title, description, snippets, page_age, thumbnail_url, favicon_url, and, when the request sets extraction, contents. None is marked required in the OpenAPI schema, so read each with a default, as the entry point does for title and URL. page_age is a string, and the docs' examples show no UTC offset.
Filters are keys in the request array. freshness takes day, week, month, year, or a YYYY-MM-DDtoYYYY-MM-DD range, and include_domains, exclude_domains, and boost_domains take arrays. Combining include_domains with exclude_domains or boost_domains returns a 422. The request controls page lists every option, and the date filter guide covers how a time phrase inside the query interacts with freshness.
Which statuses are worth retrying, and what does a retry cost a PHP worker?
The docs give each status a specific meaning. A 401 is a missing, expired, or revoked key, or a wrong header name. A 402 means a keyed account is out of credits. A 403 means the key lacks scope for the path, or the path is on the wrong host and the body says Missing Authentication Token. A 422 is an invalid parameter combination. A 429 is the per-second limit, 10 requests per second by default on /v1/search as of October 2026, and a 500 is a server error that can also come from authentication middleware. Only 429, 5xx, and transport failures can succeed on a second try; the rest are configuration or request bugs that a retry only repeats. The error reference and rate limits page list the bodies.
Add the retry wrapper to the same file:
/**
* Retries 429, 5xx, and transport failures. Everything else fails on the first attempt.
*
* @param array<string, mixed> $request
* @return array<string, mixed>
*/
function ydc_search_with_retry(array $request, string $apiKey, int $maxAttempts = 3): array
{
for ($attempt = 1; ; $attempt++) {
try {
return ydc_search($request, $apiKey);
} catch (YdcApiException $e) {
$retryable = $e->status === 429 || $e->status >= 500;
if (!$retryable || $attempt >= $maxAttempts) {
throw $e;
}
$delay = $e->retryAfterSeconds ?? 2 ** ($attempt - 1);
} catch (YdcTransportException $e) {
if ($attempt >= $maxAttempts) {
throw $e;
}
$delay = 2 ** ($attempt - 1);
}
sleep(min($delay, 10));
}
}
In the command-line script, call ydc_search_with_retry in place of ydc_search to get the retries. The wrapper sleeps for Retry-After when the API sends it and otherwise backs off for 1 second, then 2, capping any sleep at 10 seconds. It does not retry JsonException, because a 200 with an unparseable body is not evidence that waiting will help.
The sleep is the PHP-specific cost. A web request that sleeps holds a PHP-FPM worker, and pm.max_children sets the limit on the number of simultaneous requests FPM serves. With the defaults above, one search that fails three times can block its worker for up to 48 seconds: three 15-second timeouts plus 1 and 2 seconds of sleep. In a web request, use a single attempt and render the page without search results on failure, or move the search to a queue worker where blocking is cheap, and keep the retry wrapper for command-line scripts and queue jobs.
How do you test the client without calling the API?
Because ydc_decode and ydc_web_results never touch the network, a test can call them with saved fixtures. The script below needs no framework; run it with php, and its exit code fails a CI job. Keep live calls out of the default run, since each one is billed at $5.00 per 1,000 calls as of October 2026, per the billing page.
<?php
declare(strict_types=1);
require __DIR__ . '/ydc_search.php';
$failures = 0;
function check(string $name, bool $passed): void
{
global $failures;
echo ($passed ? 'PASS' : 'FAIL'), " {$name}\n";
if (!$passed) {
$failures++;
}
}
// Response shapes: results and results.web are both optional.
check('empty object gives no web results', ydc_web_results(ydc_decode(200, '{}')) === []);
check('news-only response gives no web results', ydc_web_results(ydc_decode(200, '{"results":{"news":[]}}')) === []);
$fixture = '{"results":{"web":[{"url":"https://example.com","title":"T","page_age":"2026-01-01T00:00:00"}]}}';
$web = ydc_web_results(ydc_decode(200, $fixture));
check('web results decode', ($web[0]['title'] ?? null) === 'T' && ($web[0]['page_age'] ?? null) === '2026-01-01T00:00:00');
// A 200 with an unparseable body must not look like "no results".
try {
ydc_decode(200, '<html>maintenance</html>');
check('invalid JSON throws', false);
} catch (JsonException) {
check('invalid JSON throws', true);
}
// Errors carry the status, the body, and the Retry-After value.
try {
ydc_decode(429, '{"detail":"slow down"}', ydc_parse_retry_after('3'));
check('429 throws', false);
} catch (YdcApiException $e) {
check('429 carries status and Retry-After', $e->status === 429 && $e->retryAfterSeconds === 3);
}
try {
ydc_decode(401, '{"detail":"Invalid or expired API key"}');
check('401 throws', false);
} catch (YdcApiException $e) {
check('401 carries the body', $e->status === 401 && str_contains($e->body, 'Invalid or expired'));
}
exit($failures === 0 ? 0 : 1);
The first three fixtures pin the shapes that break code written only against the happy path: an empty object, a results object without web, and a normal hit. The invalid JSON case proves that a broken 200 raises JsonException instead of passing as "no results", and the 429 and 401 cases pin the status handling. These checks cover how your code treats documented shapes. They say nothing about relevance, latency, or current pricing, so keep a small set of live queries that you judge by hand.
Should you use Guzzle instead?
Use Guzzle if the application already depends on it. The Guzzle docs call Composer the recommended way to install it, and Guzzle falls back to PHP's stream wrapper when cURL is missing, although cURL is still required for concurrent requests. Its request options map onto this call: json encodes the body and adds an application/json content type if none is set, headers carries X-API-Key, and timeout and connect_timeout set the two timeouts. Both default to 0, which waits indefinitely, so set them. By default Guzzle throws ClientException for 4xx and ServerException for 5xx responses, and ConnectException for networking errors, which replaces the manual status check:
<?php
declare(strict_types=1);
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
$client = new Client([
'base_uri' => 'https://ydc-index.io',
'headers' => ['X-API-Key' => $apiKey],
'timeout' => 15,
'connect_timeout' => 5,
]);
try {
$response = $client->request('POST', '/v1/search', [
'json' => ['query' => 'php curl timeouts', 'count' => 5],
]);
$decoded = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (ClientException | ServerException $e) {
fwrite(STDERR, 'HTTP ' . $e->getResponse()->getStatusCode() . "\n");
exit(2);
} catch (ConnectException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(3);
}
What breaks in production?
The key is missing under PHP-FPM. The command line works and the web requests do not when clear_env has removed the variable from the workers. Detection: 401s from the web SAPI while the same code works from the command line, and var_dump(getenv('YDC_API_KEY')) inside a request printing bool(false).
Workers pile up behind a slow upstream. With no timeouts, one stalled call holds a worker indefinitely, and with the sample's timeouts it holds one for at most 15 seconds per attempt. Detection: every FPM worker busy while CPU stays idle.
Silent nulls and ignored statuses. Code that calls json_decode without JSON_THROW_ON_ERROR turns an HTML error page into null and then into "no results". Code that never reads the status turns a 401 JSON body into an array with no results.web, which also looks like "no results". Detection: search results that are empty for every query at once.
Deprecation notices on PHP 8.5. Older snippets call curl_close, which is deprecated as of 8.5. Remove the call instead of silencing the notice.
Uncached repeat searches. The overview suggests caching frequent queries for 5 to 15 minutes. Key the cache on the whole request array, for example a hash of its JSON, and not on the query string alone, or a result fetched with one freshness value is served for another. As a hypothetical, a page that runs the same search on every view at 100,000 views a day makes 100,000 calls, which is $500 a day at $5.00 per 1,000. A 10-minute cache cuts that to 144 calls and $0.72 a day for that one request.
Related Guides
LI Test
LI Test
Share Article:





