From a1f82423c7d42d45a9d145628a0a9a1bc77f3d41 Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:07:09 -0500 Subject: [PATCH 1/9] 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 string assembly moves into a private `query()` method so it can be covered without issuing an HTTP request. Co-Authored-By: Claude Opus 5 --- src/Client.php | 37 +++++++++++++++++-------- tests/ClientQueryTest.php | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 tests/ClientQueryTest.php diff --git a/src/Client.php b/src/Client.php index 243f6ef..3460941 100644 --- a/src/Client.php +++ b/src/Client.php @@ -7,6 +7,9 @@ class Client { const BASE_URL = 'https://serpapi.com'; const DEFAULT_TIMEOUT = 120; + /** Client identifier reported to SerpApi for usage statistics. */ + const SOURCE = 'serpapi-php:' . self::VERSION; + /** @var string */ private $api_key; @@ -137,17 +140,7 @@ private function get(string $endpoint, string $format = 'json', array $params = throw new SerpApiException('api_key must be present'); } - $default_query = [ - 'engine' => $this->engine, - 'source' => 'php', - ]; - - if (!empty($api_key)) { - $default_query['api_key'] = $api_key; - } - - $query = array_merge($default_query, $params); - $query['output'] = $format; + $query = $this->query($params, $api_key, $format); $url = self::BASE_URL . $endpoint . '?' . http_build_query($query); @@ -189,6 +182,28 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raise_http_error($http_code, $endpoint, $query, $serpapi_error, $search_id, 'json'); } + /** + * Build the query string parameters for a request. + * + * @param array $params + * @return array + */ + private function query(array $params, string $api_key, string $format): array { + $default_query = [ + 'engine' => $this->engine, + 'source' => self::SOURCE, + ]; + + if (!empty($api_key)) { + $default_query['api_key'] = $api_key; + } + + $query = array_merge($default_query, $params); + $query['output'] = $format; + + return $query; + } + /** * @return array{response: string|false, http_code: int, curl_error: string} * @throws SerpApiException diff --git a/tests/ClientQueryTest.php b/tests/ClientQueryTest.php new file mode 100644 index 0000000..9aac708 --- /dev/null +++ b/tests/ClientQueryTest.php @@ -0,0 +1,58 @@ + $params + * @return array + */ + protected function query(Client $client, array $params = [], string $api_key = 'secret', string $format = 'json'): array { + $method = new \ReflectionMethod(Client::class, 'query'); + + // Required before PHP 8.1, and a no-op deprecated in PHP 8.5. + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + return $method->invoke($client, $params, $api_key, $format); + } + + public function test_source_reports_library_name_and_version() { + $query = $this->query(new Client('secret')); + $this->assertEquals('serpapi-php:' . Client::VERSION, $query['source']); + } + + public function test_source_constant_matches_version() { + $this->assertStringStartsWith('serpapi-php:', Client::SOURCE); + $this->assertStringEndsWith(Client::VERSION, Client::SOURCE); + } + + public function test_query_includes_engine_api_key_and_output() { + $query = $this->query(new Client('secret', 'bing'), ['q' => 'coffee']); + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('secret', $query['api_key']); + $this->assertEquals('coffee', $query['q']); + $this->assertEquals('json', $query['output']); + } + + public function test_query_omits_api_key_when_empty() { + $query = $this->query(new Client(), [], ''); + $this->assertArrayNotHasKey('api_key', $query); + } + + public function test_output_reflects_requested_format() { + $query = $this->query(new Client('secret'), [], 'secret', 'html'); + $this->assertEquals('html', $query['output']); + } +} From ced70fa27655ef127a5baaf623845a24754ac5dd Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:08:06 -0500 Subject: [PATCH 2/9] 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. Per-call parameters still take precedence, and null values are dropped from the query rather than sent empty. Expose the effective defaults via get_params(), alongside a get_timeout() accessor. The existing positional signature is unchanged. Co-Authored-By: Claude Opus 5 --- src/Client.php | 95 +++++++++++++++++++++++++++++-- tests/ClientDefaultParamsTest.php | 95 +++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 tests/ClientDefaultParamsTest.php diff --git a/src/Client.php b/src/Client.php index 3460941..a2384f3 100644 --- a/src/Client.php +++ b/src/Client.php @@ -19,20 +19,80 @@ class Client { /** @var int */ private $timeout; + /** @var array Search parameters applied to every request */ + private $params = []; + /** - * @param string $api_key + * Client-only configuration keys, never forwarded to the API as search parameters. + * + * @var array + */ + private static $option_keys = ['api_key', 'engine', 'timeout']; + + /** + * Accepts either positional arguments or, like the Ruby client, a single + * associative array holding both configuration and default search parameters: + * + * new Client(['api_key' => '...', 'engine' => 'google', 'hl' => 'en']) + * + * Any key that is not `api_key`, `engine` or `timeout` becomes a default + * search parameter merged into every request, and can still be overridden + * per call. + * + * @param string|array $api_key API key, or a full configuration array * @param string $engine * @param int $timeout Request timeout in seconds + * @param array $params Default search parameters * @throws SerpApiException */ - public function __construct(string $api_key = '', string $engine = 'google', int $timeout = self::DEFAULT_TIMEOUT) { + public function __construct($api_key = '', string $engine = 'google', int $timeout = self::DEFAULT_TIMEOUT, array $params = []) { + if (is_array($api_key)) { + $config = $api_key; + $api_key = (string) $this->take($config, 'api_key', ''); + $engine = (string) $this->take($config, 'engine', $engine); + $timeout = (int) $this->take($config, 'timeout', $timeout); + $params = array_merge($config, $params); + } + if (empty($engine)) { throw new SerpApiException('engine must be present'); } - $this->api_key = $api_key; + $this->api_key = (string) $api_key; $this->engine = $engine; $this->timeout = $timeout; + $this->params = $this->without_option_keys($params); + } + + /** + * Pull a value out of a configuration array, removing it in the process. + * + * @param array $config + * @param mixed $default + * @return mixed + */ + private function take(array &$config, string $key, $default) { + if (!array_key_exists($key, $config) || $config[$key] === null) { + unset($config[$key]); + return $default; + } + + $value = $config[$key]; + unset($config[$key]); + + return $value; + } + + /** + * @param array $params + * @return array + */ + private function without_option_keys(array $params): array { + foreach (self::$option_keys as $key) { + unset($params[$key]); + } + + return $params; } /** @@ -63,6 +123,29 @@ public function get_engine(): string { return $this->engine; } + /** + * Get the request timeout in seconds. + */ + public function get_timeout(): int { + return $this->timeout; + } + + /** + * Get the default search parameters applied to every request, + * including `engine` and `api_key`. + * + * @return array + */ + public function get_params(): array { + $params = ['engine' => $this->engine]; + + if (!empty($this->api_key)) { + $params['api_key'] = $this->api_key; + } + + return array_merge($params, $this->params); + } + /** * Run a search and return decoded JSON. * @@ -198,10 +281,12 @@ private function query(array $params, string $api_key, string $format): array { $default_query['api_key'] = $api_key; } - $query = array_merge($default_query, $params); + $query = array_merge($default_query, $this->params, $params); $query['output'] = $format; - return $query; + return array_filter($query, static function ($value) { + return $value !== null; + }); } /** diff --git a/tests/ClientDefaultParamsTest.php b/tests/ClientDefaultParamsTest.php new file mode 100644 index 0000000..25b308d --- /dev/null +++ b/tests/ClientDefaultParamsTest.php @@ -0,0 +1,95 @@ + 'en', 'gl' => 'us']); + $query = $this->query($client, ['q' => 'coffee']); + + $this->assertEquals('en', $query['hl']); + $this->assertEquals('us', $query['gl']); + $this->assertEquals('coffee', $query['q']); + } + + public function test_per_search_params_override_defaults() { + $client = new Client('secret', 'google', 120, ['hl' => 'en']); + $query = $this->query($client, ['hl' => 'fr']); + + $this->assertEquals('fr', $query['hl']); + } + + public function test_null_params_are_dropped_from_the_query() { + $client = new Client('secret', 'google', 120, ['hl' => 'en']); + $query = $this->query($client, ['hl' => null]); + + $this->assertArrayNotHasKey('hl', $query); + } + + public function test_array_constructor_sets_configuration() { + $client = new Client([ + 'api_key' => 'secret', + 'engine' => 'bing', + 'timeout' => 30, + ]); + + $this->assertEquals('secret', $client->get_api_key()); + $this->assertEquals('bing', $client->get_engine()); + $this->assertEquals(30, $client->get_timeout()); + } + + public function test_array_constructor_treats_unknown_keys_as_default_params() { + $client = new Client([ + 'api_key' => 'secret', + 'engine' => 'google', + 'location' => 'Austin, TX', + 'no_cache' => true, + ]); + + $query = $this->query($client, ['q' => 'coffee']); + $this->assertEquals('Austin, TX', $query['location']); + $this->assertTrue($query['no_cache']); + } + + public function test_array_constructor_defaults_engine_to_google() { + $client = new Client(['api_key' => 'secret']); + $this->assertEquals('google', $client->get_engine()); + } + + public function test_array_constructor_rejects_empty_engine() { + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('engine must be present'); + new Client(['api_key' => 'secret', 'engine' => '']); + } + + public function test_positional_constructor_remains_supported() { + $client = new Client('secret', 'bing', 30); + + $this->assertEquals('secret', $client->get_api_key()); + $this->assertEquals('bing', $client->get_engine()); + $this->assertEquals(30, $client->get_timeout()); + } + + public function test_get_params_exposes_engine_api_key_and_defaults() { + $client = new Client(['api_key' => 'secret', 'engine' => 'bing', 'hl' => 'en']); + $params = $client->get_params(); + + $this->assertEquals('bing', $params['engine']); + $this->assertEquals('secret', $params['api_key']); + $this->assertEquals('en', $params['hl']); + } + + public function test_get_params_omits_client_only_options() { + $client = new Client(['api_key' => 'secret', 'timeout' => 30]); + + $this->assertArrayNotHasKey('timeout', $client->get_params()); + $this->assertArrayNotHasKey('timeout', $this->query($client)); + } +} From c47220bf02194c3ba083e6f04a07a7a415e1a2f3 Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:08:45 -0500 Subject: [PATCH 3/9] Mask the API key in client debug output var_dump() on a client printed the api_key property 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 and debuggers, plus an inspect() method for parity. Keys of 8 characters or fewer are replaced entirely rather than partially revealed. print_r() and var_export() read properties directly and cannot be hooked; this is called out in the docblock. Co-Authored-By: Claude Opus 5 --- src/Client.php | 47 +++++++++++++++++++++++++ tests/ClientInspectTest.php | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 tests/ClientInspectTest.php diff --git a/src/Client.php b/src/Client.php index a2384f3..7a749bb 100644 --- a/src/Client.php +++ b/src/Client.php @@ -146,6 +146,53 @@ public function get_params(): array { return array_merge($params, $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->masked_api_key() + ); + } + + /** + * Keeps `var_dump()` and debuggers from printing the API key in clear text. + * + * Note that `print_r()` and `var_export()` bypass this hook and read + * properties directly; use `inspect()` when dumping a client on purpose. + * + * @return array + */ + public function __debugInfo(): array { + return [ + 'engine' => $this->engine, + 'timeout' => $this->timeout, + 'api_key' => $this->masked_api_key(), + 'params' => $this->params, + ]; + } + + /** + * Show only the first and last 4 characters of the API key. + */ + private function masked_api_key(): string { + $length = strlen($this->api_key); + + if ($length === 0) { + return ''; + } + + if ($length <= 8) { + return '****'; + } + + return substr($this->api_key, 0, 4) . '****' . substr($this->api_key, -4); + } + /** * Run a search and return decoded JSON. * diff --git a/tests/ClientInspectTest.php b/tests/ClientInspectTest.php new file mode 100644 index 0000000..daa2eb0 --- /dev/null +++ b/tests/ClientInspectTest.php @@ -0,0 +1,68 @@ +assertStringNotContainsString(self::LONG_KEY, $client->inspect()); + } + + public function test_inspect_shows_first_and_last_four_characters() { + $client = new Client(self::LONG_KEY); + $this->assertStringContainsString('abcd****wxyz', $client->inspect()); + } + + public function test_inspect_reports_engine_and_timeout() { + $client = new Client(self::LONG_KEY, 'bing', 30); + $inspect = $client->inspect(); + + $this->assertStringContainsString('@engine=bing', $inspect); + $this->assertStringContainsString('@timeout=30', $inspect); + } + + public function test_short_api_key_is_fully_masked() { + $client = new Client('abcdef'); + $inspect = $client->inspect(); + + $this->assertStringNotContainsString('abcdef', $inspect); + $this->assertStringContainsString('****', $inspect); + } + + public function test_eight_character_api_key_is_fully_masked() { + $client = new Client('abcdefgh'); + $this->assertStringNotContainsString('abcdefgh', $client->inspect()); + } + + public function test_missing_api_key_renders_as_empty() { + $client = new Client(); + $this->assertStringContainsString('@api_key=>', $client->inspect()); + } + + public function test_var_dump_does_not_expose_the_api_key() { + $client = new Client(self::LONG_KEY); + + ob_start(); + var_dump($client); + $dump = (string) ob_get_clean(); + + $this->assertStringNotContainsString(self::LONG_KEY, $dump); + $this->assertStringContainsString('abcd****wxyz', $dump); + } + + public function test_debug_info_does_not_expose_the_api_key_in_default_params() { + $client = new Client(['api_key' => self::LONG_KEY, 'hl' => 'en']); + $info = $client->__debugInfo(); + + $this->assertEquals('abcd****wxyz', $info['api_key']); + $this->assertEquals(['hl' => 'en'], $info['params']); + } +} From 08848d64e981799b0ae58695e2c54e52c08bff7e Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:22:29 -0500 Subject: [PATCH 4/9] 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, follow-up requests drop from ~110ms to ~22ms. 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. Co-Authored-By: Claude Opus 5 --- src/Client.php | 98 ++++++++++++++++++++++++---- tests/ClientPersistentTest.php | 116 +++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 tests/ClientPersistentTest.php diff --git a/src/Client.php b/src/Client.php index 7a749bb..9c62f3a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -22,12 +22,18 @@ class Client { /** @var array Search parameters applied to every request */ private $params = []; + /** @var bool Reuse a single cURL handle, keeping the connection alive between requests */ + private $persistent = true; + + /** @var resource|\CurlHandle|null Shared cURL handle used in persistent mode */ + private $handle = null; + /** * Client-only configuration keys, never forwarded to the API as search parameters. * * @var array */ - private static $option_keys = ['api_key', 'engine', 'timeout']; + private static $option_keys = ['api_key', 'engine', 'timeout', 'persistent']; /** * Accepts either positional arguments or, like the Ruby client, a single @@ -51,6 +57,7 @@ public function __construct($api_key = '', string $engine = 'google', int $timeo $api_key = (string) $this->take($config, 'api_key', ''); $engine = (string) $this->take($config, 'engine', $engine); $timeout = (int) $this->take($config, 'timeout', $timeout); + $this->persistent = (bool) $this->take($config, 'persistent', true); $params = array_merge($config, $params); } @@ -146,15 +153,43 @@ public function get_params(): array { return array_merge($params, $this->params); } + /** + * Whether the client reuses a single connection across requests. + */ + public function is_persistent(): bool { + return $this->persistent; + } + + /** + * Close the shared connection. Safe to call more than once; the client + * stays usable and opens a new connection on the next request. + */ + public function close(): void { + if ($this->handle === null) { + return; + } + + if (PHP_VERSION_ID < 80500) { + curl_close($this->handle); + } + + $this->handle = null; + } + + public function __destruct() { + $this->close(); + } + /** * 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->masked_api_key() ); } @@ -169,10 +204,11 @@ public function inspect(): string { */ public function __debugInfo(): array { return [ - 'engine' => $this->engine, - 'timeout' => $this->timeout, - 'api_key' => $this->masked_api_key(), - 'params' => $this->params, + 'engine' => $this->engine, + 'timeout' => $this->timeout, + 'persistent' => $this->persistent, + 'api_key' => $this->masked_api_key(), + 'params' => $this->params, ]; } @@ -341,10 +377,7 @@ private function query(array $params, string $api_key, string $format): array { * @throws SerpApiException */ private function request(string $url): array { - $ch = curl_init(); - if ($ch === false) { - throw new SerpApiException('Failed to initialize cURL handle'); - } + $ch = $this->acquire_handle(); try { $is_configured = curl_setopt_array($ch, [ @@ -369,11 +402,48 @@ private function request(string $url): array { 'curl_error' => $curl_error, ]; } finally { - if (PHP_VERSION_ID < 80500) { - curl_close($ch); - } + $this->release_handle($ch); + } + } + + /** + * Return the shared handle in persistent mode, otherwise a fresh one. + * + * @return resource|\CurlHandle + * @throws SerpApiException + */ + private function acquire_handle() { + if ($this->persistent && $this->handle !== null) { + return $this->handle; + } + + $ch = curl_init(); + if ($ch === false) { + throw new SerpApiException('Failed to initialize cURL handle'); + } + + if ($this->persistent) { + $this->handle = $ch; + } + + return $ch; + } + + /** + * Keep the shared handle open so the underlying connection is reused; + * dispose of single use handles. + * + * @param resource|\CurlHandle $ch + */ + private function release_handle($ch): void { + if ($this->persistent && $ch === $this->handle) { + return; + } - $ch = null; + // curl_close() is a no-op since PHP 8.0 and deprecated in 8.5; + // the handle is released by the garbage collector instead. + if (PHP_VERSION_ID < 80500) { + curl_close($ch); } } diff --git a/tests/ClientPersistentTest.php b/tests/ClientPersistentTest.php new file mode 100644 index 0000000..797b1e4 --- /dev/null +++ b/tests/ClientPersistentTest.php @@ -0,0 +1,116 @@ +setAccessible(true); + } + + return $method->invoke($client); + } + + /** + * @return resource|\CurlHandle|null + */ + private function handle(Client $client) { + $property = new \ReflectionProperty(Client::class, 'handle'); + + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + + return $property->getValue($client); + } + + public function test_persistent_is_enabled_by_default() { + $this->assertTrue((new Client('secret'))->is_persistent()); + } + + public function test_persistent_can_be_disabled() { + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + $this->assertFalse($client->is_persistent()); + } + + public function test_persistent_is_not_sent_as_a_search_parameter() { + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + $this->assertArrayNotHasKey('persistent', $client->get_params()); + } + + public function test_no_connection_is_opened_before_the_first_request() { + $this->assertNull($this->handle(new Client('secret'))); + } + + public function test_persistent_client_reuses_the_same_handle() { + $client = new Client('secret'); + + $first = $this->acquire($client); + $second = $this->acquire($client); + + $this->assertSame($first, $second); + $this->assertSame($first, $this->handle($client)); + } + + public function test_non_persistent_client_uses_a_fresh_handle_each_time() { + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + + $first = $this->acquire($client); + $second = $this->acquire($client); + + $this->assertNotSame($first, $second); + $this->assertNull($this->handle($client), 'non persistent handles must not be retained'); + } + + public function test_close_releases_the_shared_handle() { + $client = new Client('secret'); + $this->acquire($client); + + $client->close(); + + $this->assertNull($this->handle($client)); + } + + public function test_close_is_idempotent() { + $client = new Client('secret'); + $this->acquire($client); + + $client->close(); + $client->close(); + + $this->assertNull($this->handle($client)); + } + + public function test_client_reconnects_after_close() { + $client = new Client('secret'); + $first = $this->acquire($client); + $client->close(); + + $second = $this->acquire($client); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $this->handle($client)); + } + + public function test_inspect_reports_persistent_mode() { + $this->assertStringContainsString('@persistent=true', (new Client('secret'))->inspect()); + + $client = new Client(['api_key' => 'secret', 'persistent' => false]); + $this->assertStringContainsString('@persistent=false', $client->inspect()); + } +} From c529f0117dc5d151185b8c4b11a9a2b7f557308a Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:24:12 -0500 Subject: [PATCH 5/9] Allow decoding responses to associative arrays Responses were always decoded to stdClass, with no way to get arrays. The Ruby client exposes symbolize_names for the equivalent choice. Add an `assoc` option, settable on the client or per call. Error and search_id extraction now reads either shape through a small dig() helper, so exceptions carry the same context in both modes. The default stays stdClass to keep existing code working, which is why this is opt-in rather than mirroring Ruby's default. search() and account() lose their `object` return type declaration since they can now return an array; the docblocks carry the union type instead. Co-Authored-By: Claude Opus 5 --- src/Client.php | 66 +++++++++++++++++++++++++++++----- tests/ClientAssocTest.php | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 tests/ClientAssocTest.php diff --git a/src/Client.php b/src/Client.php index 9c62f3a..d41ac95 100644 --- a/src/Client.php +++ b/src/Client.php @@ -28,12 +28,22 @@ class Client { /** @var resource|\CurlHandle|null Shared cURL handle used in persistent mode */ private $handle = null; + /** @var bool Decode JSON responses to associative arrays instead of stdClass */ + private $assoc = false; + /** * Client-only configuration keys, never forwarded to the API as search parameters. * * @var array */ - private static $option_keys = ['api_key', 'engine', 'timeout', 'persistent']; + private static $option_keys = ['api_key', 'engine', 'timeout', 'persistent', 'assoc']; + + /** + * Client-only keys stripped from the query string of every request. + * + * @var array + */ + private static $client_only_keys = ['timeout', 'persistent', 'assoc']; /** * Accepts either positional arguments or, like the Ruby client, a single @@ -58,6 +68,7 @@ public function __construct($api_key = '', string $engine = 'google', int $timeo $engine = (string) $this->take($config, 'engine', $engine); $timeout = (int) $this->take($config, 'timeout', $timeout); $this->persistent = (bool) $this->take($config, 'persistent', true); + $this->assoc = (bool) $this->take($config, 'assoc', false); $params = array_merge($config, $params); } @@ -160,6 +171,13 @@ public function is_persistent(): bool { return $this->persistent; } + /** + * Whether JSON responses are decoded to associative arrays instead of stdClass. + */ + public function is_assoc(): bool { + return $this->assoc; + } + /** * Close the shared connection. Safe to call more than once; the client * stays usable and opens a new connection on the next request. @@ -207,6 +225,7 @@ public function __debugInfo(): array { 'engine' => $this->engine, 'timeout' => $this->timeout, 'persistent' => $this->persistent, + 'assoc' => $this->assoc, 'api_key' => $this->masked_api_key(), 'params' => $this->params, ]; @@ -233,9 +252,10 @@ private function masked_api_key(): string { * Run a search and return decoded JSON. * * @param array $params + * @return object|array stdClass, or an array when `assoc` is enabled * @throws SerpApiException */ - public function search(array $params = []): object { + public function search(array $params = []) { return $this->get('/search', 'json', $params); } @@ -252,9 +272,10 @@ public function html(array $params = []): string { /** * Get account information using Account API. * + * @return object|array stdClass, or an array when `assoc` is enabled * @throws SerpApiException */ - public function account(?string $api_key = null): object { + public function account(?string $api_key = null) { $params = empty($api_key) ? [] : ['api_key' => $api_key]; return $this->get('/account', 'json', $params); } @@ -263,7 +284,7 @@ public function account(?string $api_key = null): object { * Get locations using Location API. * * @param array $params - * @return array + * @return array> * @throws SerpApiException */ public function location(array $params = []): array { @@ -327,15 +348,19 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raise_http_error($http_code, $endpoint, $query, null, null, 'html'); } - $decoded = json_decode($response); + $assoc = isset($params['assoc']) ? (bool) $params['assoc'] : $this->assoc; + + $decoded = json_decode($response, $assoc); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { $this->raise_parser_error($http_code, $endpoint, $query); } - $serpapi_error = (is_object($decoded) && isset($decoded->error)) ? $decoded->error : null; - $search_id = (is_object($decoded) && isset($decoded->search_metadata->id)) - ? (string) $decoded->search_metadata->id - : null; + $error = $this->dig($decoded, 'error'); + $serpapi_error = is_string($error) ? $error : null; + + $metadata = $this->dig($decoded, 'search_metadata'); + $id = $this->dig($metadata, 'id'); + $search_id = ($id === null) ? null : (string) $id; if ($http_code === 200) { if ($serpapi_error !== null) { @@ -348,6 +373,25 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raise_http_error($http_code, $endpoint, $query, $serpapi_error, $search_id, 'json'); } + /** + * Read a key from a decoded response, which is an object or an + * associative array depending on the `assoc` setting. + * + * @param mixed $data + * @return mixed null when absent + */ + private function dig($data, string $key) { + if (is_object($data)) { + return $data->{$key} ?? null; + } + + if (is_array($data)) { + return $data[$key] ?? null; + } + + return null; + } + /** * Build the query string parameters for a request. * @@ -367,6 +411,10 @@ private function query(array $params, string $api_key, string $format): array { $query = array_merge($default_query, $this->params, $params); $query['output'] = $format; + foreach (self::$client_only_keys as $key) { + unset($query[$key]); + } + return array_filter($query, static function ($value) { return $value !== null; }); diff --git a/tests/ClientAssocTest.php b/tests/ClientAssocTest.php new file mode 100644 index 0000000..8d32dbc --- /dev/null +++ b/tests/ClientAssocTest.php @@ -0,0 +1,75 @@ +assertFalse((new Client('secret'))->is_assoc()); + } + + public function test_assoc_can_be_enabled() { + $client = new Client(['api_key' => 'secret', 'assoc' => true]); + $this->assertTrue($client->is_assoc()); + } + + public function test_assoc_is_not_sent_as_a_search_parameter() { + $client = new Client(['api_key' => 'secret', 'assoc' => true]); + + $this->assertArrayNotHasKey('assoc', $client->get_params()); + $this->assertArrayNotHasKey('assoc', $this->query($client)); + } + + public function test_per_call_assoc_is_not_sent_as_a_search_parameter() { + $query = $this->query(new Client('secret'), ['q' => 'coffee', 'assoc' => true]); + + $this->assertArrayNotHasKey('assoc', $query); + $this->assertEquals('coffee', $query['q']); + } + + public function test_client_only_keys_never_reach_the_query() { + $query = $this->query(new Client('secret'), [ + 'q' => 'coffee', + 'timeout' => 5, + 'persistent' => false, + 'assoc' => true, + ]); + + $this->assertEquals(['engine', 'source', 'api_key', 'q', 'output'], array_keys($query)); + } + + public function test_dig_reads_both_object_and_array_shapes() { + $client = new Client('secret'); + + foreach ([json_decode('{"error":"boom"}'), json_decode('{"error":"boom"}', true)] as $data) { + $this->assertEquals('boom', $this->dig($client, $data, 'error')); + $this->assertNull($this->dig($client, $data, 'missing')); + } + } + + public function test_dig_tolerates_scalar_and_null_responses() { + $client = new Client('secret'); + + $this->assertNull($this->dig($client, null, 'error')); + $this->assertNull($this->dig($client, 'a string', 'error')); + } + + /** + * @param mixed $data + * @return mixed + */ + private function dig(Client $client, $data, string $key) { + $method = new \ReflectionMethod(Client::class, 'dig'); + + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + return $method->invoke($client, $data, $key); + } +} From 88f82c730a538049b9475e2b59264a6673d52e88 Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:25:45 -0500 Subject: [PATCH 6/9] Cover the remaining search engines and drop a duplicate test The suite covered 20 engines against the Ruby client's 38. Add examples for amazon, yelp, yandex, google_news, google_news_light, google_images, google_images_light, google_light, google_light_search, google_finance, google_flights, google_hotels, google_patents, google_product, google_reverse_image, google_trends, google_videos, google_immersive_product and google_ai_overview. Two of these are written differently from their Ruby counterparts: - google_flights and google_hotels compute check-in and departure dates relative to today. The Ruby specs hardcode 2025 dates that have since passed. - google_ai_overview reads page_token from a live Google search instead of a hardcoded token, and skips when Google returns no overview. The Ruby spec pins an expired token. ClientIntegrationTest duplicated GoogleSearchTest assertion for assertion, so it is removed. These require an API key to run and were not executed here; only the key-free unit tests were. Co-Authored-By: Claude Opus 5 --- tests/ClientIntegrationTest.php | 51 ------------------- tests/ExampleSearchAmazonTest.php | 22 ++++++++ tests/ExampleSearchGoogleAiOverviewTest.php | 21 ++++++++ tests/ExampleSearchGoogleFinanceTest.php | 22 ++++++++ tests/ExampleSearchGoogleFlightsTest.php | 29 +++++++++++ tests/ExampleSearchGoogleHotelsTest.php | 30 +++++++++++ tests/ExampleSearchGoogleImagesLightTest.php | 22 ++++++++ tests/ExampleSearchGoogleImagesTest.php | 23 +++++++++ ...xampleSearchGoogleImmersiveProductTest.php | 22 ++++++++ tests/ExampleSearchGoogleLightSearchTest.php | 22 ++++++++ tests/ExampleSearchGoogleLightTest.php | 22 ++++++++ tests/ExampleSearchGoogleNewsLightTest.php | 22 ++++++++ tests/ExampleSearchGoogleNewsTest.php | 24 +++++++++ tests/ExampleSearchGooglePatentsTest.php | 22 ++++++++ tests/ExampleSearchGoogleProductTest.php | 23 +++++++++ tests/ExampleSearchGoogleReverseImageTest.php | 22 ++++++++ tests/ExampleSearchGoogleTrendsTest.php | 23 +++++++++ tests/ExampleSearchGoogleVideosTest.php | 22 ++++++++ tests/ExampleSearchYandexTest.php | 22 ++++++++ tests/ExampleSearchYelpTest.php | 23 +++++++++ 20 files changed, 438 insertions(+), 51 deletions(-) delete mode 100644 tests/ClientIntegrationTest.php create mode 100644 tests/ExampleSearchAmazonTest.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/ClientIntegrationTest.php b/tests/ClientIntegrationTest.php deleted file mode 100644 index 775b16e..0000000 --- a/tests/ClientIntegrationTest.php +++ /dev/null @@ -1,51 +0,0 @@ - */ - private $search_params; - - protected function setUp(): void { - parent::setUp(); - $this->search_params = [ - 'q' => 'Coffee', - 'location' => 'Austin,Texas', - ]; - } - - public function test_account() { - $client = $this->serpApiClient(); - $response = $client->account(); - $this->assertEquals($client->get_api_key(), $response->api_key); - } - - public function test_html() { - $client = $this->serpApiClient(); - $response = $client->html($this->search_params); - $this->assertGreaterThan(10000, strlen($response)); - } - - public function test_search() { - $client = $this->serpApiClient(); - $response = $client->search($this->search_params); - $this->assertEquals('Success', $response->search_metadata->status); - $this->assertResponseHasProperty($response, 'organic_results'); - $this->assertNotEmpty($response->organic_results); - } - - public function test_location() { - $client = $this->serpApiClient(); - $location_list = $client->location(['q' => 'Austin', 'limit' => 3]); - $this->assertCount(3, $location_list); - $this->assertStringContainsString('Austin', $location_list[0]->name); - $this->assertGreaterThan(0, $location_list[0]->google_id); - } - - public function test_search_archive() { - $client = $this->serpApiClient(); - $result = $client->search($this->search_params); - $archived_result = $client->search_archive($result->search_metadata->id); - $this->assertEquals($result->search_metadata->id, $archived_result->search_metadata->id); - } -} diff --git a/tests/ExampleSearchAmazonTest.php b/tests/ExampleSearchAmazonTest.php new file mode 100644 index 0000000..786f763 --- /dev/null +++ b/tests/ExampleSearchAmazonTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'amazon', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `amazon` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchGoogleAiOverviewTest.php b/tests/ExampleSearchGoogleAiOverviewTest.php new file mode 100644 index 0000000..2e1ffce --- /dev/null +++ b/tests/ExampleSearchGoogleAiOverviewTest.php @@ -0,0 +1,21 @@ +serpApiClient('google'); + $search = $google->search(['q' => 'what is coffee']); + + $page_token = $search->ai_overview->page_token ?? null; + if ($page_token === null) { + $this->markTestSkipped('Google returned no AI overview page_token for this query'); + } + + $client = $this->serpApiClient('google_ai_overview'); + $response = $client->search(['page_token' => $page_token]); + $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..f361137 --- /dev/null +++ b/tests/ExampleSearchGoogleFinanceTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_finance', + 'q' => 'GOOG:NASDAQ', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..a4a3083 --- /dev/null +++ b/tests/ExampleSearchGoogleFlightsTest.php @@ -0,0 +1,29 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + + // Dates are computed rather than hardcoded so the test does not expire. + $this->search_params = [ + 'engine' => 'google_flights', + 'departure_id' => 'PEK', + 'arrival_id' => 'AUS', + 'outbound_date' => date('Y-m-d', strtotime('+30 days')), + 'return_date' => date('Y-m-d', strtotime('+37 days')), + 'currency' => 'USD', + 'hl' => 'en', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'best_flights', 'Error on `google_flights` engine: no `best_flights`'); + } +} diff --git a/tests/ExampleSearchGoogleHotelsTest.php b/tests/ExampleSearchGoogleHotelsTest.php new file mode 100644 index 0000000..12b89ca --- /dev/null +++ b/tests/ExampleSearchGoogleHotelsTest.php @@ -0,0 +1,30 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + + // Dates are computed rather than hardcoded so the test does not expire. + $this->search_params = [ + '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 test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..4458c16 --- /dev/null +++ b/tests/ExampleSearchGoogleImagesLightTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_images_light', + 'q' => 'Coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..3fcd841 --- /dev/null +++ b/tests/ExampleSearchGoogleImagesTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_images', + 'tbm' => 'isch', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..1bdca4f --- /dev/null +++ b/tests/ExampleSearchGoogleImmersiveProductTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_immersive_product', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'immersive_product_results', 'Error on `google_immersive_product` engine: no `immersive_product_results`'); + } +} diff --git a/tests/ExampleSearchGoogleLightSearchTest.php b/tests/ExampleSearchGoogleLightSearchTest.php new file mode 100644 index 0000000..8bf4512 --- /dev/null +++ b/tests/ExampleSearchGoogleLightSearchTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_light_search', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..2fa94e7 --- /dev/null +++ b/tests/ExampleSearchGoogleLightTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_light', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..0bc685e --- /dev/null +++ b/tests/ExampleSearchGoogleNewsLightTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_news_light', + 'q' => 'pizza', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..1ac0c96 --- /dev/null +++ b/tests/ExampleSearchGoogleNewsTest.php @@ -0,0 +1,24 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_news', + 'q' => 'pizza', + 'gl' => 'us', + 'hl' => 'en', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..494600a --- /dev/null +++ b/tests/ExampleSearchGooglePatentsTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_patents', + 'q' => '(Coffee)', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..2f26813 --- /dev/null +++ b/tests/ExampleSearchGoogleProductTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_product', + 'q' => 'coffee', + 'product_id' => '4887235756540435899', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..1864dcc --- /dev/null +++ b/tests/ExampleSearchGoogleReverseImageTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_reverse_image', + 'image_url' => 'https://i.imgur.com/5bGzZi7.jpg', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..32a366d --- /dev/null +++ b/tests/ExampleSearchGoogleTrendsTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_trends', + 'q' => 'coffee,milk,bread,pasta,steak', + 'data_type' => 'TIMESERIES', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..ad9e72b --- /dev/null +++ b/tests/ExampleSearchGoogleVideosTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'google_videos', + 'q' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `google_videos` engine: no `organic_results`'); + } +} diff --git a/tests/ExampleSearchYandexTest.php b/tests/ExampleSearchYandexTest.php new file mode 100644 index 0000000..8007ba4 --- /dev/null +++ b/tests/ExampleSearchYandexTest.php @@ -0,0 +1,22 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'yandex', + 'text' => 'coffee', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $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..e0522ee --- /dev/null +++ b/tests/ExampleSearchYelpTest.php @@ -0,0 +1,23 @@ + */ + private $search_params; + + protected function setUp(): void { + parent::setUp(); + $this->search_params = [ + 'engine' => 'yelp', + 'find_desc' => 'Coffee', + 'find_loc' => 'New York, NY, USA', + ]; + } + + public function test_result_exists() { + $client = $this->serpApiClient(); + $response = $client->search($this->search_params); + $this->assertResponseHasProperty($response, 'organic_results', 'Error on `yelp` engine: no `organic_results`'); + } +} From 9d44a1cede550118b0e5ce2c8b4d90375358483e Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:26:55 -0500 Subject: [PATCH 7/9] Add runnable demo scripts The Ruby client ships a demo directory used as out-of-box testing; the PHP client had no runnable examples outside the test suite. Add four scripts and a `make demo` target: demo.php basic search demo_suggest.php autocomplete, showing client-level default parameters demo_async.php non-blocking batch submission collected via the Search Archive API demo_persistent.php connection reuse benchmark, needs no API key demo_persistent.php measures a 2.6x speedup locally, in line with the Ruby client's reported figure. Co-Authored-By: Claude Opus 5 --- Makefile | 7 ++++ demo/demo.php | 43 +++++++++++++++++++++++ demo/demo_async.php | 73 ++++++++++++++++++++++++++++++++++++++++ demo/demo_persistent.php | 42 +++++++++++++++++++++++ demo/demo_suggest.php | 46 +++++++++++++++++++++++++ 5 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 23d9c31..9066c6a 100644 --- a/Makefile +++ b/Makefile @@ -16,3 +16,10 @@ test: # Generate README from ERB template readme: erb -T '-' README.md.erb > README.md + +# Run the demo scripts end to end +demo: + @for file in demo/*.php; do \ + echo "running demo: $$file"; \ + php $$file || exit 1; \ + done diff --git a/demo/demo.php b/demo/demo.php new file mode 100644 index 0000000..1e1c9d5 --- /dev/null +++ b/demo/demo.php @@ -0,0 +1,43 @@ + 'google', + 'api_key' => $api_key, +]); + +$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"; +exit(0); diff --git a/demo/demo_async.php b/demo/demo_async.php new file mode 100644 index 0000000..94b71e7 --- /dev/null +++ b/demo/demo_async.php @@ -0,0 +1,73 @@ + true` the backend accepts the search and returns + * immediately instead of waiting for the search engine. Submitting a batch + * first and collecting the results afterwards through the Search Archive API + * is much faster than running the searches one after another. + * + * Usage: + * export API_KEY="your secret key" + * php demo/demo_async.php + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use SerpApi\Client; + +$api_key = getenv('API_KEY'); +if (empty($api_key)) { + fwrite(STDERR, "API_KEY environment variable must be set\n"); + exit(1); +} + +$companies = ['meta', 'amazon', 'apple', 'netflix', 'google']; + +// Persistent mode keeps a single connection open for the whole batch. +$client = new Client([ + 'engine' => 'google', + 'api_key' => $api_key, + 'async' => true, + 'persistent' => true, +]); + +// Submit every search without waiting for its results. +$pending = []; +foreach ($companies as $company) { + $result = $client->search(['q' => $company]); + $pending[$result->search_metadata->id] = $company; + echo "submitted: {$company}\n"; +} + +echo "\ncollecting ", count($pending), " results\n"; + +// Collect the results, putting back the searches still in progress. +$deadline = time() + 60; +while (!empty($pending) && time() < $deadline) { + foreach ($pending as $search_id => $company) { + $archived = $client->search_archive($search_id); + $status = $archived->search_metadata->status; + + if ($status === 'Success' || $status === 'Cached') { + $count = isset($archived->organic_results) ? count($archived->organic_results) : 0; + printf(" %-8s %s (%d organic results)\n", $company, $status, $count); + unset($pending[$search_id]); + } + } + + if (!empty($pending)) { + sleep(1); + } +} + +$client->close(); + +if (!empty($pending)) { + fwrite(STDERR, 'timed out waiting for: ' . implode(', ', $pending) . "\n"); + exit(1); +} + +echo "done\n"; +exit(0); diff --git a/demo/demo_persistent.php b/demo/demo_persistent.php new file mode 100644 index 0000000..05b1431 --- /dev/null +++ b/demo/demo_persistent.php @@ -0,0 +1,42 @@ +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"; +exit(0); diff --git a/demo/demo_suggest.php b/demo/demo_suggest.php new file mode 100644 index 0000000..d2393bd --- /dev/null +++ b/demo/demo_suggest.php @@ -0,0 +1,46 @@ + 'google_autocomplete', + 'api_key' => $api_key, + 'client' => 'safari', + 'hl' => 'en', + 'gl' => 'us', + 'persistent' => false, + 'timeout' => 10, +]); + +$results = $client->search(['q' => 'coffee']); + +if (empty($results->suggestions)) { + fwrite(STDERR, "no suggestions found\n"); + exit(1); +} + +foreach ($results->suggestions as $suggestion) { + echo ' - ', $suggestion->value, "\n"; +} + +echo "done\n"; +exit(0); From 6dddb284e87456bf1505089f1e8bfd790a778399 Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Mon, 10 Aug 2026 07:30:02 -0500 Subject: [PATCH 8/9] Run PHPStan in CI CI ran the test suite but nothing checked types, despite the codebase already carrying full @param and @return annotations. The Ruby client gates its build on rubocop. Add PHPStan at level 6 over src/ and demo/, wired into `make analyse`, `composer run-script analyse` and the workflow. Analysis is skipped on the 7.2 and 7.3 matrix entries, which predate PHPStan 2. It caught one real problem: demo_async.php read ->search_metadata off a value that search() can also return as an array, which would have surfaced as a fatal error rather than a clear message. The demo now validates the response shape first. Test files are left out. Level 6 wants a return type on every test method, which is churn across 40 files for little benefit. The cURL handle type is excluded, with a comment: it is a resource on PHP 7 and a CurlHandle on PHP 8, and PHPStan only ever sees one version. No code style fixer is added; the codebase uses 2-space indentation, so PSR-12 enforcement would rewrite every file. Co-Authored-By: Claude Opus 5 --- .github/workflows/serpapi-php.yml | 5 +++++ Makefile | 6 +++++- composer.json | 6 ++++-- demo/demo_async.php | 24 ++++++++++++++++++++---- phpstan.neon | 17 +++++++++++++++++ src/Client.php | 2 +- 6 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 phpstan.neon diff --git a/.github/workflows/serpapi-php.yml b/.github/workflows/serpapi-php.yml index 5db9789..b549e60 100644 --- a/.github/workflows/serpapi-php.yml +++ b/.github/workflows/serpapi-php.yml @@ -41,6 +41,11 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress + - name: Run static analysis + # PHPStan needs PHP 7.4+; the library itself still supports 7.2. + if: matrix.php-versions >= '7.4' + run: composer run-script analyse + - name: Run test suite run: composer run-script test env: diff --git a/Makefile b/Makefile index 9066c6a..12c9530 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Default target -all: install readme test +all: install readme analyse test # Clean up the project clean: @@ -9,6 +9,10 @@ clean: install: composer install --prefer-dist --no-progress +# 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 c815858..8f5b1d8 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,8 @@ "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^8.5.52 || ^9.6 || ^10.5 || ^11.5 || ^12.5 || ^13.0" + "phpunit/phpunit": "^8.5.52 || ^9.6 || ^10.5 || ^11.5 || ^12.5 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.1" }, "autoload": { "psr-4": { @@ -58,6 +59,7 @@ } }, "scripts": { - "test": "vendor/bin/phpunit -c phpunit.xml" + "test": "vendor/bin/phpunit -c phpunit.xml", + "analyse": "vendor/bin/phpstan analyse --no-progress --memory-limit=512M" } } diff --git a/demo/demo_async.php b/demo/demo_async.php index 94b71e7..ee5cfd8 100644 --- a/demo/demo_async.php +++ b/demo/demo_async.php @@ -17,6 +17,20 @@ use SerpApi\Client; +/** + * Read search_metadata off a decoded response, failing loudly on anything + * unexpected rather than on a property access further down. + * + * @param object|array|string $response + */ +function search_metadata($response): object { + if (!is_object($response) || !isset($response->search_metadata)) { + throw new RuntimeException('response carries no search_metadata'); + } + + return $response->search_metadata; +} + $api_key = getenv('API_KEY'); if (empty($api_key)) { fwrite(STDERR, "API_KEY environment variable must be set\n"); @@ -37,7 +51,7 @@ $pending = []; foreach ($companies as $company) { $result = $client->search(['q' => $company]); - $pending[$result->search_metadata->id] = $company; + $pending[search_metadata($result)->id] = $company; echo "submitted: {$company}\n"; } @@ -47,11 +61,13 @@ $deadline = time() + 60; while (!empty($pending) && time() < $deadline) { foreach ($pending as $search_id => $company) { - $archived = $client->search_archive($search_id); - $status = $archived->search_metadata->status; + $archived = $client->search_archive((string) $search_id); + $status = search_metadata($archived)->status; if ($status === 'Success' || $status === 'Cached') { - $count = isset($archived->organic_results) ? count($archived->organic_results) : 0; + $count = is_object($archived) && isset($archived->organic_results) + ? count((array) $archived->organic_results) + : 0; printf(" %-8s %s (%d organic results)\n", $company, $status, $count); unset($pending[$search_id]); } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3a67f13 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,17 @@ +parameters: + level: 6 + paths: + - src + - demo + + # composer.json promises PHP 7.2 through 8.5. Analysis runs against 8.x, + # where curl_init() returns a CurlHandle. The 7.x resource form of the same + # handle is exercised by the CI matrix instead. + phpVersion: 80100 + + ignoreErrors: + # The cURL handle is a resource on PHP 7 and a CurlHandle on PHP 8. Both + # arms of the union are reachable across the supported range, but PHPStan + # analyses a single version and so only ever sees one of them assigned. + - identifier: property.unusedType + path: src/Client.php diff --git a/src/Client.php b/src/Client.php index d41ac95..b33278e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -25,7 +25,7 @@ class Client { /** @var bool Reuse a single cURL handle, keeping the connection alive between requests */ private $persistent = true; - /** @var resource|\CurlHandle|null Shared cURL handle used in persistent mode */ + /** @var resource|\CurlHandle|null Shared cURL handle (a resource on PHP 7, a CurlHandle on PHP 8) */ private $handle = null; /** @var bool Decode JSON responses to associative arrays instead of stdClass */ From 92a4a262cc451ebdb73364e5560c66837d82a107 Mon Sep 17 00:00:00 2001 From: Victor Benarbia Date: Fri, 14 Aug 2026 08:44:16 -0500 Subject: [PATCH 9/9] Rename API_KEY to SERPAPI_KEY The test suite, demos and README used API_KEY, while the Ruby client and SerpApi's own documentation use SERPAPI_KEY. Anyone working across both libraries had to keep two names for the same secret. SERPAPI_KEY is now the documented name everywhere. The test helper still falls back to API_KEY so existing local setups keep working, and the workflow reads `secrets.SERPAPI_KEY || secrets.API_KEY` so CI does not break before the repository secret is renamed. Co-Authored-By: Claude Opus 5 --- .github/workflows/serpapi-php.yml | 2 +- README.md | 56 +++++++++++++++---------------- README.md.erb | 18 +++++----- demo/demo.php | 6 ++-- demo/demo_async.php | 6 ++-- demo/demo_suggest.php | 6 ++-- tests/SerpApiTestCase.php | 27 ++++++++++----- 7 files changed, 65 insertions(+), 56 deletions(-) diff --git a/.github/workflows/serpapi-php.yml b/.github/workflows/serpapi-php.yml index b549e60..97aee6d 100644 --- a/.github/workflows/serpapi-php.yml +++ b/.github/workflows/serpapi-php.yml @@ -49,4 +49,4 @@ jobs: - name: Run test suite run: composer run-script test env: - API_KEY: ${{ secrets.API_KEY }} + SERPAPI_KEY: ${{ secrets.SERPAPI_KEY || secrets.API_KEY }} diff --git a/README.md b/README.md index bce33cc..8d9dd04 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ require 'vendor/autoload.php'; use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'coffee', ]); @@ -86,7 +86,7 @@ The default engine is `google`. You can change it via the second constructor par ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'bing'); +$client = new Client(getenv('SERPAPI_KEY'), 'bing'); $results = $client->search(['q' => 'coffee']); ``` @@ -97,7 +97,7 @@ The default request timeout is 120 seconds. Customize it via the third construct ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'google', 30); +$client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` ## Search API @@ -106,7 +106,7 @@ $client = new Client(getenv('API_KEY'), 'google', 30); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google', 'tbm' => 'isch', @@ -123,7 +123,7 @@ see: [https://serpapi.com/search-api](https://serpapi.com/search-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_scholar', 'q' => 'coffee', @@ -139,7 +139,7 @@ see: [https://serpapi.com/google-scholar-api](https://serpapi.com/google-scholar ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_autocomplete', 'q' => 'coffee', @@ -155,7 +155,7 @@ see: [https://serpapi.com/google-autocomplete-api](https://serpapi.com/google-au ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_shopping', 'q' => 'coffee', @@ -171,7 +171,7 @@ see: [https://serpapi.com/google-shopping-api](https://serpapi.com/google-shoppi ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_maps', 'q' => 'pizza', @@ -189,7 +189,7 @@ see: [https://serpapi.com/google-maps-api](https://serpapi.com/google-maps-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_jobs', 'q' => 'coffee', @@ -205,7 +205,7 @@ see: [https://serpapi.com/google-jobs-api](https://serpapi.com/google-jobs-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_events', 'q' => 'Events in Austin', @@ -222,7 +222,7 @@ see: [https://serpapi.com/google-events-api](https://serpapi.com/google-events-a ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_lens', 'url' => 'https://i.imgur.com/5bGzZi7.jpg', @@ -240,7 +240,7 @@ see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_play', 'q' => 'kite', @@ -257,7 +257,7 @@ see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'google_local_services', 'q' => 'electrician', @@ -274,7 +274,7 @@ see: [https://serpapi.com/google-local-services-api](https://serpapi.com/google- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'bing', 'q' => 'coffee', @@ -290,7 +290,7 @@ see: [https://serpapi.com/bing-search-api](https://serpapi.com/bing-search-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'baidu', 'q' => 'coffee', @@ -306,7 +306,7 @@ see: [https://serpapi.com/baidu-search-api](https://serpapi.com/baidu-search-api ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'yahoo', 'p' => 'coffee', @@ -322,7 +322,7 @@ see: [https://serpapi.com/yahoo-search-api](https://serpapi.com/yahoo-search-api ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'youtube', 'search_query' => 'coffee', @@ -338,7 +338,7 @@ see: [https://serpapi.com/youtube-search-api](https://serpapi.com/youtube-search ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'walmart', 'query' => 'coffee', @@ -354,7 +354,7 @@ see: [https://serpapi.com/walmart-search-api](https://serpapi.com/walmart-search ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'ebay', '_nkw' => 'water', @@ -370,7 +370,7 @@ see: [https://serpapi.com/ebay-search-api](https://serpapi.com/ebay-search-api) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'naver', 'query' => 'coffee', @@ -386,7 +386,7 @@ see: [https://serpapi.com/naver-search-api](https://serpapi.com/naver-search-api ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'home_depot', 'q' => 'table', @@ -402,7 +402,7 @@ see: [https://serpapi.com/home-depot-search-api](https://serpapi.com/home-depot- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'apple_app_store', 'term' => 'coffee', @@ -418,7 +418,7 @@ see: [https://serpapi.com/apple-app-store](https://serpapi.com/apple-app-store) ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'engine' => 'duckduckgo', 'q' => 'coffee', @@ -437,7 +437,7 @@ see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $locations = $client->location(['q' => 'Austin', 'limit' => 3]); echo "Number of locations: " . count($locations) . "\n"; @@ -453,7 +453,7 @@ First, run a search and save the search ID: ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'Coffee', 'location' => 'Austin, Texas', @@ -473,7 +473,7 @@ print_r($archived); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $account = $client->account(); print_r($account); ``` @@ -483,7 +483,7 @@ print_r($account); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $html = $client->html(['q' => 'Coffee']); echo strlen($html) . " bytes of HTML\n"; @@ -522,7 +522,7 @@ The `tests/` directory includes specifications which serve the dual purposes of Set your secret API key in your shell before running tests: ```bash -export API_KEY="your_secret_key" +export SERPAPI_KEY="your_secret_key" ``` Install dependencies and run the test suite: diff --git a/README.md.erb b/README.md.erb index 727b642..834c3b5 100644 --- a/README.md.erb +++ b/README.md.erb @@ -15,7 +15,7 @@ def snippet(path) property = source[/assertResponseHasProperty\(\$response,\s*'([^']+)'/, 1] || 'organic_results' code = "use SerpApi\\Client;\n\n" - code += "$client = new Client(getenv('API_KEY'));\n" + code += "$client = new Client(getenv('SERPAPI_KEY'));\n" code += "$results = $client->search(#{params});\n\n" code += "print_r($results->#{property});\n" @@ -72,7 +72,7 @@ require 'vendor/autoload.php'; use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'coffee', ]); @@ -110,7 +110,7 @@ The default engine is `google`. You can change it via the second constructor par ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'bing'); +$client = new Client(getenv('SERPAPI_KEY'), 'bing'); $results = $client->search(['q' => 'coffee']); ``` @@ -121,7 +121,7 @@ The default request timeout is 120 seconds. Customize it via the third construct ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY'), 'google', 30); +$client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` ## Search API @@ -213,7 +213,7 @@ see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo- ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $locations = $client->location(['q' => 'Austin', 'limit' => 3]); echo "Number of locations: " . count($locations) . "\n"; @@ -229,7 +229,7 @@ First, run a search and save the search ID: ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $results = $client->search([ 'q' => 'Coffee', 'location' => 'Austin, Texas', @@ -249,7 +249,7 @@ print_r($archived); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $account = $client->account(); print_r($account); ``` @@ -259,7 +259,7 @@ print_r($account); ```php use SerpApi\Client; -$client = new Client(getenv('API_KEY')); +$client = new Client(getenv('SERPAPI_KEY')); $html = $client->html(['q' => 'Coffee']); echo strlen($html) . " bytes of HTML\n"; @@ -298,7 +298,7 @@ The `tests/` directory includes specifications which serve the dual purposes of Set your secret API key in your shell before running tests: ```bash -export API_KEY="your_secret_key" +export SERPAPI_KEY="your_secret_key" ``` Install dependencies and run the test suite: diff --git a/demo/demo.php b/demo/demo.php index 1e1c9d5..bb0175f 100644 --- a/demo/demo.php +++ b/demo/demo.php @@ -5,7 +5,7 @@ * * Prerequisites: * - composer install - * - export API_KEY="your secret key" (get one at https://serpapi.com/dashboard) + * - export SERPAPI_KEY="your secret key" (get one at https://serpapi.com/dashboard) * * Usage: * php demo/demo.php @@ -15,9 +15,9 @@ use SerpApi\Client; -$api_key = getenv('API_KEY'); +$api_key = getenv('SERPAPI_KEY'); if (empty($api_key)) { - fwrite(STDERR, "API_KEY environment variable must be set\n"); + fwrite(STDERR, "SERPAPI_KEY environment variable must be set\n"); exit(1); } diff --git a/demo/demo_async.php b/demo/demo_async.php index ee5cfd8..eeffb1e 100644 --- a/demo/demo_async.php +++ b/demo/demo_async.php @@ -9,7 +9,7 @@ * is much faster than running the searches one after another. * * Usage: - * export API_KEY="your secret key" + * export SERPAPI_KEY="your secret key" * php demo/demo_async.php */ @@ -31,9 +31,9 @@ function search_metadata($response): object { return $response->search_metadata; } -$api_key = getenv('API_KEY'); +$api_key = getenv('SERPAPI_KEY'); if (empty($api_key)) { - fwrite(STDERR, "API_KEY environment variable must be set\n"); + fwrite(STDERR, "SERPAPI_KEY environment variable must be set\n"); exit(1); } diff --git a/demo/demo_suggest.php b/demo/demo_suggest.php index d2393bd..078731b 100644 --- a/demo/demo_suggest.php +++ b/demo/demo_suggest.php @@ -7,7 +7,7 @@ * and reused by every call, so only the query changes per search. * * Usage: - * export API_KEY="your secret key" + * export SERPAPI_KEY="your secret key" * php demo/demo_suggest.php */ @@ -15,9 +15,9 @@ use SerpApi\Client; -$api_key = getenv('API_KEY'); +$api_key = getenv('SERPAPI_KEY'); if (empty($api_key)) { - fwrite(STDERR, "API_KEY environment variable must be set\n"); + fwrite(STDERR, "SERPAPI_KEY environment variable must be set\n"); exit(1); } diff --git a/tests/SerpApiTestCase.php b/tests/SerpApiTestCase.php index 7a390e7..1ac4371 100644 --- a/tests/SerpApiTestCase.php +++ b/tests/SerpApiTestCase.php @@ -14,7 +14,7 @@ protected function setUp(): void { $resolved = $this->resolveApiKey(); if ($resolved === null && $this->requiresApiKey()) { - $this->markTestSkipped('API_KEY is not set'); + $this->markTestSkipped('SERPAPI_KEY is not set'); return; } @@ -25,16 +25,25 @@ protected function requiresApiKey(): bool { return true; } - protected function resolveApiKey(): ?string { - $env = $_ENV['API_KEY'] ?? null; + /** + * Look up the secret key, preferring SERPAPI_KEY. API_KEY is the previous + * name, still accepted so existing setups keep working. + * + * @var array + */ + protected static $api_key_env_names = ['SERPAPI_KEY', 'API_KEY']; - if (!empty($env)) { - return $env; - } + protected function resolveApiKey(): ?string { + foreach (self::$api_key_env_names as $name) { + $env = $_ENV[$name] ?? null; + if (!empty($env)) { + return $env; + } - $value = getenv('API_KEY'); - if (!empty($value)) { - return $value; + $value = getenv($name); + if (!empty($value)) { + return $value; + } } return null;