Skip to content

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

Merged
strzibny merged 10 commits into
masterfrom
feature/ruby-parity
Oct 21, 2026
Merged

strzibny merged 10 commits into
masterfrom
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

This was referenced Aug 16, 2026
strzibny and others added 9 commits October 21, 2026 14:00
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 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 <victor@serpapi.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.
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 <victor@serpapi.com>
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 <victor@serpapi.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, 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 <victor@serpapi.com>
search() keeps returning stdClass. Error and search id extraction now
reads both decoded shapes.

Co-authored-by: Victor Benarbia <victor@serpapi.com>
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 <victor@serpapi.com>
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 <victor@serpapi.com>
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 <victor@serpapi.com>
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 <victor@serpapi.com>
@strzibny
strzibny force-pushed the feature/ruby-parity branch from 92a4a26 to e057e0d Compare October 21, 2026 12:20
@strzibny
strzibny changed the base branch from Init to master October 21, 2026 12:20
@strzibny

strzibny commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

@jvmvik I rebased this onto master and reworked it to match master's conventions, so it can go in without waiting on another round. Your original branch is preserved at 92a4a26, and every commit credits you as co-author.

Aligned with master

  • camelCase methods and properties, PSR-12 with 2-space indentation, testXxx test names
  • Kept master's md(), SERPAPI_KEY, lint job and release workflow, and ClientIntegrationTest, which now covers md()
  • Replaced the assoc option with a separate searchArray(), so search() and account() keep their object return types

Fixes

  • A per-call timeout now applies to that request; it was dropped from the query but never used. A per-call persistent throws instead of being silently ignored
  • Clones open their own connection, and the cURL handle is left out of serialization (serializing a CurlHandle throws on PHP 8)
  • Amazon searches with k, not q
  • Google Videos asserts video_results, which is what the engine returns
  • google_immersive_product and google_product take a page_token and product_id from a live Google Shopping search instead of hardcoded values
  • AI Overview fetches its page_token with no_cache, since the token expires a minute after the search
  • The async demo stops on Success (there is no Cached status), fails fast on errors, and passes async per call so it isn't also sent with every Search Archive request
  • make demo did nothing: make treated the demo/ directory as an up-to-date target. It's .PHONY now
  • The persistent benchmark warms up both clients before timing
  • PHPStan runs once in the lint job. The >= '7.4' matrix check compared strings and ran it six times, and the ignore rule and phpVersion pin turned out to be unnecessary

Additions

  • google_ai_mode example, matching the Ruby specs
  • README snippets now handle assertions split over several lines, which fixes the Shopping, Autocomplete and Events examples on master

VERSION stays at 1.0.0; the bump is in #6.

PHPUnit and PHPCS pass at every commit, and PHPStan level 6 is clean.

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
@strzibny
strzibny merged commit 07c1dcb into master Oct 21, 2026
10 checks passed
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.

2 participants