Skip to content

Bring the PHP client to parity with serpapi-ruby - #3

Open
jvmvik wants to merge 9 commits into
Initfrom
feature/ruby-parity
Open

Bring the PHP client to parity with serpapi-ruby#3
jvmvik wants to merge 9 commits into
Initfrom
feature/ruby-parity

Conversation

@jvmvik

@jvmvik jvmvik commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Brings the PHP client in line with serpapi-ruby v1.0.3, after a feature-by-feature comparison of the two libraries. One commit per issue, all backwards compatible.

Branched from and targeting Init.

What changed

Commit Gap closed
a1f8242 source reported php; now serpapi-php:<version>, so PHP client versions are distinguishable in usage stats
ced70fa Constructor took only api_key/engine/timeout; now accepts default search params, and a Ruby-style config array
c47220b var_dump($client) printed the API key in clear text; now masked via __debugInfo(), plus inspect()
08848d6 A new cURL handle per request; now one reused handle — 2.6x measured (16.3 → 42.6 req/s)
c529f01 Responses were always stdClass; new assoc option, the counterpart of Ruby's symbolize_names
88f82c7 20 engines covered vs Ruby's 38; adds the missing 19 and drops a duplicate test file
9d44a1c No runnable examples; adds demo/ (basic, autocomplete, async, persistent benchmark) + make demo
6dddb28 Nothing type-checked the code; adds PHPStan level 6 to make analyse and CI
92a4a26 API_KEY renamed to SERPAPI_KEY to match the Ruby client and SerpApi's docs

Verification

  • PHPStan level 6 over src/ and demo/: clean.
  • PHPUnit: 108 tests, 0 failures. Test count went 44 → 108; the new key-free unit coverage is ClientQueryTest, ClientDefaultParamsTest, ClientInspectTest, ClientPersistentTest, ClientAssocTest.
  • Persistent connections and assoc mode were verified live against the locations endpoint, which needs no API key.

For the reviewer

The 19 new engine tests have never been executed — no SerpApi key was available in the environment where this was written. They are syntax-checked and follow the existing verified pattern, but this PR's CI run is their first real execution. Please check that job before merging.

Two of them deliberately differ from their Ruby counterparts:

  • google_flights / google_hotels compute 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 and skips when there is no overview. The Ruby spec pins a token that has expired.

One genuine API change: search() and account() lost their : object return declarations, since they can now return arrays. The union type moved to the docblock. Everything else is additive — the positional constructor still works, and assoc defaults to false so responses stay stdClass. That last choice deliberately does not match Ruby's default, which would have broken every existing caller.

PHPStan caught a real bug in this PR's own demo_async.php: it read ->search_metadata off a value search() can return as an array, which would have been a fatal error rather than a clear message.

Two exclusions, both documented in phpstan.neon:

  • tests/ is out of scope — level 6 wants a return type on every method across 40+ files for little benefit.
  • One property.unusedType exclusion for the cURL handle, genuinely resource on PHP 7 and CurlHandle on PHP 8; PHPStan only analyses one version at a time.

No code style fixer was added. The codebase uses 2-space indentation, so PSR-12 enforcement would rewrite every file — that is a call for the maintainers, not a side effect of this PR.

Not included

The README documents none of the new API. close(), get_params(), assoc, persistent and the array constructor are currently undiscoverable. README.md is generated from README.md.erb via make readme, which needs erb. This was outside the agreed scope; it is the obvious follow-up, along with the missing CHANGELOG.md and CONTRIBUTING.md.

Repository secret: the workflow reads secrets.SERPAPI_KEY || secrets.API_KEY, so CI keeps working either way. Renaming the secret to SERPAPI_KEY lets that fallback be dropped later.

Also worth noting three places where this library is already ahead of Ruby and the behaviour should probably be backported: exception payloads strip api_key, parse errors omit the response body, and search_archive URL-encodes the search ID.

🤖 Generated with Claude Code

jvmvik and others added 9 commits August 10, 2026 07:07
The client sent `source=php`, which made it impossible to tell PHP client
versions apart in SerpApi usage statistics. Report `serpapi-php:<version>`
instead, matching the `serpapi-ruby:<version>` 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant