Scraping Central is reader-supported. When you buy through links on our site, we may earn an affiliate commission.

3.35intermediate5 min read

Hands-On: SERP API in PHP, Composer, SDK, Real Queries

The PHP version of the SERP-API walkthrough. Composer, Guzzle, dotenv, SQLite, real queries, same shape, PHP idioms.

What you’ll learn

  • Make your first SERP-API call in PHP via Guzzle.
  • Structure a SerpClient class with retries.
  • Normalize responses to a stable internal shape.
  • Persist to SQLite via PDO.

The PHP counterpart to lesson 3.34. Same architecture: client + normalizer + storage + runner. Different idioms.

Step 1, set up

mkdir serp-php && cd serp-php
composer init --no-interaction --name=you/serp-runner
composer require guzzlehttp/guzzle vlucas/phpdotenv

Create .env:

SERP_API_KEY=your_key_here

Step 2, the client

<?php
// src/SerpClient.php
declare(strict_types=1);
namespace App;

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\RequestInterface;

class SerpClient {
  public const API_URL = 'https://api.example-serp.com/search';

  private Client $http;

  public function __construct(private string $apiKey) {
  $stack = HandlerStack::create();
  $stack->push(Middleware::retry(
  fn($r, RequestInterface $req, ?ResponseInterface $res, ?\Throwable $e) =>
  $r < 5 && ($e || ($res && in_array($res->getStatusCode(), [429, 500, 502, 503, 504]))),
  fn($r) => (int)(min(30, pow(2, $r)) * 1000 * mt_rand(0, 1000) / 1000)
  ));
  $this->http = new Client([
  'handler' => $stack,
  'timeout' => 30,
  ]);
  }

  public function search(string $q, array $params = []): array {
  $merged = array_merge([
  'q'  => $q,
  'api_key'  => $this->apiKey,
  'engine'  => 'google',
  'gl'  => 'us',
  'hl'  => 'en',
  ], $params);
  $res = $this->http->get(self::API_URL, ['query' => $merged]);
  return json_decode($res->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
  }
}

Step 3, the normalizer

<?php
// src/Normalizer.php
declare(strict_types=1);
namespace App;

class Normalizer {
  public static function normalize(array $data): array {
  return [
  'organic' => array_map(fn($r) => [
  'position' => $r['position'] ?? null,
  'title'  => $r['title'] ?? null,
  'url'  => $r['link'] ?? null,
  'snippet'  => $r['snippet'] ?? null,
  'domain'  => self::domain($r['link'] ?? ''),
  ], $data['organic_results'] ?? []),
  'ads' => array_map(fn($a) => [
  'position' => $a['position'] ?? null,
  'title'  => $a['title'] ?? null,
  'url'  => $a['link'] ?? null,
  'block'  => $a['block_position'] ?? null,
  ], $data['ads'] ?? []),
  'ai_overview' => self::ai($data['ai_overview'] ?? null),
  'knowledge_graph' => self::kg($data['knowledge_graph'] ?? null),
  'local_pack' => array_map(fn($p) => [
  'place_id' => $p['place_id'] ?? null,
  'name'  => $p['title'] ?? null,
  'rating'  => $p['rating'] ?? null,
  'phone'  => $p['phone'] ?? null,
  'address'  => $p['address'] ?? null,
  ], $data['local_results']['places'] ?? []),
  ];
  }

  private static function domain(string $url): string {
  return strtolower(parse_url($url, PHP_URL_HOST) ?? '');
  }

  private static function ai(?array $ao): ?array {
  if (!$ao) return null;
  return [
  'text' => $ao['text'] ?? null,
  'sources' => array_map(
  fn($s, $i) => [
  'domain' => self::domain($s['link'] ?? ''),
  'rank'  => $i + 1,
  'url'  => $s['link'] ?? null,
  ],
  $ao['sources'] ?? [],
  array_keys($ao['sources'] ?? [])
  ),
  ];
  }

  private static function kg(?array $kg): ?array {
  if (!$kg) return null;
  return [
  'title' => $kg['title'] ?? null,
  'type'  => $kg['type'] ?? null,
  'description' => $kg['description'] ?? null,
  ];
  }
}

Step 4, storage (SQLite via PDO)

<?php
// src/Storage.php
declare(strict_types=1);
namespace App;

class Storage {
  private \PDO $db;

  public function __construct(string $path = __DIR__ . '/../serp.db') {
  $this->db = new \PDO("sqlite:$path");
  $this->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
  $this->init();
  }

  private function init(): void {
  $this->db->exec("
  CREATE TABLE IF NOT EXISTS serp (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  query TEXT NOT NULL,
  gl TEXT NOT NULL,
  hl TEXT NOT NULL,
  device TEXT NOT NULL,
  collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  raw_json TEXT NOT NULL,
  normalized_json TEXT NOT NULL
  );
  CREATE INDEX IF NOT EXISTS idx_serp_query ON serp (query, gl, hl, device);
  ");
  }

  public function save(string $q, string $gl, string $hl, string $device, array $raw, array $norm): void {
  $stmt = $this->db->prepare(
  "INSERT INTO serp (query, gl, hl, device, raw_json, normalized_json) VALUES (?, ?, ?, ?, ?, ?)"
  );
  $stmt->execute([$q, $gl, $hl, $device, json_encode($raw), json_encode($norm)]);
  }
}

Step 5, the runner

<?php
// run.php
require __DIR__ . '/vendor/autoload.php';

use App\{SerpClient, Normalizer, Storage};
use Dotenv\Dotenv;

Dotenv::createImmutable(__DIR__)->load();

$client = new SerpClient($_ENV['SERP_API_KEY']);
$storage = new Storage();

$keywords = [
  'php web scraping',
  'best scraping tools 2026',
  'api scraping guide',
];

$locales = [
  ['us', 'en', 'desktop'],
  ['us', 'en', 'mobile'],
];

foreach ($keywords as $kw) {
  foreach ($locales as [$gl, $hl, $device]) {
  try {
  $data = $client->search($kw, [
  'gl'  => $gl,
  'hl'  => $hl,
  'device' => $device,
  ]);
  $norm = Normalizer::normalize($data);
  $storage->save($kw, $gl, $hl, $device, $data, $norm);
  echo "OK: {$kw} | {$gl}/{$hl}/{$device}\n";
  usleep(300_000);
  } catch (\Throwable $e) {
  echo "ERR: {$kw} | " . $e->getMessage() . "\n";
  }
  }
}

Step 6, query for rank

<?php
// rank.php
require __DIR__ . '/vendor/autoload.php';

$db = new PDO('sqlite:' . __DIR__ . '/serp.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$domain = 'scrapingcentral.com';
$query = 'api scraping guide';

$stmt = $db->prepare("
  SELECT collected_at, normalized_json FROM serp
  WHERE query = ? ORDER BY collected_at DESC LIMIT 30
");
$stmt->execute([$query]);

foreach ($stmt as $row) {
  $org = json_decode($row['normalized_json'], true)['organic'];
  $rank = null;
  foreach ($org as $r) {
  if (str_contains($r['domain'], $domain)) {
  $rank = $r['position'];
  break;
  }
  }
  echo "{$row['collected_at']}: " . ($rank ?? ',') . "\n";
}

Step 7, schedule

0 2 * * * cd /path/to/serp-php && /usr/bin/php run.php >> serp.log 2>&1

Step 8, adding to a Symfony app

If your codebase is Symfony:

  • Register SerpClient in services.yaml with the API key from .env.
  • Inject Storage via PDO.
  • Wrap the runner as a Symfony Console command (bin/console serp:run).
  • Use Symfony's Scheduler component (or Messenger + delayed dispatch) instead of raw cron for in-app scheduling.

The architecture is identical; the wiring is different.

What you've shipped

A complete PHP SERP-tracking pipeline:

  1. Composer-managed project with Guzzle + dotenv.
  2. SerpClient with retry middleware.
  3. Normalizer that decouples your code from provider field names.
  4. PDO-backed SQLite storage.
  5. Runner that loops keywords × locales.
  6. Cron-friendly scheduling.
  7. Analysis script for rank trends.

Drop-in to any PHP project, wrap in a Symfony command if you have one, or run standalone.

Hands-on lab

Build the project end-to-end. Run it for a week against a real SERP-API free tier. Query the database with php rank.php and inspect the trends. Then port the runner into a Symfony command structure (if you have a Symfony project lying around). You've delivered the PHP equivalent of the Python pipeline, a portable SERP-tracking skeleton you can re-use forever.

Quiz, check your understanding

Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.

Hands-On: SERP API in PHP, Composer, SDK, Real Queries1 / 8

Which Composer package handles `.env` file loading idiomatically in PHP?

Score so far: 0 / 0