~ / guides / PHP Web Scraping: The Complete Guide

PHP Web Scraping: The Complete Guide

MR
Marcus Reed
Founder & lead tester · about the author
the short version
  • The standard PHP stack is Guzzle (or Symfony HttpClient) to fetch and Symfony DomCrawler to parse with CSS selectors or XPath.
  • Goutte is dead. It was archived in April 2023 and became a thin wrapper around Symfony BrowserKit's HttpBrowser. Use that directly.
  • For JavaScript-rendered pages use Symfony Panther (v2.4.0), which drives a real Chrome or Firefox over the WebDriver protocol.
  • Versions as of June 2026: symfony/dom-crawler 8.1.0 (needs PHP 8.4.1+), symfony/panther 2.4.0 (PHP 8.1+).
  • Parsing is a dozen lines. Staying unblocked across thousands of requests is the hard part, and that is what a scraper API handles.

I have written scrapers in five languages, and PHP is the one I reach for when the data has to land inside an app that already speaks PHP: a Laravel dashboard, a Symfony backend, a WordPress plugin pulling prices. This guide is the map for that path. Which library does what, a full PHP web scraping example you can run, how to parse with CSS selectors and XPath, how to handle JavaScript pages with Panther, and how to keep a scraper alive once a site pushes back. The code uses the documented API for each library at its current 2026 version. I point the examples at books.toscrape.com, a sandbox built for scraping practice.

Is PHP good for web scraping?

Yes, PHP is a solid choice for web scraping when your stack is already PHP. The library set is mature: Guzzle for HTTP, Symfony DomCrawler for parsing, and Symfony Panther for JavaScript-rendered pages. A working scraper is a dozen lines, and DomCrawler uses the same CSS selectors you already know from the browser and from jQuery.

Three reasons PHP earns its place here:

Python has more scraping tutorials and a deeper bench of one-off scripts, which I cover in the Python guide. PHP wins when the scraper is a feature of a larger PHP system rather than a standalone script.

What are the main PHP web scraping libraries?

Four tools cover the whole field, and the right one depends on whether the page needs JavaScript. Static HTML needs a fetcher plus a parser. JavaScript-rendered pages need a real browser, and that means Panther.

LibraryComposer packageWhat it doesRuns JavaScript
Guzzleguzzlehttp/guzzleHTTP client: fetches pages, sets headers, handles cookiesNo
Symfony DomCrawlersymfony/dom-crawlerParses HTML/XML with CSS selectors and XPathNo
Symfony BrowserKitsymfony/browser-kitFetch + parse in one object (HttpBrowser), form and link helpersNo
Symfony Panthersymfony/pantherDrives a real Chrome or Firefox over WebDriverYes

A few notes from using all four:

Is Goutte still the right choice for PHP scraping?

No. Goutte was the famous PHP scraping library for years, and it is now archived. The repository went read-only on April 1, 2023, and the final v4 release turned Goutte into a thin proxy around Symfony\Component\BrowserKit\HttpBrowser. The README itself tells you to replace Goutte\Client with HttpBrowser in your code.

So skip the wrapper and use the real thing. Everything a Goutte tutorial shows you still works, because Goutte was already calling these classes under the hood:

// The old Goutte way (deprecated, archived 2023):
$client = new Goutte\Client();

// The maintained replacement, identical workflow:
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

$browser = new HttpBrowser(HttpClient::create());

request() still returns a DomCrawler Crawler, so the parsing code below is unchanged whether you came from Goutte or started fresh.

How do you install a PHP scraper with Composer?

Add the packages with one Composer command and let it resolve the dependencies. For the standard Guzzle-plus-DomCrawler stack you need three packages: the HTTP client, the crawler, and the CSS-selector bridge that lets DomCrawler accept filter() selectors.

composer require guzzlehttp/guzzle symfony/dom-crawler symfony/css-selector

The symfony/css-selector package is the one people forget. Without it, filter() throws because DomCrawler converts CSS selectors to XPath through that component. filterXPath() works without it, but CSS is friendlier.

Current stable versions on Packagist as of June 2026:

PackageVersionRequires
guzzlehttp/guzzle7.xPHP 8.1+
symfony/dom-crawler8.1.0PHP 8.4.1+
symfony/css-selector8.1.0PHP 8.4.1+
symfony/panther2.4.0PHP 8.1+

A note on PHP versions: the Symfony 8.x components above require PHP 8.4.1 or newer. If you are pinned to an older PHP, Composer will resolve symfony/dom-crawler to the latest 6.x or 7.x line, where the API in this guide is identical. Check your runtime with php -v before you install.

How do you write a PHP web scraping example?

Here is a complete, runnable scraper. The approach: Guzzle fetches the page, the response body becomes a string, DomCrawler parses it, and CSS selectors pull the fields. This targets the books sandbox, which lists products with a title, price, and rating on each card.

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

use GuzzleHttp\Client;
use Symfony\Component\DomCrawler\Crawler;

// 1. Fetch the page with a realistic User-Agent.
$client = new Client([
    'timeout' => 10.0,
    'headers' => [
        'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
            . 'AppleWebKit/537.36 (KHTML, like Gecko) '
            . 'Chrome/125.0.0.0 Safari/537.36',
    ],
]);

$response = $client->request('GET', 'https://books.toscrape.com/');
$html = (string) $response->getBody();

// 2. Parse the HTML.
$crawler = new Crawler($html);

// 3. Each product is an <article class="product_pod">.
//    each() runs a callback per node and collects the return values.
$books = $crawler->filter('article.product_pod')->each(function (Crawler $node) {
    return [
        'title' => $node->filter('h3 a')->attr('title'),
        'price' => $node->filter('.price_color')->text(),
        // The rating sits in a class like "star-rating Three".
        'rating' => $node->filter('.star-rating')->attr('class'),
    ];
});

// 4. Do something with the data.
foreach ($books as $book) {
    printf("%-55s %s\n", $book['title'], $book['price']);
}

echo count($books) . " books scraped.\n";

The shape of every PHP scraper is in those four steps: fetch, parse, select, store. The selectors are the only part that changes site to site. To find them, open the page in your browser, right-click an element, and choose Inspect.

I have not run this script here because the PHP runtime is not installed in my writing environment. The code uses the documented Guzzle and DomCrawler APIs at their June 2026 versions. Run it against the books sandbox first, where there is no risk of a block, before you point it at a live target.

How do you parse HTML with CSS selectors and XPath?

DomCrawler gives you both selector languages, and they map cleanly onto each other. CSS is shorter for everyday selecting; XPath wins when you need to walk up the tree or match on text. The four methods you will use constantly:

MethodWhat it doesExample
filter('css')Select nodes by CSS selector$crawler->filter('h3 a')
filterXPath('xpath')Select nodes by XPath expression$crawler->filterXPath('//h3/a')
text()Text content of the first matched node->text() returns "a light in the..."
attr('name')Attribute value of the first matched node->attr('href')

A few patterns I use on almost every job:

// Text of the first match, with a fallback if nothing matches.
$title = $crawler->filter('h1')->text('No title found');

// An attribute, for example a link target or an image source.
$href = $crawler->filter('article.product_pod h3 a')->attr('href');
$img  = $crawler->filter('article.product_pod img')->attr('src');

// The same selection in XPath, useful when you match on an attribute value.
$price = $crawler->filterXPath('//p[@class="price_color"]')->text();

// Pull several fields from each node in one pass with extract().
$rows = $crawler->filter('article.product_pod')
    ->extract(['_text']); // _text is node text; pass attribute names too

One gotcha worth flagging: calling text() or attr() on an empty selection throws an InvalidArgumentException. Pass a default to text() as shown, or guard with if ($node->count() > 0) before you read. For a deeper reference on building robust selectors across both languages, see my XPath and CSS selectors guide.

How do you scrape JavaScript-rendered pages in PHP?

Use Symfony Panther. Guzzle and DomCrawler only see the HTML the server sends in the first response. When a site builds its content with JavaScript after load, that HTML is nearly empty and your selectors return nothing. Panther solves this by driving a real browser through the W3C WebDriver protocol: it loads the page, runs the scripts, waits for content, then hands you a Crawler over the fully rendered DOM.

Install Panther and a WebDriver manager that downloads the matching browser driver for you:

composer require symfony/panther dbrekelmans/bdi
vendor/bin/bdi detect drivers

Panther needs a real Chrome or Firefox installed on the machine, plus the matching chromedriver or geckodriver. The dbrekelmans/bdi helper detects your browser and fetches the right driver into drivers/.

The API mirrors DomCrawler, so the parsing code carries straight over:

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

use Symfony\Component\Panther\Client;

// Launches a headless Chrome and returns a Panther client.
$client = Client::createChromeClient();

// request() drives the browser to the URL and returns a Crawler
// over the rendered DOM, after JavaScript has run.
$crawler = $client->request('GET', 'https://books.toscrape.com/');

// Wait until the product cards are present (handles async loading).
$client->waitFor('article.product_pod');

$titles = $crawler->filter('article.product_pod h3 a')->each(
    fn ($node) => $node->attr('title')
);

print_r($titles);

$client->quit(); // Always close the browser to free the process.

The key differences from the Guzzle stack: Client::createChromeClient() boots a browser, waitFor() blocks until a selector appears (essential for content that loads after the initial render), and quit() shuts the browser down so you do not leak processes. Everything between is the same filter()/text()/attr() API you already wrote. If you have used Selenium, this is the same idea, expressed in PHP. My Selenium guide covers the browser-automation model in more depth.

How do you handle forms, logins, and pagination?

Use BrowserKit’s HttpBrowser for stateful navigation without a real browser. It keeps cookies between requests, so once you log in, later requests stay authenticated. It also gives you clickLink() and submitForm() that read the actual <a> and <form> elements on the page, so you do not hand-build URLs or POST bodies.

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

use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

$browser = new HttpBrowser(HttpClient::create());

// Submit a login form by its button label and field names.
$browser->request('GET', 'https://example.com/login');
$browser->submitForm('Sign in', [
    'username' => 'demo',
    'password' => 'secret',
]);

// The browser now holds the session cookie. Follow a link by its text.
$crawler = $browser->clickLink('My Account');
echo $crawler->filter('h1')->text();

For pagination, the cleanest pattern is a loop that follows the “next” link until it disappears:

$crawler = $browser->request('GET', 'https://books.toscrape.com/');
$allTitles = [];

while (true) {
    $allTitles = array_merge(
        $allTitles,
        $crawler->filter('article.product_pod h3 a')->each(fn ($n) => $n->attr('title'))
    );

    $next = $crawler->filter('li.next a');
    if ($next->count() === 0) {
        break; // No next link means the last page.
    }
    $crawler = $browser->clickLink('next');
}

echo count($allTitles) . " titles across all pages.\n";

When a page builds its form or pagination with JavaScript, switch to Panther, which exposes the same clickLink() and submitForm() methods on top of a real browser.

How do you scrape in PHP without getting blocked?

Parsing is the easy 20 lines. The hard part is staying unblocked across thousands of requests, and that is a separate engineering problem from selecting HTML. Sites detect scrapers through datacenter IP addresses, missing or robotic headers, request rate, and JavaScript or CAPTCHA challenges. A scraper that works on page one often gets a 403 by page fifty.

The defenses, cheapest first:

TechniqueWhat it countersEffort
Realistic User-Agent + headersTrivial bot filtersLow: set headers on the Guzzle client
Throttling and random delaysRate-based blocksLow: usleep() between requests
Retry with backoff on 429/503Transient blocksMedium: wrap requests in retry logic
Rotating proxiesIP bansHigh: pool, rotate, monitor health
Headless browser (Panther)JavaScript challengesHigh: slow, memory-heavy
Scraper APIAll of the above, managedLow: one HTTP call

You can build the first three in an afternoon. Rotating residential proxies and solving JavaScript challenges at scale is a full subsystem, and most teams do not want to own it. My dedicated guide on scraping without getting blocked walks through each defense with PHP code.

When the anti-bot work outgrows the value of building it yourself, a scraper API absorbs proxies, header rotation, and browser rendering behind a single endpoint. ChocoData, for example, offers a universal endpoint plus 453 dedicated endpoints, so you send a target URL and get back rendered HTML without managing a proxy pool. From PHP it stays a normal Guzzle request:

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

use GuzzleHttp\Client;
use Symfony\Component\DomCrawler\Crawler;

$client = new Client();

// Send the target URL to the scraper API; it returns rendered HTML.
$response = $client->request('GET', 'https://api.chocodata.com/v1', [
    'query' => [
        'api_key' => getenv('CHOCODATA_KEY'),
        'url'     => 'https://books.toscrape.com/',
        'render'  => 'true', // run JavaScript on their side
    ],
]);

// Parse the result with the same DomCrawler code as always.
$crawler = new Crawler((string) $response->getBody());
$titles = $crawler->filter('article.product_pod h3 a')->each(fn ($n) => $n->attr('title'));
print_r($titles);

The parsing layer never changes. You swap where the HTML comes from. Check ChocoData’s own docs for the exact parameter names and current pricing before you wire it in.

Where should you go next?

Start on the books sandbox, get the Guzzle-plus-DomCrawler example returning clean data, then point it at your real target and watch for blocks. When they appear, add headers and delays first, and reach for a browser or a scraper API only when you actually need them.

For the broader picture beyond PHP, my web scraping pillar guide covers the concepts that apply in every language: selectors, anti-bot defenses, the legal lines, and when to build versus buy. If you also work in Python, the BeautifulSoup and Scrapy guides cover the closest equivalents to the PHP tools here.

FAQ

Is PHP good for web scraping?

Yes, especially if your app is already PHP. Guzzle handles fetching, Symfony DomCrawler parses HTML with the same CSS selectors you use in the browser, and Panther covers JavaScript pages. Python has more tutorials, but a PHP scraper drops straight into a Laravel or Symfony codebase with no second runtime.

Should I still use Goutte in 2026?

No. Goutte was archived on April 1, 2023 and its final release made it a proxy to Symfony BrowserKit's HttpBrowser. Install symfony/browser-kit and symfony/http-client and use HttpBrowser directly. You get the same fetch-plus-DomCrawler workflow with a maintained package.

Can PHP scrape JavaScript-heavy sites?

Yes, with Symfony Panther. Guzzle and DomCrawler only see the HTML the server sends, so content injected by JavaScript is missing. Panther drives a real Chrome or Firefox through WebDriver, runs the page scripts, and then hands you a Crawler over the rendered DOM.

MR
Marcus Reed
I've built and run web scrapers for the better part of a decade. On this site I put scraper APIs and scraping tools through real jobs against real targets, then write up what actually holds up.