Add Image API support with uploadImage() by strzibny · Pull Request #11 · serpapi/serpapi-php · GitHub
Skip to content

Add Image API support with uploadImage() - #11

Open
strzibny wants to merge 1 commit into
masterfrom
feature/upload-image
Open

strzibny wants to merge 1 commit into
masterfrom
feature/upload-image

Conversation

@strzibny

Copy link
Copy Markdown
Contributor

Adds Client::uploadImage() for the Image API, matching upload_image in serpapi-ruby (#27) and serpapi-python (#38), and uploadImage in serpapi-javascript (#46).

$client = new Client(getenv('SERPAPI_KEY'));
$upload = $client->uploadImage('path/to/image.jpg');

$results = $client->search([
  'engine' => 'google_lens',
  'image_id' => $upload->image_id,
]);

Behavior

  • Input: a file path or an open stream, such as fopen($path, 'rb') or php://memory.
    • Seekable streams are rewound first, as in Ruby.
    • Streams are uploaded from a temporary copy, because cURL uploads by path and CURLStringFile needs PHP 8.1.
  • Request: a multipart/form-data POST to /image.
    • It carries the same parameters as any other request (api_key, source, default search parameters), the way Ruby's post reuses query(params).
    • The parameters go in the body, so the API key stays out of the URL.
    • Per-call api_key and timeout work as they do for search(), and null drops a default.
  • File name: only the base name is sent. Without one, cURL would send the full local path.
  • Return value: the decoded response as an object (message, image_id).
  • Errors: a 400 such as "Invalid image format…" or a 401 raises SerpApiException with the same context as a search error, without the key or the file.

Fixes needed in the shared cURL handle

  • Method reset: a GET on the persistent handle now sets CURLOPT_HTTPGET. Without it, the search that follows an upload goes out as a multipart POST. I confirmed both before and after the fix against a local echo server.
  • Expect header: uploads send Expect: to drop Expect: 100-continue. libcurl before 7.69 adds that header to bodies over 1 KB, SerpApi doesn't answer it, and each upload stalled for about 1 s. I measured this by forcing the header against the live API.

request() gains an optional $form argument. It is @internal, and the test double RecordingClient was updated to match. The JSON parse error now reads "from url" instead of "on get url", as Ruby's does and as the HTTP error already did.

Tests

  • ClientUploadImageTest (offline) covers:
    • path upload: endpoint, form fields, base name and a clean URL;
    • stream upload: rewind, temp copy deleted, caller's stream left open;
    • the same parameters as a search, including defaults and booleans;
    • per-call api_key, timeout and null;
    • API errors, missing key, missing file and invalid argument.
  • ClientIntegrationTest::testUploadImage (live) uploads tests/fixtures/mona-lisa.jpg (a 27 KB public domain thumbnail from Wikimedia Commons). It then runs a Google Lens search for visual_matches on the same persistent connection.

Locally on PHP 8.5:

  • the full suite passes offline: 116 tests;
  • ClientIntegrationTest and the Google Lens example pass live;
  • phpcs and PHPStan level 6 are clean.

A live stream upload also succeeded, and an invalid file returns the API's error message.

Follow-ups (not in this PR)

  • Version bump: bump VERSION to 1.1.0 with a change log entry that includes this method. This replaces Bump version to 1.1.0 #6, which targets the merged feature/ruby-parity branch and lists Markdown as new, although Markdown is already in 1.0.0.
  • release.yml: it passes API_KEY, but the tests read SERPAPI_KEY since chore: rename API_KEY env var to SERPAPI_KEY #4, so the v1.0.0 release ran with every live test skipped.

Uploads a JPG, PNG, or WebP image to POST /image as multipart/form-data
and returns the decoded response, whose image_id can be passed to
Google Lens and other engines that accept uploaded images. Mirrors
upload_image in serpapi-ruby and serpapi-python:

- accepts a file path or a stream; seekable streams are rewound first,
  as Ruby does, and uploaded from a temporary copy because cURL uploads
  by path and CURLStringFile needs PHP 8.1
- sends the same parameters as any other request (api_key, source,
  default search parameters) as form fields, so the key stays out of
  the URL; per-call api_key and timeout work as for search()
- sends only the file's base name; without one, cURL would send the
  full local path
- errors raise SerpApiException with the same context as searches

The persistent cURL handle now resets the method on every GET.
Without CURLOPT_HTTPGET, a search after an upload went out as a
multipart POST. Uploads also drop the Expect: 100-continue header:
libcurl before 7.69 sends it with bodies over 1 KB, SerpApi does not
answer it, and each upload stalled for a second.

The live integration test uploads a 27 KB public domain thumbnail of
the Mona Lisa from Wikimedia Commons and searches it with Google Lens.
@strzibny
strzibny requested review from galetahub and jvmvik October 21, 2026 13:49

@galetahub galetahub left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @strzibny! 👍

Just a couple of minor comments below.

Comment thread src/Client.php
Comment on lines +380 to +382

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An empty or already-consumed stream (e.g. a pipe that was read before) passes this check: stream_copy_to_stream returns 0, not false.

Suggested change
if (stream_copy_to_stream($stream, $copy) === false || !fflush($copy)) {
fclose($copy);
throw new SerpApiException('Failed to read the image stream');
$copied = stream_copy_to_stream($stream, $copy);
if ($copied === false || $copied === 0 || !fflush($copy)) {
fclose($copy);
throw new SerpApiException('Failed to read the image stream');

Comment thread src/Client.php
{
$fields = [];

foreach (explode('&', http_build_query($query)) as $pair) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: http_build_query uses the arg_separator.output ini setting. Passing '&' explicitly removes the php.ini dependency.

Suggested change

This branch has not been deployed

No deployments
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