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
SerpClientinservices.yamlwith the API key from.env. - Inject
Storagevia 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:
- Composer-managed project with Guzzle + dotenv.
- SerpClient with retry middleware.
- Normalizer that decouples your code from provider field names.
- PDO-backed SQLite storage.
- Runner that loops keywords × locales.
- Cron-friendly scheduling.
- 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.