From e19a8df88c7814ce9540e1907ec2f714a6bc4e78 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:00:09 +0200 Subject: [PATCH 01/10] Report library name and version in source parameter The client sent `source=php`, which made it impossible to tell PHP client versions apart in SerpApi usage statistics. Report `serpapi-php:` instead, matching the `serpapi-ruby:` convention. Query assembly moves into buildQuery(), and request() becomes a protected seam so tests can observe outgoing requests through a recording double instead of reflecting into private methods or hitting the network. Co-authored-by: Victor Benarbia --- src/Client.php | 49 ++++++++++++++++++++--------- tests/ClientQueryTest.php | 64 ++++++++++++++++++++++++++++++++++++++ tests/RecordingClient.php | 65 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 tests/ClientQueryTest.php create mode 100644 tests/RecordingClient.php diff --git a/src/Client.php b/src/Client.php index 156b83c..0e33475 100644 --- a/src/Client.php +++ b/src/Client.php @@ -8,6 +8,9 @@ class Client public const BASE_URL = 'https://serpapi.com'; public const DEFAULT_TIMEOUT = 120; + /** Client identifier reported to SerpApi in the `source` parameter. */ + public const SOURCE = 'serpapi-php:' . self::VERSION; + /** @var string */ private $apiKey; @@ -159,21 +162,11 @@ private function get(string $endpoint, string $format = 'json', array $params = throw new SerpApiException('api_key must be present'); } - $defaultQuery = [ - 'engine' => $this->engine, - 'source' => 'php', - ]; - - if (!empty($apiKey)) { - $defaultQuery['api_key'] = $apiKey; - } - - $query = array_merge($defaultQuery, $params); - $query['output'] = $format; + $query = $this->buildQuery($params, (string) $apiKey, $format); $url = self::BASE_URL . $endpoint . '?' . http_build_query($query); - $requestResult = $this->request($url); + $requestResult = $this->request($url, $this->timeout); $response = $requestResult['response']; $httpCode = $requestResult['http_code']; $curlError = $requestResult['curl_error']; @@ -212,10 +205,38 @@ private function get(string $endpoint, string $format = 'json', array $params = } /** + * Assemble the query string parameters for a request. + * + * @param array $params + * @return array + */ + private function buildQuery(array $params, string $apiKey, string $format): array + { + $query = [ + 'engine' => $this->engine, + 'source' => self::SOURCE, + ]; + + if (!empty($apiKey)) { + $query['api_key'] = $apiKey; + } + + $query = array_merge($query, $params); + $query['output'] = $format; + + return $query; + } + + /** + * Perform the HTTP GET request. + * + * @internal Seam for tests to observe requests without network access; + * not covered by the backwards compatibility promise. + * * @return array{response: string|false, http_code: int, curl_error: string} * @throws SerpApiException */ - private function request(string $url): array + protected function request(string $url, int $timeout): array { $ch = curl_init(); if ($ch === false) { @@ -228,7 +249,7 @@ private function request(string $url): array CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => 'serpapi-php/' . self::VERSION, CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_TIMEOUT => $timeout, ]); if ($isConfigured === false) { diff --git a/tests/ClientQueryTest.php b/tests/ClientQueryTest.php new file mode 100644 index 0000000..a54b49d --- /dev/null +++ b/tests/ClientQueryTest.php @@ -0,0 +1,64 @@ +search(['q' => 'coffee']); + + $this->assertEquals('serpapi-php:' . Client::VERSION, $client->lastQuery()['source']); + } + + public function testQueryIncludesEngineApiKeyAndOutput() + { + $client = new RecordingClient('secret', 'bing'); + $client->search(['q' => 'coffee']); + $query = $client->lastQuery(); + + $this->assertEquals('/search', $client->lastPath()); + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('secret', $query['api_key']); + $this->assertEquals('coffee', $query['q']); + $this->assertEquals('json', $query['output']); + } + + public function testPerCallParamsOverrideEngineAndApiKey() + { + $client = new RecordingClient('secret'); + $client->search(['engine' => 'bing', 'api_key' => 'other']); + $query = $client->lastQuery(); + + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('other', $query['api_key']); + } + + public function testLocationOmitsApiKeyWhenNotSet() + { + $client = (new RecordingClient())->willReturn('[]'); + $client->location(['q' => 'Austin']); + + $this->assertArrayNotHasKey('api_key', $client->lastQuery()); + } + + public function testOutputReflectsRequestedFormat() + { + $client = (new RecordingClient('secret'))->willReturn(''); + $client->html(['q' => 'coffee']); + + $this->assertEquals('html', $client->lastQuery()['output']); + } +} diff --git a/tests/RecordingClient.php b/tests/RecordingClient.php new file mode 100644 index 0000000..5c3bacb --- /dev/null +++ b/tests/RecordingClient.php @@ -0,0 +1,65 @@ + */ + public $urls = []; + + /** @var array */ + public $timeouts = []; + + /** @var string */ + private $body = '{"search_metadata":{"id":"abc123","status":"Success"}}'; + + /** @var int */ + private $httpCode = 200; + + public function willReturn(string $body, int $httpCode = 200): self + { + $this->body = $body; + $this->httpCode = $httpCode; + + return $this; + } + + protected function request(string $url, int $timeout): array + { + $this->urls[] = $url; + $this->timeouts[] = $timeout; + + return [ + 'response' => $this->body, + 'http_code' => $this->httpCode, + 'curl_error' => '', + ]; + } + + /** + * Query parameters of the most recent request. + * + * @return array + */ + public function lastQuery(): array + { + $url = (string) end($this->urls); + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query; + } + + public function lastPath(): string + { + $url = (string) end($this->urls); + + return (string) parse_url($url, PHP_URL_PATH); + } +} From c54727da0c7a20036ea09f7751ba8ea71976773e Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:01:26 +0200 Subject: [PATCH 02/10] Support default search parameters in the constructor The constructor only accepted api_key, engine and timeout, so parameters like location, hl, gl or no_cache had to be repeated on every search call. The Ruby client takes a hash where any unrecognized key becomes a default search parameter. Add a fourth positional argument for default parameters, and accept a configuration array as the first argument for parity with the Ruby client. Configuration keys are honored in either array, rather than silently dropped. Per-call parameters take precedence, a null value drops a default for one call, and a per-call `timeout` applies to that request instead of being sent to the API. The effective values are exposed via getParams() and getTimeout(). The existing positional signature is unchanged. Co-authored-by: Victor Benarbia --- src/Client.php | 85 ++++++++++++++++-- tests/ClientDefaultParamsTest.php | 142 ++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 tests/ClientDefaultParamsTest.php diff --git a/src/Client.php b/src/Client.php index 0e33475..b5f7a38 100644 --- a/src/Client.php +++ b/src/Client.php @@ -20,21 +20,65 @@ class Client /** @var int */ private $timeout; + /** @var array Search parameters merged into every request */ + private $params = []; + /** - * @param string $apiKey + * Accepts positional arguments or, like the Ruby client, a single array + * mixing configuration with default search parameters: + * + * new Client(['api_key' => '...', 'engine' => 'google', 'hl' => 'en']) + * + * `api_key`, `engine` and `timeout` configure the client and take + * precedence over the positional arguments. Every other key becomes a + * default search parameter, sent with every request unless overridden + * per call. + * + * @param string|array $apiKey API key, or a configuration array * @param string $engine * @param int $timeout Request timeout in seconds + * @param array $params Default search parameters and configuration * @throws SerpApiException */ - public function __construct(string $apiKey = '', string $engine = 'google', int $timeout = self::DEFAULT_TIMEOUT) - { + public function __construct( + $apiKey = '', + string $engine = 'google', + int $timeout = self::DEFAULT_TIMEOUT, + array $params = [] + ) { + if (is_array($apiKey)) { + $params = array_merge($apiKey, $params); + $apiKey = ''; + } + + $apiKey = self::pull($params, 'api_key', $apiKey); + $engine = self::pull($params, 'engine', $engine); + $timeout = self::pull($params, 'timeout', $timeout); + if (empty($engine)) { throw new SerpApiException('engine must be present'); } - $this->apiKey = $apiKey; - $this->engine = $engine; - $this->timeout = $timeout; + $this->apiKey = (string) $apiKey; + $this->engine = (string) $engine; + $this->timeout = (int) $timeout; + $this->params = $params; + } + + /** + * Remove a key from an options array and return its value, or the + * default when the key is absent or null. + * + * @param array $options + * @param mixed $default + * @return mixed + */ + private static function pull(array &$options, string $key, $default) + { + $value = $options[$key] ?? $default; + unset($options[$key]); + + return $value; } /** @@ -68,6 +112,24 @@ public function getEngine(): string return $this->engine; } + /** + * Get the request timeout in seconds. + */ + public function getTimeout(): int + { + return $this->timeout; + } + + /** + * Get the default search parameters sent with every request. + * + * @return array + */ + public function getParams(): array + { + return $this->params; + } + /** * Run a search and return decoded JSON. * @@ -162,11 +224,13 @@ private function get(string $endpoint, string $format = 'json', array $params = throw new SerpApiException('api_key must be present'); } + $timeout = (int) self::pull($params, 'timeout', $this->timeout); + $query = $this->buildQuery($params, (string) $apiKey, $format); $url = self::BASE_URL . $endpoint . '?' . http_build_query($query); - $requestResult = $this->request($url, $this->timeout); + $requestResult = $this->request($url, $timeout); $response = $requestResult['response']; $httpCode = $requestResult['http_code']; $curlError = $requestResult['curl_error']; @@ -221,7 +285,12 @@ private function buildQuery(array $params, string $apiKey, string $format): arra $query['api_key'] = $apiKey; } - $query = array_merge($query, $params); + // A null value drops a default search parameter for a single call. + $overrides = array_filter(array_merge($this->params, $params), static function ($value) { + return $value !== null; + }); + + $query = array_merge($query, $overrides); $query['output'] = $format; return $query; diff --git a/tests/ClientDefaultParamsTest.php b/tests/ClientDefaultParamsTest.php new file mode 100644 index 0000000..129c770 --- /dev/null +++ b/tests/ClientDefaultParamsTest.php @@ -0,0 +1,142 @@ + 'en', 'gl' => 'us']); + $client->search(['q' => 'coffee']); + $query = $client->lastQuery(); + + $this->assertEquals('en', $query['hl']); + $this->assertEquals('us', $query['gl']); + $this->assertEquals('coffee', $query['q']); + } + + public function testPerCallParamsOverrideDefaults() + { + $client = new RecordingClient('secret', 'google', 120, ['hl' => 'en']); + $client->search(['hl' => 'fr']); + + $this->assertEquals('fr', $client->lastQuery()['hl']); + } + + public function testNullDropsADefaultParamForOneCall() + { + $client = new RecordingClient('secret', 'google', 120, ['hl' => 'en']); + + $client->search(['hl' => null]); + $this->assertArrayNotHasKey('hl', $client->lastQuery()); + + $client->search(); + $this->assertEquals('en', $client->lastQuery()['hl']); + } + + public function testNullCannotDropTheEngineOrApiKey() + { + $client = new RecordingClient('secret', 'bing'); + $client->search(['engine' => null, 'api_key' => null]); + $query = $client->lastQuery(); + + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('secret', $query['api_key']); + } + + public function testArrayConstructorSetsConfiguration() + { + $client = new Client([ + 'api_key' => 'secret', + 'engine' => 'bing', + 'timeout' => 30, + ]); + + $this->assertEquals('secret', $client->getApiKey()); + $this->assertEquals('bing', $client->getEngine()); + $this->assertEquals(30, $client->getTimeout()); + $this->assertEquals([], $client->getParams()); + } + + public function testArrayConstructorTreatsOtherKeysAsDefaultParams() + { + $client = new RecordingClient([ + 'api_key' => 'secret', + 'location' => 'Austin, TX', + 'no_cache' => true, + ]); + $client->search(['q' => 'coffee']); + $query = $client->lastQuery(); + + $this->assertEquals(['location' => 'Austin, TX', 'no_cache' => true], $client->getParams()); + $this->assertEquals('Austin, TX', $query['location']); + $this->assertEquals('1', $query['no_cache']); + } + + public function testArrayConstructorDefaultsEngineToGoogle() + { + $client = new Client(['api_key' => 'secret']); + $this->assertEquals('google', $client->getEngine()); + } + + public function testArrayConstructorRejectsEmptyEngine() + { + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('engine must be present'); + new Client(['api_key' => 'secret', 'engine' => '']); + } + + public function testConfigurationInParamsArgumentIsHonored() + { + $client = new Client('secret', 'google', 120, ['timeout' => 15, 'hl' => 'en']); + + $this->assertEquals(15, $client->getTimeout()); + $this->assertEquals(['hl' => 'en'], $client->getParams()); + } + + public function testPositionalConstructorRemainsSupported() + { + $client = new Client('secret', 'bing', 30); + + $this->assertEquals('secret', $client->getApiKey()); + $this->assertEquals('bing', $client->getEngine()); + $this->assertEquals(30, $client->getTimeout()); + } + + public function testMissingEnvironmentKeyIsTreatedAsEmpty() + { + // getenv() returns false for unset variables. + $client = new Client(false); + $this->assertEquals('', $client->getApiKey()); + } + + public function testClientTimeoutIsUsedByDefault() + { + $client = new RecordingClient('secret', 'google', 30); + $client->search(['q' => 'coffee']); + + $this->assertEquals([30], $client->timeouts); + } + + public function testPerCallTimeoutAppliesToThatRequestOnly() + { + $client = new RecordingClient('secret', 'google', 30); + $client->search(['q' => 'coffee', 'timeout' => 5]); + $this->assertArrayNotHasKey('timeout', $client->lastQuery()); + + $client->search(['q' => 'coffee']); + $this->assertEquals([5, 30], $client->timeouts); + } +} From 57f094ded909732f16279d30433933e38d1de19a Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:01:42 +0200 Subject: [PATCH 03/10] Mask the API key in client debug output var_dump() on a client printed the API key in clear text, so a key could end up in logs or a bug report. The Ruby client masks it in inspect. Add __debugInfo() to mask the key for var_dump(), print_r() and debuggers, plus an inspect() method for parity. Keys of 8 characters or fewer are replaced entirely rather than partially revealed. var_export() and (array) casts read properties directly and cannot be hooked; this is called out in the docblock. Co-authored-by: Victor Benarbia --- src/Client.php | 52 ++++++++++++++++++++++++++++ tests/ClientInspectTest.php | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/ClientInspectTest.php diff --git a/src/Client.php b/src/Client.php index b5f7a38..f822a78 100644 --- a/src/Client.php +++ b/src/Client.php @@ -130,6 +130,58 @@ public function getParams(): array return $this->params; } + /** + * Human readable representation with the API key masked. + */ + public function inspect(): string + { + return sprintf( + '#<%s @engine=%s @timeout=%d @api_key=%s>', + static::class, + $this->engine, + $this->timeout, + $this->maskedApiKey() + ); + } + + /** + * Keeps var_dump(), print_r() and debuggers from printing the API key in + * clear text. + * + * var_export() and (array) casts read properties directly and bypass this + * hook; use inspect() when dumping a client on purpose. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'engine' => $this->engine, + 'timeout' => $this->timeout, + 'apiKey' => $this->maskedApiKey(), + 'params' => $this->params, + ]; + } + + /** + * Reveal only the first and last 4 characters of the API key, and none + * of a key too short for that to hide most of it. + */ + private function maskedApiKey(): string + { + $length = strlen($this->apiKey); + + if ($length === 0) { + return ''; + } + + if ($length <= 8) { + return '****'; + } + + return substr($this->apiKey, 0, 4) . '****' . substr($this->apiKey, -4); + } + /** * Run a search and return decoded JSON. * diff --git a/tests/ClientInspectTest.php b/tests/ClientInspectTest.php new file mode 100644 index 0000000..9c60cd1 --- /dev/null +++ b/tests/ClientInspectTest.php @@ -0,0 +1,69 @@ +assertStringNotContainsString(self::LONG_KEY, $client->inspect()); + } + + public function testInspectShowsFirstAndLastFourCharacters() + { + $client = new Client(self::LONG_KEY); + $this->assertStringContainsString('@api_key=abcd****wxyz', $client->inspect()); + } + + public function testInspectReportsEngineAndTimeout() + { + $inspect = (new Client(self::LONG_KEY, 'bing', 30))->inspect(); + + $this->assertStringContainsString('@engine=bing', $inspect); + $this->assertStringContainsString('@timeout=30', $inspect); + } + + public function testShortApiKeyIsFullyMasked() + { + $inspect = (new Client('abcdefgh'))->inspect(); + + $this->assertStringNotContainsString('abcd', $inspect); + $this->assertStringContainsString('@api_key=****', $inspect); + } + + public function testMissingApiKeyRendersAsEmpty() + { + $this->assertStringEndsWith('@api_key=>', (new Client())->inspect()); + } + + public function testVarDumpDoesNotExposeTheApiKey() + { + $client = new Client(['api_key' => self::LONG_KEY, 'hl' => 'en']); + + ob_start(); + var_dump($client); + $dump = (string) ob_get_clean(); + + $this->assertStringNotContainsString(self::LONG_KEY, $dump); + $this->assertStringContainsString('abcd****wxyz', $dump); + $this->assertStringContainsString('"hl"', $dump); + } + + public function testPrintRDoesNotExposeTheApiKey() + { + $dump = print_r(new Client(self::LONG_KEY), true); + + $this->assertStringNotContainsString(self::LONG_KEY, $dump); + $this->assertStringContainsString('abcd****wxyz', $dump); + } +} From e3eaad2a74fbb0c450be8f3092b5d98db17cd106 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:04:05 +0200 Subject: [PATCH 04/10] Reuse the connection across requests Every request created and destroyed its own cURL handle, paying for DNS, TCP and TLS setup each time. The Ruby client keeps a persistent socket and reports roughly twice the throughput because of it. Keep one cURL handle per client and reuse it, so curl holds the connection open between requests. Measured against the locations endpoint, five warmed-up requests drop from ~300ms to ~100ms. Persistent mode is on by default, matching the Ruby client, and can be turned off with `['persistent' => false]`. close() releases the connection and the client reconnects on the next request; the destructor closes it too, so existing code needs no change. Holding a handle brings lifecycle concerns that are handled explicitly: - a clone opens its own connection instead of sharing the original's, which on PHP 7 it would otherwise close when destroyed - the handle is left out of serialization, since serializing a CurlHandle throws on PHP 8 and would break clients stored on queued jobs - a stale handle (closed, or deserialized on PHP 7.2/7.3) is replaced - a per-call `persistent` is rejected rather than silently ignored Co-authored-by: Victor Benarbia --- src/Client.php | 139 ++++++++++++++++++++++++++++--- tests/ClientPersistentTest.php | 146 +++++++++++++++++++++++++++++++++ tests/LocalFileClient.php | 17 ++++ 3 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 tests/ClientPersistentTest.php create mode 100644 tests/LocalFileClient.php diff --git a/src/Client.php b/src/Client.php index f822a78..caf15bc 100644 --- a/src/Client.php +++ b/src/Client.php @@ -23,16 +23,22 @@ class Client /** @var array Search parameters merged into every request */ private $params = []; + /** @var bool Keep one connection open and reuse it across requests */ + private $persistent = true; + + /** @var resource|\CurlHandle|null Shared cURL handle: a resource on PHP 7, a CurlHandle on PHP 8 */ + private $handle; + /** * Accepts positional arguments or, like the Ruby client, a single array * mixing configuration with default search parameters: * * new Client(['api_key' => '...', 'engine' => 'google', 'hl' => 'en']) * - * `api_key`, `engine` and `timeout` configure the client and take - * precedence over the positional arguments. Every other key becomes a - * default search parameter, sent with every request unless overridden - * per call. + * `api_key`, `engine`, `timeout` and `persistent` configure the client + * and take precedence over the positional arguments. Every other key + * becomes a default search parameter, sent with every request unless + * overridden per call. * * @param string|array $apiKey API key, or a configuration array * @param string $engine @@ -54,6 +60,7 @@ public function __construct( $apiKey = self::pull($params, 'api_key', $apiKey); $engine = self::pull($params, 'engine', $engine); $timeout = self::pull($params, 'timeout', $timeout); + $persistent = self::pull($params, 'persistent', true); if (empty($engine)) { throw new SerpApiException('engine must be present'); @@ -62,6 +69,7 @@ public function __construct( $this->apiKey = (string) $apiKey; $this->engine = (string) $engine; $this->timeout = (int) $timeout; + $this->persistent = (bool) $persistent; $this->params = $params; } @@ -130,16 +138,76 @@ public function getParams(): array return $this->params; } + /** + * Whether the client reuses one connection across requests. + */ + public function isPersistent(): bool + { + return $this->persistent; + } + + /** + * Close the shared connection. Safe to call more than once; the client + * stays usable and reconnects on the next request. + */ + public function close(): void + { + if ($this->hasOpenHandle()) { + self::closeHandle($this->handle); + } + + $this->handle = null; + } + + public function __destruct() + { + $this->close(); + } + + /** + * A clone opens its own connection rather than sharing, and later + * closing, the original's. + */ + public function __clone() + { + $this->handle = null; + } + + /** + * cURL handles cannot be serialized; the connection is reopened on the + * first request after unserializing. + * + * @return array + */ + public function __serialize(): array + { + $data = get_object_vars($this); + unset($data['handle']); + + return $data; + } + + /** + * @param array $data + */ + public function __unserialize(array $data): void + { + foreach ($data as $property => $value) { + $this->{$property} = $value; + } + } + /** * Human readable representation with the API key masked. */ public function inspect(): string { return sprintf( - '#<%s @engine=%s @timeout=%d @api_key=%s>', + '#<%s @engine=%s @timeout=%d @persistent=%s @api_key=%s>', static::class, $this->engine, $this->timeout, + $this->persistent ? 'true' : 'false', $this->maskedApiKey() ); } @@ -158,6 +226,7 @@ public function __debugInfo(): array return [ 'engine' => $this->engine, 'timeout' => $this->timeout, + 'persistent' => $this->persistent, 'apiKey' => $this->maskedApiKey(), 'params' => $this->params, ]; @@ -276,6 +345,10 @@ private function get(string $endpoint, string $format = 'json', array $params = throw new SerpApiException('api_key must be present'); } + if (array_key_exists('persistent', $params)) { + throw new SerpApiException('persistent can only be set on the client'); + } + $timeout = (int) self::pull($params, 'timeout', $this->timeout); $query = $this->buildQuery($params, (string) $apiKey, $format); @@ -359,10 +432,7 @@ private function buildQuery(array $params, string $apiKey, string $format): arra */ protected function request(string $url, int $timeout): array { - $ch = curl_init(); - if ($ch === false) { - throw new SerpApiException('Failed to initialize cURL handle'); - } + $ch = $this->acquireHandle(); try { $isConfigured = curl_setopt_array($ch, [ @@ -387,11 +457,56 @@ protected function request(string $url, int $timeout): array 'curl_error' => $curlError, ]; } finally { - if (PHP_VERSION_ID < 80500) { - curl_close($ch); + if (!$this->persistent) { + self::closeHandle($ch); } + } + } + + /** + * The shared handle in persistent mode, otherwise a new one per request. + * + * @return resource|\CurlHandle + * @throws SerpApiException + */ + private function acquireHandle() + { + if ($this->persistent && $this->hasOpenHandle()) { + return $this->handle; + } + + $ch = curl_init(); + if ($ch === false) { + throw new SerpApiException('Failed to initialize cURL handle'); + } - $ch = null; + if ($this->persistent) { + $this->handle = $ch; + } + + return $ch; + } + + /** + * False for a closed PHP 7 resource too, and for the integer a resource + * turns into when serialized on PHP 7.2 and 7.3. + * + * @phpstan-assert-if-true !null $this->handle + */ + private function hasOpenHandle(): bool + { + return is_resource($this->handle) || $this->handle instanceof \CurlHandle; + } + + /** + * @param resource|\CurlHandle $handle + */ + private static function closeHandle($handle): void + { + // curl_close() is a no-op since PHP 8.0 and deprecated in 8.5; the + // handle is freed once nothing references it. + if (PHP_VERSION_ID < 80500) { + curl_close($handle); } } diff --git a/tests/ClientPersistentTest.php b/tests/ClientPersistentTest.php new file mode 100644 index 0000000..4132fed --- /dev/null +++ b/tests/ClientPersistentTest.php @@ -0,0 +1,146 @@ +setAccessible(true); + } + + return $property->getValue($client); + } + + public function testPersistentIsEnabledByDefault() + { + $this->assertTrue((new Client('secret'))->isPersistent()); + } + + public function testPersistentCanBeDisabled() + { + $this->assertFalse((new Client(['api_key' => 'secret', 'persistent' => false]))->isPersistent()); + $this->assertFalse((new Client('secret', 'google', 120, ['persistent' => false]))->isPersistent()); + } + + public function testPersistentIsNotSentAsASearchParameter() + { + $client = new RecordingClient(['api_key' => 'secret', 'persistent' => false]); + $client->search(['q' => 'coffee']); + + $this->assertArrayNotHasKey('persistent', $client->getParams()); + $this->assertArrayNotHasKey('persistent', $client->lastQuery()); + } + + public function testPerCallPersistentIsRejected() + { + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('persistent can only be set on the client'); + (new RecordingClient('secret'))->search(['persistent' => false]); + } + + public function testInspectReportsPersistentMode() + { + $this->assertStringContainsString('@persistent=true', (new Client('secret'))->inspect()); + $this->assertStringContainsString('@persistent=false', (new Client(['persistent' => false]))->inspect()); + } + + public function testRequestsWorkThroughTheSharedHandle() + { + $client = new LocalFileClient('secret'); + + $this->assertStringContainsString(self::MARKER, $client->fetch()); + $this->assertStringContainsString(self::MARKER, $client->fetch()); + } + + public function testNoConnectionIsOpenedBeforeTheFirstRequest() + { + $this->assertNull($this->handle(new LocalFileClient('secret'))); + } + + public function testPersistentClientReusesTheSameHandle() + { + $client = new LocalFileClient('secret'); + + $client->fetch(); + $first = $this->handle($client); + $client->fetch(); + + $this->assertNotNull($first); + $this->assertSame($first, $this->handle($client)); + } + + public function testNonPersistentClientKeepsNoHandle() + { + $client = new LocalFileClient(['api_key' => 'secret', 'persistent' => false]); + + $this->assertStringContainsString(self::MARKER, $client->fetch()); + $this->assertNull($this->handle($client)); + } + + public function testCloseIsIdempotentAndTheClientReconnects() + { + $client = new LocalFileClient('secret'); + $client->fetch(); + $first = $this->handle($client); + + $client->close(); + $client->close(); + $this->assertNull($this->handle($client)); + + $client->fetch(); + $this->assertNotNull($this->handle($client)); + $this->assertNotSame($first, $this->handle($client)); + } + + public function testCloneOpensItsOwnConnection() + { + $client = new LocalFileClient('secret'); + $client->fetch(); + $original = $this->handle($client); + + $copy = clone $client; + $this->assertNull($this->handle($copy)); + + $copy->fetch(); + unset($copy); + + $this->assertSame($original, $this->handle($client)); + $this->assertStringContainsString(self::MARKER, $client->fetch()); + } + + public function testClientCanBeSerializedAfterARequest() + { + $client = new LocalFileClient(['api_key' => 'secret', 'engine' => 'bing', 'hl' => 'en']); + $client->fetch(); + + $restored = unserialize(serialize($client)); + + $this->assertInstanceOf(LocalFileClient::class, $restored); + $this->assertEquals('bing', $restored->getEngine()); + $this->assertEquals('secret', $restored->getApiKey()); + $this->assertEquals(['hl' => 'en'], $restored->getParams()); + $this->assertStringContainsString(self::MARKER, $restored->fetch()); + } +} diff --git a/tests/LocalFileClient.php b/tests/LocalFileClient.php new file mode 100644 index 0000000..cd2eaf7 --- /dev/null +++ b/tests/LocalFileClient.php @@ -0,0 +1,17 @@ +request('file://' . __FILE__, 5)['response']; + } +} From c7782e0aaf96973a8370f031bc61cd15984c6a04 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:05:47 +0200 Subject: [PATCH 05/10] Add searchArray() returning results as an associative array search() keeps returning stdClass. Error and search id extraction now reads both decoded shapes. Co-authored-by: Victor Benarbia --- src/Client.php | 46 +++++++++++++--- tests/ClientSearchArrayTest.php | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 tests/ClientSearchArrayTest.php diff --git a/src/Client.php b/src/Client.php index caf15bc..fb9a308 100644 --- a/src/Client.php +++ b/src/Client.php @@ -262,6 +262,18 @@ public function search(array $params = []): object return $this->get('/search', 'json', $params); } + /** + * Run a search and return decoded JSON as an associative array. + * + * @param array $params + * @return array + * @throws SerpApiException + */ + public function searchArray(array $params = []): array + { + return $this->get('/search', 'json', $params, true); + } + /** * Run a search and return Markdown optimized for LLMs and AI agents. * @@ -329,10 +341,11 @@ public function searchArchive(string $searchId, string $format = 'json') /** * @param array $params + * @param bool $assoc Decode JSON objects to associative arrays instead of stdClass * @return object|array|string * @throws SerpApiException */ - private function get(string $endpoint, string $format = 'json', array $params = []) + private function get(string $endpoint, string $format = 'json', array $params = [], bool $assoc = false) { if (!in_array($format, ['json', 'html', 'md'], true)) { throw new SerpApiException("Unsupported format '$format'. Expected 'html', 'json', or 'md'."); @@ -372,15 +385,16 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raiseHttpError($httpCode, $endpoint, $query, null, null, $format); } - $decoded = json_decode($response); + $decoded = json_decode($response, $assoc); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { $this->raiseParserError($httpCode, $endpoint, $query); } - $serpApiError = (is_object($decoded) && isset($decoded->error)) ? $decoded->error : null; - $searchId = (is_object($decoded) && isset($decoded->search_metadata->id)) - ? (string) $decoded->search_metadata->id - : null; + $error = self::dig($decoded, 'error'); + $serpApiError = is_scalar($error) ? (string) $error : null; + + $id = self::dig(self::dig($decoded, 'search_metadata'), 'id'); + $searchId = is_scalar($id) ? (string) $id : null; if ($httpCode === 200) { if ($serpApiError !== null) { @@ -393,6 +407,26 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raiseHttpError($httpCode, $endpoint, $query, $serpApiError, $searchId, 'json'); } + /** + * Read a key from decoded JSON, whether it was decoded to objects or + * associative arrays. + * + * @param mixed $data + * @return mixed + */ + private static function dig($data, string $key) + { + if (is_array($data)) { + return $data[$key] ?? null; + } + + if (is_object($data)) { + return $data->{$key} ?? null; + } + + return null; + } + /** * Assemble the query string parameters for a request. * diff --git a/tests/ClientSearchArrayTest.php b/tests/ClientSearchArrayTest.php new file mode 100644 index 0000000..3baff57 --- /dev/null +++ b/tests/ClientSearchArrayTest.php @@ -0,0 +1,93 @@ +willReturn(self::RESULTS); + $results = $client->searchArray(['q' => 'coffee']); + + $this->assertIsArray($results); + $this->assertIsArray($results['organic_results'][0]); + $this->assertEquals('Coffee', $results['organic_results'][0]['title']); + $this->assertEquals('abc123', $results['search_metadata']['id']); + } + + public function testSearchArraySendsSameQueryAsSearch() + { + $client = (new RecordingClient('secret', 'bing'))->willReturn(self::RESULTS); + + $client->search(['q' => 'coffee']); + $objectQuery = $client->lastQuery(); + + $client->searchArray(['q' => 'coffee']); + + $this->assertEquals('/search', $client->lastPath()); + $this->assertEquals($objectQuery, $client->lastQuery()); + } + + public function testSearchStillReturnsObject() + { + $client = (new RecordingClient('secret'))->willReturn(self::RESULTS); + $results = $client->search(['q' => 'coffee']); + + $this->assertIsObject($results); + $this->assertEquals('Coffee', $results->organic_results[0]->title); + } + + public function testSearchArrayRaisesSerpApiError() + { + $client = (new RecordingClient('secret'))->willReturn( + '{"error":"Invalid API key.","search_metadata":{"id":"abc123"}}', + 401 + ); + + try { + $client->searchArray(['q' => 'coffee']); + $this->fail('Expected SerpApiException'); + } catch (SerpApiException $e) { + $this->assertEquals('Invalid API key.', $e->getSerpApiError()); + $this->assertEquals('abc123', $e->getSearchId()); + $this->assertEquals(401, $e->getResponseStatus()); + } + } + + public function testSearchArrayRaisesErrorReturnedWithStatus200() + { + $client = (new RecordingClient('secret'))->willReturn( + '{"error":"Google hasn\'t returned any results for this query."}' + ); + + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage("error: Google hasn't returned any results for this query."); + + $client->searchArray(['q' => 'zzzz']); + } + + public function testSearchArrayRaisesOnInvalidJson() + { + $client = (new RecordingClient('secret'))->willReturn('not json'); + + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('JSON parse error'); + + $client->searchArray(['q' => 'coffee']); + } +} From 504919b09dc1d2ab29acc59216537145d1752c18 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:10:03 +0200 Subject: [PATCH 06/10] Add example tests for the engines covered by serpapi-ruby Ports the PR's engine examples to the master test style and adds google_ai_mode. Compared to the original PR: - amazon searches with `k`, its query parameter, instead of `q` - google_videos asserts `video_results`, which the engine returns - google_images drops the redundant `tbm=isch` - google_flights matches the Ruby spec (LAX to AUS) and accepts either `best_flights` or `other_flights` - google_immersive_product and google_product take a page_token and a product_id from a live Google Shopping search instead of hardcoding values that go stale, and skip when none is returned - google_ai_overview bypasses the cache when fetching its page_token, which expires a minute after the search Co-authored-by: Victor Benarbia --- tests/ExampleSearchAmazonTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleAiModeTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleAiOverviewTest.php | 27 ++++++++++++++ tests/ExampleSearchGoogleFinanceTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleFlightsTest.php | 35 ++++++++++++++++++ tests/ExampleSearchGoogleHotelsTest.php | 37 +++++++++++++++++++ tests/ExampleSearchGoogleImagesLightTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleImagesTest.php | 29 +++++++++++++++ ...xampleSearchGoogleImmersiveProductTest.php | 34 +++++++++++++++++ tests/ExampleSearchGoogleLightSearchTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleLightTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleNewsLightTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleNewsTest.php | 31 ++++++++++++++++ tests/ExampleSearchGooglePatentsTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleProductTest.php | 34 +++++++++++++++++ tests/ExampleSearchGoogleReverseImageTest.php | 29 +++++++++++++++ tests/ExampleSearchGoogleTrendsTest.php | 30 +++++++++++++++ tests/ExampleSearchGoogleVideosTest.php | 29 +++++++++++++++ tests/ExampleSearchYandexTest.php | 29 +++++++++++++++ tests/ExampleSearchYelpTest.php | 30 +++++++++++++++ 20 files changed, 606 insertions(+) create mode 100644 tests/ExampleSearchAmazonTest.php create mode 100644 tests/ExampleSearchGoogleAiModeTest.php create mode 100644 tests/ExampleSearchGoogleAiOverviewTest.php create mode 100644 tests/ExampleSearchGoogleFinanceTest.php create mode 100644 tests/ExampleSearchGoogleFlightsTest.php create mode 100644 tests/ExampleSearchGoogleHotelsTest.php create mode 100644 tests/ExampleSearchGoogleImagesLightTest.php create mode 100644 tests/ExampleSearchGoogleImagesTest.php create mode 100644 tests/ExampleSearchGoogleImmersiveProductTest.php create mode 100644 tests/ExampleSearchGoogleLightSearchTest.php create mode 100644 tests/ExampleSearchGoogleLightTest.php create mode 100644 tests/ExampleSearchGoogleNewsLightTest.php create mode 100644 tests/ExampleSearchGoogleNewsTest.php create mode 100644 tests/ExampleSearchGooglePatentsTest.php create mode 100644 tests/ExampleSearchGoogleProductTest.php create mode 100644 tests/ExampleSearchGoogleReverseImageTest.php create mode 100644 tests/ExampleSearchGoogleTrendsTest.php create mode 100644 tests/ExampleSearchGoogleVideosTest.php create mode 100644 tests/ExampleSearchYandexTest.php create mode 100644 tests/ExampleSearchYelpTest.php diff --git a/tests/ExampleSearchAmazonTest.php b/tests/ExampleSearchAmazonTest.php new file mode 100644 index 0000000..3bd9d77 --- /dev/null +++ b/tests/ExampleSearchAmazonTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'amazon', + 'k' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `amazon` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleAiModeTest.php b/tests/ExampleSearchGoogleAiModeTest.php new file mode 100644 index 0000000..4d115fb --- /dev/null +++ b/tests/ExampleSearchGoogleAiModeTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_ai_mode', + 'q' => 'best coffee maker', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'reconstructed_markdown', + 'Error on `google_ai_mode` engine: no `reconstructed_markdown`' + ); + } +} diff --git a/tests/ExampleSearchGoogleAiOverviewTest.php b/tests/ExampleSearchGoogleAiOverviewTest.php new file mode 100644 index 0000000..ece6b20 --- /dev/null +++ b/tests/ExampleSearchGoogleAiOverviewTest.php @@ -0,0 +1,27 @@ +serpApiClient('google'); + $search = $google->search(['q' => 'what is coffee', 'no_cache' => true]); + + $pageToken = $search->ai_overview->page_token ?? null; + if ($pageToken === null) { + $this->markTestSkipped('Google returned no AI Overview page_token for this query'); + } + + $client = $this->serpApiClient('google_ai_overview'); + $response = $client->search(['page_token' => $pageToken]); + $this->assertResponseHasProperty( + $response, + 'ai_overview', + 'Error on `google_ai_overview` engine: no `ai_overview`' + ); + } +} diff --git a/tests/ExampleSearchGoogleFinanceTest.php b/tests/ExampleSearchGoogleFinanceTest.php new file mode 100644 index 0000000..fa7b7bc --- /dev/null +++ b/tests/ExampleSearchGoogleFinanceTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_finance', + 'q' => 'GOOG:NASDAQ', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'markets', + 'Error on `google_finance` engine: no `markets`' + ); + } +} diff --git a/tests/ExampleSearchGoogleFlightsTest.php b/tests/ExampleSearchGoogleFlightsTest.php new file mode 100644 index 0000000..17d898e --- /dev/null +++ b/tests/ExampleSearchGoogleFlightsTest.php @@ -0,0 +1,35 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + + // Dates are relative to today so the example never asks for a past flight. + $this->searchParams = [ + 'engine' => 'google_flights', + 'departure_id' => 'LAX', + 'arrival_id' => 'AUS', + 'outbound_date' => date('Y-m-d', strtotime('+30 days')), + 'return_date' => date('Y-m-d', strtotime('+37 days')), + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + + // Google only ranks some routes, so either list counts as a result. + $this->assertNotNull( + $response->best_flights ?? $response->other_flights ?? null, + 'Error on `google_flights` engine: no `best_flights` or `other_flights`' + ); + } +} diff --git a/tests/ExampleSearchGoogleHotelsTest.php b/tests/ExampleSearchGoogleHotelsTest.php new file mode 100644 index 0000000..ef45bc0 --- /dev/null +++ b/tests/ExampleSearchGoogleHotelsTest.php @@ -0,0 +1,37 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + + // Dates are relative to today so the example never asks for a past stay. + $this->searchParams = [ + 'engine' => 'google_hotels', + 'q' => 'Bali Resorts', + 'check_in_date' => date('Y-m-d', strtotime('+30 days')), + 'check_out_date' => date('Y-m-d', strtotime('+31 days')), + 'adults' => '2', + 'currency' => 'USD', + 'gl' => 'us', + 'hl' => 'en', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'properties', + 'Error on `google_hotels` engine: no `properties`' + ); + } +} diff --git a/tests/ExampleSearchGoogleImagesLightTest.php b/tests/ExampleSearchGoogleImagesLightTest.php new file mode 100644 index 0000000..1c776dd --- /dev/null +++ b/tests/ExampleSearchGoogleImagesLightTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_images_light', + 'q' => 'Coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'images_results', + 'Error on `google_images_light` engine: no `images_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleImagesTest.php b/tests/ExampleSearchGoogleImagesTest.php new file mode 100644 index 0000000..ff12ece --- /dev/null +++ b/tests/ExampleSearchGoogleImagesTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_images', + 'q' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'images_results', + 'Error on `google_images` engine: no `images_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleImmersiveProductTest.php b/tests/ExampleSearchGoogleImmersiveProductTest.php new file mode 100644 index 0000000..4778bd0 --- /dev/null +++ b/tests/ExampleSearchGoogleImmersiveProductTest.php @@ -0,0 +1,34 @@ +serpApiClient('google_shopping'); + $search = $shopping->search(['q' => 'coffee maker']); + + $pageToken = null; + foreach ($search->shopping_results ?? [] as $product) { + if (isset($product->immersive_product_page_token)) { + $pageToken = $product->immersive_product_page_token; + break; + } + } + + if ($pageToken === null) { + $this->markTestSkipped('Google Shopping returned no immersive_product_page_token'); + } + + $client = $this->serpApiClient('google_immersive_product'); + $response = $client->search(['page_token' => $pageToken]); + $this->assertResponseHasProperty( + $response, + 'product_results', + 'Error on `google_immersive_product` engine: no `product_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleLightSearchTest.php b/tests/ExampleSearchGoogleLightSearchTest.php new file mode 100644 index 0000000..21e529c --- /dev/null +++ b/tests/ExampleSearchGoogleLightSearchTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_light_search', + 'q' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `google_light_search` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleLightTest.php b/tests/ExampleSearchGoogleLightTest.php new file mode 100644 index 0000000..3381bee --- /dev/null +++ b/tests/ExampleSearchGoogleLightTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_light', + 'q' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `google_light` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleNewsLightTest.php b/tests/ExampleSearchGoogleNewsLightTest.php new file mode 100644 index 0000000..3bf3eb3 --- /dev/null +++ b/tests/ExampleSearchGoogleNewsLightTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_news_light', + 'q' => 'pizza', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'news_results', + 'Error on `google_news_light` engine: no `news_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleNewsTest.php b/tests/ExampleSearchGoogleNewsTest.php new file mode 100644 index 0000000..ce25565 --- /dev/null +++ b/tests/ExampleSearchGoogleNewsTest.php @@ -0,0 +1,31 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_news', + 'q' => 'pizza', + 'gl' => 'us', + 'hl' => 'en', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'news_results', + 'Error on `google_news` engine: no `news_results`' + ); + } +} diff --git a/tests/ExampleSearchGooglePatentsTest.php b/tests/ExampleSearchGooglePatentsTest.php new file mode 100644 index 0000000..6924d4f --- /dev/null +++ b/tests/ExampleSearchGooglePatentsTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_patents', + 'q' => '(Coffee)', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `google_patents` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleProductTest.php b/tests/ExampleSearchGoogleProductTest.php new file mode 100644 index 0000000..b52ca43 --- /dev/null +++ b/tests/ExampleSearchGoogleProductTest.php @@ -0,0 +1,34 @@ +serpApiClient('google_shopping'); + $search = $shopping->search(['q' => 'coffee maker']); + + $productId = null; + foreach ($search->shopping_results ?? [] as $product) { + if (isset($product->product_id)) { + $productId = $product->product_id; + break; + } + } + + if ($productId === null) { + $this->markTestSkipped('Google Shopping returned no product_id'); + } + + $client = $this->serpApiClient('google_product'); + $response = $client->search(['product_id' => $productId]); + $this->assertResponseHasProperty( + $response, + 'product_results', + 'Error on `google_product` engine: no `product_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleReverseImageTest.php b/tests/ExampleSearchGoogleReverseImageTest.php new file mode 100644 index 0000000..a6e72df --- /dev/null +++ b/tests/ExampleSearchGoogleReverseImageTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_reverse_image', + 'image_url' => 'https://i.imgur.com/5bGzZi7.jpg', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'image_sizes', + 'Error on `google_reverse_image` engine: no `image_sizes`' + ); + } +} diff --git a/tests/ExampleSearchGoogleTrendsTest.php b/tests/ExampleSearchGoogleTrendsTest.php new file mode 100644 index 0000000..8b15a3b --- /dev/null +++ b/tests/ExampleSearchGoogleTrendsTest.php @@ -0,0 +1,30 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_trends', + 'q' => 'coffee,milk,bread,pasta,steak', + 'data_type' => 'TIMESERIES', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'interest_over_time', + 'Error on `google_trends` engine: no `interest_over_time`' + ); + } +} diff --git a/tests/ExampleSearchGoogleVideosTest.php b/tests/ExampleSearchGoogleVideosTest.php new file mode 100644 index 0000000..be86e9c --- /dev/null +++ b/tests/ExampleSearchGoogleVideosTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_videos', + 'q' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'video_results', + 'Error on `google_videos` engine: no `video_results`' + ); + } +} diff --git a/tests/ExampleSearchYandexTest.php b/tests/ExampleSearchYandexTest.php new file mode 100644 index 0000000..cfdadb7 --- /dev/null +++ b/tests/ExampleSearchYandexTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'yandex', + 'text' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `yandex` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchYelpTest.php b/tests/ExampleSearchYelpTest.php new file mode 100644 index 0000000..4148762 --- /dev/null +++ b/tests/ExampleSearchYelpTest.php @@ -0,0 +1,30 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'yelp', + 'find_desc' => 'Coffee', + 'find_loc' => 'New York, NY, USA', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `yelp` engine: no `organic_results`' + ); + } +} From 6ba9a9bbf08ff969b2c77efcc9ac000e06299257 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:12:28 +0200 Subject: [PATCH 07/10] Add demo scripts and a make demo target demo.php runs a basic search, demo_suggest.php reuses default search parameters, demo_async.php submits a batch with async and collects it through the Search Archive API, and demo_persistent.php benchmarks connection reuse. Compared to the original PR: - the async demo stops polling on Success, fails fast on an archived error, and passes `async` per call so it is not also sent with every Search Archive request - the benchmark warms up both clients before timing them - `demo` is declared .PHONY; otherwise make sees the demo/ directory and reports the target as up to date without running anything - demo/ is linted with phpcs Co-authored-by: Victor Benarbia --- Makefile | 9 +++++ demo/demo.php | 39 +++++++++++++++++++++ demo/demo_async.php | 73 ++++++++++++++++++++++++++++++++++++++++ demo/demo_persistent.php | 42 +++++++++++++++++++++++ demo/demo_suggest.php | 47 ++++++++++++++++++++++++++ phpcs.xml | 1 + 6 files changed, 211 insertions(+) create mode 100644 demo/demo.php create mode 100644 demo/demo_async.php create mode 100644 demo/demo_persistent.php create mode 100644 demo/demo_suggest.php diff --git a/Makefile b/Makefile index 9cc42ed..b6fe297 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: all clean install lint test readme demo + # Default target all: install readme lint test @@ -20,3 +22,10 @@ test: # Generate README from ERB template readme: erb -T '-' README.md.erb > README.md + +# Run the demo scripts against the live API (requires SERPAPI_KEY) +demo: + @for file in demo/*.php; do \ + echo "running $$file"; \ + php $$file || exit 1; \ + done diff --git a/demo/demo.php b/demo/demo.php new file mode 100644 index 0000000..6eea992 --- /dev/null +++ b/demo/demo.php @@ -0,0 +1,39 @@ + $apiKey, + 'engine' => 'google', +]); + +$results = $client->search(['q' => 'coffee']); + +if (empty($results->organic_results)) { + fwrite(STDERR, "no organic results found\n"); + exit(1); +} + +foreach ($results->organic_results as $result) { + printf("%d. %s\n %s\n", $result->position, $result->title, $result->link); +} + +$client->close(); +echo "done\n"; diff --git a/demo/demo_async.php b/demo/demo_async.php new file mode 100644 index 0000000..a2bd8ee --- /dev/null +++ b/demo/demo_async.php @@ -0,0 +1,73 @@ + true` SerpApi accepts a search and returns right away + * instead of waiting for the results. Submitting a batch first and then + * collecting the results through the Search Archive API is much faster + * than running the searches one after another. + * + * Usage: + * export SERPAPI_KEY="your secret key" + * php demo/demo_async.php + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use SerpApi\Client; +use SerpApi\SerpApiException; + +$apiKey = getenv('SERPAPI_KEY'); +if (empty($apiKey)) { + fwrite(STDERR, "SERPAPI_KEY environment variable must be set\n"); + exit(1); +} + +// One client, and so one open connection, for the whole batch. +$client = new Client(['api_key' => $apiKey, 'engine' => 'google']); + +// `async` is passed per call rather than set on the client, where it would +// also be sent with every Search Archive request below. +$pending = []; +foreach (['meta', 'amazon', 'apple', 'netflix', 'google'] as $company) { + $submitted = $client->search(['q' => $company, 'async' => true]); + $pending[$submitted->search_metadata->id] = $company; + echo "submitted: {$company}\n"; +} + +echo "\ncollecting " . count($pending) . " results\n"; + +$deadline = time() + 120; +while (!empty($pending)) { + if (time() >= $deadline) { + fwrite(STDERR, 'timed out waiting for: ' . implode(', ', $pending) . "\n"); + exit(1); + } + + foreach ($pending as $searchId => $company) { + try { + $archived = $client->searchArchive((string) $searchId); + } catch (SerpApiException $e) { + // A failed search is archived with an `error`, which the client raises. + fwrite(STDERR, "{$company} failed: {$e->getMessage()}\n"); + exit(1); + } + + // Queued or Processing: check again on the next pass. + if ($archived->search_metadata->status !== 'Success') { + continue; + } + + $count = count($archived->organic_results ?? []); + printf(" %-8s %d organic results\n", $company, $count); + unset($pending[$searchId]); + } + + if (!empty($pending)) { + sleep(1); + } +} + +$client->close(); +echo "done\n"; diff --git a/demo/demo_persistent.php b/demo/demo_persistent.php new file mode 100644 index 0000000..0ad75ab --- /dev/null +++ b/demo/demo_persistent.php @@ -0,0 +1,42 @@ +location(['q' => 'Austin', 'limit' => 1]); + + $start = microtime(true); + for ($i = 0; $i < $requests; $i++) { + $client->location(['q' => 'Austin', 'limit' => 1]); + } + + return microtime(true) - $start; +}; + +$without = $benchmark(new Client(['persistent' => false])); +printf("persistent off: %6.0f ms (%.1f req/s)\n", $without * 1000, $requests / $without); + +$client = new Client(['persistent' => true]); +$with = $benchmark($client); +$client->close(); +printf("persistent on: %6.0f ms (%.1f req/s)\n", $with * 1000, $requests / $with); + +printf("\nspeedup: %.1fx\n", $without / $with); +echo "done\n"; diff --git a/demo/demo_suggest.php b/demo/demo_suggest.php new file mode 100644 index 0000000..afdd2ef --- /dev/null +++ b/demo/demo_suggest.php @@ -0,0 +1,47 @@ + $apiKey, + 'engine' => 'google_autocomplete', + 'timeout' => 10, + 'client' => 'safari', + 'hl' => 'en', + 'gl' => 'us', +]); + +foreach (['coffee', 'espresso'] as $query) { + $results = $client->search(['q' => $query]); + + if (empty($results->suggestions)) { + fwrite(STDERR, "no suggestions found for {$query}\n"); + exit(1); + } + + echo "{$query}:\n"; + foreach ($results->suggestions as $suggestion) { + echo " - {$suggestion->value}\n"; + } +} + +echo "done\n"; diff --git a/phpcs.xml b/phpcs.xml index 7ba24af..4a9e47f 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -4,6 +4,7 @@ src tests + demo From 61f9bca834ac1666d4f399f0bd3f731396ef27f8 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:13:23 +0200 Subject: [PATCH 08/10] Add PHPStan static analysis Analyses src and demo at level 6, from `composer analyse` or `make analyse`. CI runs it once, in the lint job, rather than across the PHP matrix. searchArchive() now declares that the json format returns an object, so callers need not narrow object|string themselves. Co-authored-by: Victor Benarbia --- .github/workflows/serpapi-php.yml | 3 +++ Makefile | 8 ++++++-- composer.json | 6 ++++-- phpstan.neon | 5 +++++ src/Client.php | 1 + 5 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 phpstan.neon diff --git a/.github/workflows/serpapi-php.yml b/.github/workflows/serpapi-php.yml index f6de1c5..83cda27 100644 --- a/.github/workflows/serpapi-php.yml +++ b/.github/workflows/serpapi-php.yml @@ -25,6 +25,9 @@ jobs: - name: Run linter run: composer run-script lint + - name: Run static analysis + run: composer run-script analyse + run: runs-on: ubuntu-latest strategy: diff --git a/Makefile b/Makefile index b6fe297..6b776af 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: all clean install lint test readme demo +.PHONY: all clean install lint analyse test readme demo # Default target -all: install readme lint test +all: install readme lint analyse test # Clean up the project clean: @@ -15,6 +15,10 @@ install: lint: vendor/bin/phpcs +# Run static analysis +analyse: + vendor/bin/phpstan analyse --no-progress --memory-limit=512M + # Run the tests test: vendor/bin/phpunit -c phpunit.xml diff --git a/composer.json b/composer.json index 3cb274b..4498ba5 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,8 @@ }, "require-dev": { "phpunit/phpunit": "^8.5.52 || ^9.6 || ^10.5 || ^11.5 || ^12.5 || ^13.0", - "squizlabs/php_codesniffer": "^3.10" + "squizlabs/php_codesniffer": "^3.10", + "phpstan/phpstan": "^1.12 || ^2.1" }, "autoload": { "psr-4": { @@ -62,6 +63,7 @@ }, "scripts": { "test": "phpunit -c phpunit.xml", - "lint": "phpcs" + "lint": "phpcs", + "analyse": "phpstan analyse --no-progress --memory-limit=512M" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f0d3853 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 6 + paths: + - src + - demo diff --git a/src/Client.php b/src/Client.php index fb9a308..af3efc8 100644 --- a/src/Client.php +++ b/src/Client.php @@ -323,6 +323,7 @@ public function location(array $params = []): array * Retrieve search result from the Search Archive API. * * @return object|string + * @phpstan-return ($format is 'json' ? object : string) * @throws SerpApiException */ public function searchArchive(string $searchId, string $format = 'json') From e057e0d2606da0390b1567d2a609de9c74a5fad4 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:15:12 +0200 Subject: [PATCH 09/10] Document the new client features and engines Covers searchArray(), per-call timeouts, default search parameters, persistent connections, API key masking, the demo scripts and static analysis, and adds a section for each engine with a new example. The snippet helper now matches assertions split over several lines, as master formats them. Google Autocomplete, Google Shopping and Google Events previously fell back to printing organic_results. Co-authored-by: Victor Benarbia --- README.md | 419 +++++++++++++++++++++++++++++++++++++++++++++++++- README.md.erb | 212 ++++++++++++++++++++++++- 2 files changed, 626 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 36c0eae..44d580d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,14 @@ Use `search` for structured results decoded into a PHP object: ```php $results = $client->search(['q' => 'coffee']); +echo $results->organic_results[0]->title; +``` + +Use `searchArray` for the same results as an associative array: + +```php +$results = $client->searchArray(['q' => 'coffee']); +echo $results['organic_results'][0]['title']; ``` Use `md` for a token-efficient Markdown string optimized for LLMs and AI agents: @@ -126,6 +134,64 @@ use SerpApi\Client; $client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` +Or for a single request: + +```php +$results = $client->search(['q' => 'coffee', 'timeout' => 10]); +``` + +### Default search parameters + +The constructor also accepts a single array. `api_key`, `engine`, `timeout`, and `persistent` configure the client; every other key becomes a default search parameter sent with every request: + +```php +use SerpApi\Client; + +$client = new Client([ + 'api_key' => getenv('SERPAPI_KEY'), + 'engine' => 'google', + 'hl' => 'en', + 'gl' => 'us', +]); + +// q=coffee, hl=en, gl=us +$results = $client->search(['q' => 'coffee']); + +// Per-call parameters take precedence: q=coffee, hl=fr, gl=us +$results = $client->search(['q' => 'coffee', 'hl' => 'fr']); + +// null drops a default for one call: q=coffee, hl=en +$results = $client->search(['q' => 'coffee', 'gl' => null]); +``` + +With positional arguments, pass default search parameters fourth: `new Client($apiKey, 'google', 30, ['hl' => 'en'])`. + +### Persistent connections + +The client keeps its connection to SerpApi open and reuses it, so only the first request pays for the DNS lookup and TLS handshake. The connection closes when the client is destroyed, or explicitly with `close()`: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$client->search(['q' => 'coffee']); +$client->search(['q' => 'tea']); // reuses the connection +$client->close(); +``` + +A closed client reconnects on its next request. To open a new connection for every request instead, pass `'persistent' => false` in the configuration array. [`demo/demo_persistent.php`](demo/demo_persistent.php) compares the two. + +### Debugging + +`inspect()`, `var_dump()`, and `print_r()` mask the API key: + +```php +echo $client->inspect(); +// # +``` + +`var_export()` bypasses the mask and prints the key in clear text. + ## Search API ### Search Google @@ -171,7 +237,7 @@ $results = $client->search([ 'q' => 'coffee', ]); -print_r($results->organic_results); +print_r($results->suggestions); ``` * source: [tests/ExampleSearchGoogleAutocompleteTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAutocompleteTest.php) @@ -187,7 +253,7 @@ $results = $client->search([ 'q' => 'coffee', ]); -print_r($results->organic_results); +print_r($results->shopping_results); ``` * source: [tests/ExampleSearchGoogleShoppingTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleShoppingTest.php) @@ -238,7 +304,7 @@ $results = $client->search([ 'location' => 'Austin, Texas, United States', ]); -print_r($results->organic_results); +print_r($results->events_results); ``` * source: [tests/ExampleSearchGoogleEventsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleEventsTest.php) @@ -296,6 +362,291 @@ print_r($results->local_ads); * source: [tests/ExampleSearchGoogleLocalServicesTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleLocalServicesTest.php) see: [https://serpapi.com/google-local-services-api](https://serpapi.com/google-local-services-api) +### Search Google Images +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_images', + 'q' => 'coffee', +]); + +print_r($results->images_results); +``` + + * source: [tests/ExampleSearchGoogleImagesTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImagesTest.php) +see: [https://serpapi.com/google-images-api](https://serpapi.com/google-images-api) + +### Search Google Images Light +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_images_light', + 'q' => 'Coffee', +]); + +print_r($results->images_results); +``` + + * source: [tests/ExampleSearchGoogleImagesLightTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImagesLightTest.php) +see: [https://serpapi.com/google-images-light-api](https://serpapi.com/google-images-light-api) + +### Search Google News +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_news', + 'q' => 'pizza', + 'gl' => 'us', + 'hl' => 'en', +]); + +print_r($results->news_results); +``` + + * source: [tests/ExampleSearchGoogleNewsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleNewsTest.php) +see: [https://serpapi.com/google-news-api](https://serpapi.com/google-news-api) + +### Search Google News Light +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_news_light', + 'q' => 'pizza', +]); + +print_r($results->news_results); +``` + + * source: [tests/ExampleSearchGoogleNewsLightTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleNewsLightTest.php) +see: [https://serpapi.com/google-news-light-api](https://serpapi.com/google-news-light-api) + +### Search Google Videos +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_videos', + 'q' => 'coffee', +]); + +print_r($results->video_results); +``` + + * source: [tests/ExampleSearchGoogleVideosTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleVideosTest.php) +see: [https://serpapi.com/google-videos-api](https://serpapi.com/google-videos-api) + +### Search Google Light +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_light', + 'q' => 'coffee', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchGoogleLightTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleLightTest.php) +see: [https://serpapi.com/google-light-api](https://serpapi.com/google-light-api) + +### Search Google AI Mode +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_ai_mode', + 'q' => 'best coffee maker', +]); + +print_r($results->reconstructed_markdown); +``` + + * source: [tests/ExampleSearchGoogleAiModeTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAiModeTest.php) +see: [https://serpapi.com/google-ai-mode-api](https://serpapi.com/google-ai-mode-api) + +### Search Google AI Overview + +The AI Overview API takes a `page_token` from a Google search. The token expires a minute after that search, so use it right away: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$search = $client->search(['q' => 'what is coffee']); + +if (isset($search->ai_overview->page_token)) { + $results = $client->search([ + 'engine' => 'google_ai_overview', + 'page_token' => $search->ai_overview->page_token, + ]); + + print_r($results->ai_overview); +} +``` + + * source: [tests/ExampleSearchGoogleAiOverviewTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAiOverviewTest.php) +see: [https://serpapi.com/google-ai-overview-api](https://serpapi.com/google-ai-overview-api) + +### Search Google Product + +The Product API takes a `product_id` from Google Shopping results: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_product', + 'product_id' => $shopping->shopping_results[0]->product_id, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleProductTest.php) +see: [https://serpapi.com/google-product-api](https://serpapi.com/google-product-api) + +### Search Google Immersive Product + +The Immersive Product API takes the `immersive_product_page_token` of a Google Shopping result: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_immersive_product', + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleImmersiveProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImmersiveProductTest.php) +see: [https://serpapi.com/google-immersive-product-api](https://serpapi.com/google-immersive-product-api) + +### Search Google Flights +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_flights', + 'departure_id' => 'LAX', + 'arrival_id' => 'AUS', + 'outbound_date' => date('Y-m-d', strtotime('+30 days')), + 'return_date' => date('Y-m-d', strtotime('+37 days')), +]); + +print_r($results->best_flights); +``` + + * source: [tests/ExampleSearchGoogleFlightsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleFlightsTest.php) +see: [https://serpapi.com/google-flights-api](https://serpapi.com/google-flights-api) + +### Search Google Hotels +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_hotels', + 'q' => 'Bali Resorts', + 'check_in_date' => date('Y-m-d', strtotime('+30 days')), + 'check_out_date' => date('Y-m-d', strtotime('+31 days')), + 'adults' => '2', + 'currency' => 'USD', + 'gl' => 'us', + 'hl' => 'en', +]); + +print_r($results->properties); +``` + + * source: [tests/ExampleSearchGoogleHotelsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleHotelsTest.php) +see: [https://serpapi.com/google-hotels-api](https://serpapi.com/google-hotels-api) + +### Search Google Finance +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_finance', + 'q' => 'GOOG:NASDAQ', +]); + +print_r($results->markets); +``` + + * source: [tests/ExampleSearchGoogleFinanceTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleFinanceTest.php) +see: [https://serpapi.com/google-finance-api](https://serpapi.com/google-finance-api) + +### Search Google Trends +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_trends', + 'q' => 'coffee,milk,bread,pasta,steak', + 'data_type' => 'TIMESERIES', +]); + +print_r($results->interest_over_time); +``` + + * source: [tests/ExampleSearchGoogleTrendsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleTrendsTest.php) +see: [https://serpapi.com/google-trends-api](https://serpapi.com/google-trends-api) + +### Search Google Patents +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_patents', + 'q' => '(Coffee)', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchGooglePatentsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGooglePatentsTest.php) +see: [https://serpapi.com/google-patents-api](https://serpapi.com/google-patents-api) + +### Search Google Reverse Image +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_reverse_image', + 'image_url' => 'https://i.imgur.com/5bGzZi7.jpg', +]); + +print_r($results->image_sizes); +``` + + * source: [tests/ExampleSearchGoogleReverseImageTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleReverseImageTest.php) +see: [https://serpapi.com/google-reverse-image](https://serpapi.com/google-reverse-image) + ### Search Bing ```php use SerpApi\Client; @@ -456,6 +807,55 @@ print_r($results->organic_results); * source: [tests/ExampleSearchDuckduckgoTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchDuckduckgoTest.php) see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo-search-api) +### Search Amazon +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'amazon', + 'k' => 'coffee', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchAmazonTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchAmazonTest.php) +see: [https://serpapi.com/amazon-search-api](https://serpapi.com/amazon-search-api) + +### Search Yandex +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'yandex', + 'text' => 'coffee', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchYandexTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchYandexTest.php) +see: [https://serpapi.com/yandex-search-api](https://serpapi.com/yandex-search-api) + +### Search Yelp +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'yelp', + 'find_desc' => 'Coffee', + 'find_loc' => 'New York, NY, USA', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchYelpTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchYelpTest.php) +see: [https://serpapi.com/yelp-search-api](https://serpapi.com/yelp-search-api) + ## APIs supported ### Location API @@ -561,6 +961,19 @@ make install make test ``` +Run the linter and static analysis: + +```bash +make lint +make analyse +``` + +The `demo/` directory holds runnable scripts, including a batch of async searches and a persistent connection benchmark: + +```bash +make demo +``` + Contributions are welcome. Feel free to submit a pull request! ## Change log diff --git a/README.md.erb b/README.md.erb index 871e39f..192e2cc 100644 --- a/README.md.erb +++ b/README.md.erb @@ -1,5 +1,5 @@ <%- -def snippet(path) +def snippet(path, property = nil) source = File.read(path) params_match = source.match(/\$this->searchParams\s*=\s*(\[.*?\]);/m) @@ -12,7 +12,7 @@ def snippet(path) (i == 0 || i == raw_lines.length - 1) ? l : " #{l}" }.join("\n") - property = source[/assertResponseHasProperty\(\$response,\s*'([^']+)'/, 1] || 'organic_results' + property ||= source[/assertResponseHasProperty\(\s*\$response,\s*'([^']+)'/, 1] || 'organic_results' code = "use SerpApi\\Client;\n\n" code += "$client = new Client(getenv('SERPAPI_KEY'));\n" @@ -92,6 +92,14 @@ Use `search` for structured results decoded into a PHP object: ```php $results = $client->search(['q' => 'coffee']); +echo $results->organic_results[0]->title; +``` + +Use `searchArray` for the same results as an associative array: + +```php +$results = $client->searchArray(['q' => 'coffee']); +echo $results['organic_results'][0]['title']; ``` Use `md` for a token-efficient Markdown string optimized for LLMs and AI agents: @@ -150,6 +158,64 @@ use SerpApi\Client; $client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` +Or for a single request: + +```php +$results = $client->search(['q' => 'coffee', 'timeout' => 10]); +``` + +### Default search parameters + +The constructor also accepts a single array. `api_key`, `engine`, `timeout`, and `persistent` configure the client; every other key becomes a default search parameter sent with every request: + +```php +use SerpApi\Client; + +$client = new Client([ + 'api_key' => getenv('SERPAPI_KEY'), + 'engine' => 'google', + 'hl' => 'en', + 'gl' => 'us', +]); + +// q=coffee, hl=en, gl=us +$results = $client->search(['q' => 'coffee']); + +// Per-call parameters take precedence: q=coffee, hl=fr, gl=us +$results = $client->search(['q' => 'coffee', 'hl' => 'fr']); + +// null drops a default for one call: q=coffee, hl=en +$results = $client->search(['q' => 'coffee', 'gl' => null]); +``` + +With positional arguments, pass default search parameters fourth: `new Client($apiKey, 'google', 30, ['hl' => 'en'])`. + +### Persistent connections + +The client keeps its connection to SerpApi open and reuses it, so only the first request pays for the DNS lookup and TLS handshake. The connection closes when the client is destroyed, or explicitly with `close()`: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$client->search(['q' => 'coffee']); +$client->search(['q' => 'tea']); // reuses the connection +$client->close(); +``` + +A closed client reconnects on its next request. To open a new connection for every request instead, pass `'persistent' => false` in the configuration array. [`demo/demo_persistent.php`](demo/demo_persistent.php) compares the two. + +### Debugging + +`inspect()`, `var_dump()`, and `print_r()` mask the API key: + +```php +echo $client->inspect(); +// # +``` + +`var_export()` bypasses the mask and prints the key in clear text. + ## Search API ### Search Google @@ -192,6 +258,123 @@ see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api) <%= snippet('tests/ExampleSearchGoogleLocalServicesTest.php') %> see: [https://serpapi.com/google-local-services-api](https://serpapi.com/google-local-services-api) +### Search Google Images +<%= snippet('tests/ExampleSearchGoogleImagesTest.php') %> +see: [https://serpapi.com/google-images-api](https://serpapi.com/google-images-api) + +### Search Google Images Light +<%= snippet('tests/ExampleSearchGoogleImagesLightTest.php') %> +see: [https://serpapi.com/google-images-light-api](https://serpapi.com/google-images-light-api) + +### Search Google News +<%= snippet('tests/ExampleSearchGoogleNewsTest.php') %> +see: [https://serpapi.com/google-news-api](https://serpapi.com/google-news-api) + +### Search Google News Light +<%= snippet('tests/ExampleSearchGoogleNewsLightTest.php') %> +see: [https://serpapi.com/google-news-light-api](https://serpapi.com/google-news-light-api) + +### Search Google Videos +<%= snippet('tests/ExampleSearchGoogleVideosTest.php') %> +see: [https://serpapi.com/google-videos-api](https://serpapi.com/google-videos-api) + +### Search Google Light +<%= snippet('tests/ExampleSearchGoogleLightTest.php') %> +see: [https://serpapi.com/google-light-api](https://serpapi.com/google-light-api) + +### Search Google AI Mode +<%= snippet('tests/ExampleSearchGoogleAiModeTest.php') %> +see: [https://serpapi.com/google-ai-mode-api](https://serpapi.com/google-ai-mode-api) + +### Search Google AI Overview + +The AI Overview API takes a `page_token` from a Google search. The token expires a minute after that search, so use it right away: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$search = $client->search(['q' => 'what is coffee']); + +if (isset($search->ai_overview->page_token)) { + $results = $client->search([ + 'engine' => 'google_ai_overview', + 'page_token' => $search->ai_overview->page_token, + ]); + + print_r($results->ai_overview); +} +``` + + * source: [tests/ExampleSearchGoogleAiOverviewTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAiOverviewTest.php) +see: [https://serpapi.com/google-ai-overview-api](https://serpapi.com/google-ai-overview-api) + +### Search Google Product + +The Product API takes a `product_id` from Google Shopping results: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_product', + 'product_id' => $shopping->shopping_results[0]->product_id, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleProductTest.php) +see: [https://serpapi.com/google-product-api](https://serpapi.com/google-product-api) + +### Search Google Immersive Product + +The Immersive Product API takes the `immersive_product_page_token` of a Google Shopping result: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_immersive_product', + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleImmersiveProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImmersiveProductTest.php) +see: [https://serpapi.com/google-immersive-product-api](https://serpapi.com/google-immersive-product-api) + +### Search Google Flights +<%= snippet('tests/ExampleSearchGoogleFlightsTest.php', 'best_flights') %> +see: [https://serpapi.com/google-flights-api](https://serpapi.com/google-flights-api) + +### Search Google Hotels +<%= snippet('tests/ExampleSearchGoogleHotelsTest.php') %> +see: [https://serpapi.com/google-hotels-api](https://serpapi.com/google-hotels-api) + +### Search Google Finance +<%= snippet('tests/ExampleSearchGoogleFinanceTest.php') %> +see: [https://serpapi.com/google-finance-api](https://serpapi.com/google-finance-api) + +### Search Google Trends +<%= snippet('tests/ExampleSearchGoogleTrendsTest.php') %> +see: [https://serpapi.com/google-trends-api](https://serpapi.com/google-trends-api) + +### Search Google Patents +<%= snippet('tests/ExampleSearchGooglePatentsTest.php') %> +see: [https://serpapi.com/google-patents-api](https://serpapi.com/google-patents-api) + +### Search Google Reverse Image +<%= snippet('tests/ExampleSearchGoogleReverseImageTest.php') %> +see: [https://serpapi.com/google-reverse-image](https://serpapi.com/google-reverse-image) + ### Search Bing <%= snippet('tests/ExampleSearchBingTest.php') %> see: [https://serpapi.com/bing-search-api](https://serpapi.com/bing-search-api) @@ -232,6 +415,18 @@ see: [https://serpapi.com/apple-app-store](https://serpapi.com/apple-app-store) <%= snippet('tests/ExampleSearchDuckduckgoTest.php') %> see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo-search-api) +### Search Amazon +<%= snippet('tests/ExampleSearchAmazonTest.php') %> +see: [https://serpapi.com/amazon-search-api](https://serpapi.com/amazon-search-api) + +### Search Yandex +<%= snippet('tests/ExampleSearchYandexTest.php') %> +see: [https://serpapi.com/yandex-search-api](https://serpapi.com/yandex-search-api) + +### Search Yelp +<%= snippet('tests/ExampleSearchYelpTest.php') %> +see: [https://serpapi.com/yelp-search-api](https://serpapi.com/yelp-search-api) + ## APIs supported ### Location API @@ -337,6 +532,19 @@ make install make test ``` +Run the linter and static analysis: + +```bash +make lint +make analyse +``` + +The `demo/` directory holds runnable scripts, including a batch of async searches and a persistent connection benchmark: + +```bash +make demo +``` + Contributions are welcome. Feel free to submit a pull request! ## Change log From 2f1d3dee0f5a7c7965b1e35a50528d2ac883969e Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 21 Oct 2026 14:44:24 +0200 Subject: [PATCH 10/10] Fix engine examples that fail against the live API Running the examples with an API key showed four failures and one skip, all inherited from the serpapi-ruby specs: - google_light_search is not a SerpApi engine ("Unsupported `google_light_search` search engine"); remove the example - google_finance no longer returns `markets` for a ticker; assert `summary`, which it does return - google_reverse_image found nothing for the old sample image; use the one from the current API docs, which returns `image_results` - google_product found nothing for any product_id taken from Google Shopping; take the shopping result's page_token instead, the other documented way to identify a product - google_ai_overview always skipped: Google now shows the overview inline for "what is coffee". Use the query from the API docs, which still returns a page_token --- README.md | 14 ++++----- README.md.erb | 8 ++--- tests/ExampleSearchGoogleAiOverviewTest.php | 4 ++- tests/ExampleSearchGoogleFinanceTest.php | 4 +-- tests/ExampleSearchGoogleLightSearchTest.php | 29 ------------------- tests/ExampleSearchGoogleProductTest.php | 16 +++++----- tests/ExampleSearchGoogleReverseImageTest.php | 6 ++-- 7 files changed, 27 insertions(+), 54 deletions(-) delete mode 100644 tests/ExampleSearchGoogleLightSearchTest.php diff --git a/README.md b/README.md index 44d580d..2903e82 100644 --- a/README.md +++ b/README.md @@ -478,13 +478,13 @@ see: [https://serpapi.com/google-ai-mode-api](https://serpapi.com/google-ai-mode ### Search Google AI Overview -The AI Overview API takes a `page_token` from a Google search. The token expires a minute after that search, so use it right away: +The AI Overview API takes a `page_token` from a Google search. Google shows most AI Overviews inline, so the token is only present for some queries, and it expires a minute after the search: ```php use SerpApi\Client; $client = new Client(getenv('SERPAPI_KEY')); -$search = $client->search(['q' => 'what is coffee']); +$search = $client->search(['q' => 'how does art work in android']); if (isset($search->ai_overview->page_token)) { $results = $client->search([ @@ -501,7 +501,7 @@ see: [https://serpapi.com/google-ai-overview-api](https://serpapi.com/google-ai- ### Search Google Product -The Product API takes a `product_id` from Google Shopping results: +The Product API takes the `immersive_product_page_token` of a Google Shopping result, or a `product_id`: ```php use SerpApi\Client; @@ -511,7 +511,7 @@ $shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker $results = $client->search([ 'engine' => 'google_product', - 'product_id' => $shopping->shopping_results[0]->product_id, + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, ]); print_r($results->product_results); @@ -592,7 +592,7 @@ $results = $client->search([ 'q' => 'GOOG:NASDAQ', ]); -print_r($results->markets); +print_r($results->summary); ``` * source: [tests/ExampleSearchGoogleFinanceTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleFinanceTest.php) @@ -638,10 +638,10 @@ use SerpApi\Client; $client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_reverse_image', - 'image_url' => 'https://i.imgur.com/5bGzZi7.jpg', + 'image_url' => 'https://i.imgur.com/HBrB8p0.png', ]); -print_r($results->image_sizes); +print_r($results->image_results); ``` * source: [tests/ExampleSearchGoogleReverseImageTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleReverseImageTest.php) diff --git a/README.md.erb b/README.md.erb index 192e2cc..0d99cee 100644 --- a/README.md.erb +++ b/README.md.erb @@ -288,13 +288,13 @@ see: [https://serpapi.com/google-ai-mode-api](https://serpapi.com/google-ai-mode ### Search Google AI Overview -The AI Overview API takes a `page_token` from a Google search. The token expires a minute after that search, so use it right away: +The AI Overview API takes a `page_token` from a Google search. Google shows most AI Overviews inline, so the token is only present for some queries, and it expires a minute after the search: ```php use SerpApi\Client; $client = new Client(getenv('SERPAPI_KEY')); -$search = $client->search(['q' => 'what is coffee']); +$search = $client->search(['q' => 'how does art work in android']); if (isset($search->ai_overview->page_token)) { $results = $client->search([ @@ -311,7 +311,7 @@ see: [https://serpapi.com/google-ai-overview-api](https://serpapi.com/google-ai- ### Search Google Product -The Product API takes a `product_id` from Google Shopping results: +The Product API takes the `immersive_product_page_token` of a Google Shopping result, or a `product_id`: ```php use SerpApi\Client; @@ -321,7 +321,7 @@ $shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker $results = $client->search([ 'engine' => 'google_product', - 'product_id' => $shopping->shopping_results[0]->product_id, + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, ]); print_r($results->product_results); diff --git a/tests/ExampleSearchGoogleAiOverviewTest.php b/tests/ExampleSearchGoogleAiOverviewTest.php index ece6b20..277fde2 100644 --- a/tests/ExampleSearchGoogleAiOverviewTest.php +++ b/tests/ExampleSearchGoogleAiOverviewTest.php @@ -9,8 +9,10 @@ public function testResultExists() // A page_token expires a minute after the Google search that returned // it, so fetch a fresh one and bypass the SerpApi cache. $google = $this->serpApiClient('google'); - $search = $google->search(['q' => 'what is coffee', 'no_cache' => true]); + $search = $google->search(['q' => 'how does art work in android', 'no_cache' => true]); + // Google shows most AI Overviews inline. Only some need the extra + // request this engine makes, so skip when none is returned. $pageToken = $search->ai_overview->page_token ?? null; if ($pageToken === null) { $this->markTestSkipped('Google returned no AI Overview page_token for this query'); diff --git a/tests/ExampleSearchGoogleFinanceTest.php b/tests/ExampleSearchGoogleFinanceTest.php index fa7b7bc..72bb74c 100644 --- a/tests/ExampleSearchGoogleFinanceTest.php +++ b/tests/ExampleSearchGoogleFinanceTest.php @@ -22,8 +22,8 @@ public function testResultExists() $response = $client->search($this->searchParams); $this->assertResponseHasProperty( $response, - 'markets', - 'Error on `google_finance` engine: no `markets`' + 'summary', + 'Error on `google_finance` engine: no `summary`' ); } } diff --git a/tests/ExampleSearchGoogleLightSearchTest.php b/tests/ExampleSearchGoogleLightSearchTest.php deleted file mode 100644 index 21e529c..0000000 --- a/tests/ExampleSearchGoogleLightSearchTest.php +++ /dev/null @@ -1,29 +0,0 @@ - */ - private $searchParams; - - protected function setUp(): void - { - parent::setUp(); - $this->searchParams = [ - 'engine' => 'google_light_search', - 'q' => 'coffee', - ]; - } - - public function testResultExists() - { - $client = $this->serpApiClient(); - $response = $client->search($this->searchParams); - $this->assertResponseHasProperty( - $response, - 'organic_results', - 'Error on `google_light_search` engine: no `organic_results`' - ); - } -} diff --git a/tests/ExampleSearchGoogleProductTest.php b/tests/ExampleSearchGoogleProductTest.php index b52ca43..174a4e5 100644 --- a/tests/ExampleSearchGoogleProductTest.php +++ b/tests/ExampleSearchGoogleProductTest.php @@ -6,25 +6,25 @@ class ExampleSearchGoogleProductTest extends SerpApiTestCase { public function testResultExists() { - // Product ids disappear when listings are removed, so take one from - // Google Shopping rather than hardcoding it. + // A page_token identifies a live product offer, so take one from + // Google Shopping rather than hardcoding one that will go stale. $shopping = $this->serpApiClient('google_shopping'); $search = $shopping->search(['q' => 'coffee maker']); - $productId = null; + $pageToken = null; foreach ($search->shopping_results ?? [] as $product) { - if (isset($product->product_id)) { - $productId = $product->product_id; + if (isset($product->immersive_product_page_token)) { + $pageToken = $product->immersive_product_page_token; break; } } - if ($productId === null) { - $this->markTestSkipped('Google Shopping returned no product_id'); + if ($pageToken === null) { + $this->markTestSkipped('Google Shopping returned no immersive_product_page_token'); } $client = $this->serpApiClient('google_product'); - $response = $client->search(['product_id' => $productId]); + $response = $client->search(['page_token' => $pageToken]); $this->assertResponseHasProperty( $response, 'product_results', diff --git a/tests/ExampleSearchGoogleReverseImageTest.php b/tests/ExampleSearchGoogleReverseImageTest.php index a6e72df..9b40d79 100644 --- a/tests/ExampleSearchGoogleReverseImageTest.php +++ b/tests/ExampleSearchGoogleReverseImageTest.php @@ -12,7 +12,7 @@ protected function setUp(): void parent::setUp(); $this->searchParams = [ 'engine' => 'google_reverse_image', - 'image_url' => 'https://i.imgur.com/5bGzZi7.jpg', + 'image_url' => 'https://i.imgur.com/HBrB8p0.png', ]; } @@ -22,8 +22,8 @@ public function testResultExists() $response = $client->search($this->searchParams); $this->assertResponseHasProperty( $response, - 'image_sizes', - 'Error on `google_reverse_image` engine: no `image_sizes`' + 'image_results', + 'Error on `google_reverse_image` engine: no `image_results`' ); } }