Web Scraping with PHP Picture
Scraping
11 minutes reading time

Web Scraping with PHP: Libraries, Code, and What's Deprecated in 2026

Table of contents

Web scraping in PHP is two choices, not twenty: an HTTP client to fetch the page, and a parser to pull data out of it. Everything else in the ecosystem is a wrapper over that pair. In 2026 the default answer is Guzzle or native cURL for the fetch and Symfony DomCrawler for the parse, with Symfony Panther as the escape hatch when the data only exists after JavaScript runs.

The reason to check the dates on any PHP scraping tutorial: two of the libraries they still recommend are dead. Goutte has been abandoned since 2023, and Simple HTML DOM hasn't shipped a stable release since 2019.

Key Takeaways

  • composer require guzzlehttp/guzzle symfony/dom-crawler symfony/css-selector is the entire toolchain for static sites. Omitting symfony/css-selector is the most common beginner error — DomCrawler's filter() throws a LogicException without it, while filterXPath() keeps working.
  • Goutte is abandoned. Its last release is v4.0.3 (April 2023) and its own Packagist page tells you to use symfony/browser-kit instead. Replacing Goutte\Client with Symfony\Component\BrowserKit\HttpBrowser is close to a find-and-replace.
  • Simple HTML DOM's newest release is 2.0-RC2, from November 2019. Maintain existing code with it if you must; don't start new projects there.
  • Symfony DomCrawler 8.x requires PHP >= 8.4.1. On PHP 8.2 or 8.3, pin symfony/dom-crawler:^7.4 — it has the same API.
  • Panther v2.4.0 (January 2026) is the only mainstream pure-PHP route to JavaScript-rendered pages. It drives a real Chrome over WebDriver, so budget a browser binary and hundreds of MB of RAM per concurrent worker.
  • Guzzle 8.0 landed in July 2026; the 7.x line is still maintained. Pick by PHP version, not by novelty.

What do you actually need to scrape a page in PHP?

Split every scraper into two layers and the library choice becomes obvious:

LayerJobNative optionPackage option
FetchHTTP request, redirects, cookies, headers, proxiesext-curlguzzlehttp/guzzle, symfony/http-client
ParseTurn HTML into queryable nodesext-dom (DOMDocument, Dom\HTMLDocument on PHP 8.4+)symfony/dom-crawler

One decision rule tells you whether that's enough. Fetch the page with no browser and search the raw response for a value you need:

curl -s https://example.com/products | grep -i "29.99"

If the value is in there, an HTTP client plus a parser is all you need, and it will be 10–50× cheaper per page than a browser. If it isn't, the page renders client-side and you need Panther or a rendering API — see our headless browser guide for when that cost is worth paying.

Which PHP scraping library should you use?

LibraryPackageRoleStatus (July 2026)Reach for it when
cURLbundled (ext-curl)HTTPCore extensionZero dependencies, one-off scripts, full control over the request
Guzzleguzzlehttp/guzzleHTTP8.0 (Jul 2026); 7.x maintainedConcurrency, retry middleware, anything beyond a single GET
Symfony HttpClientsymfony/http-clientHTTPActiveYou're already on Symfony, or you want BrowserKit's default client
DOMDocument / DOMXPathbundled (ext-dom)ParserCore extensionXPath queries with no Composer dependency
Dom\HTMLDocumentbundled, PHP 8.4+ParserCore extensionSpec-compliant HTML5 parsing plus querySelectorAll
Symfony DomCrawlersymfony/dom-crawlerParserv8.1.1 (Jun 2026)The default. CSS selectors and XPath over a jQuery-ish API
Symfony BrowserKitsymfony/browser-kitBrowser emulationActiveLogins, form submission, link following without a real browser
Symfony Panthersymfony/pantherHeadless browserv2.4.0 (Jan 2026)The data only exists after JavaScript runs
Roach PHProach-php/coreCrawl frameworkv3.2.1 (Mar 2025)Scrapy-style spiders, middleware, item pipelines
PHPScraperspekulatius/phpscraperHigh-level utilityv3.0.0 (Apr 2024)Quick metadata, headings, links from a page without wiring anything
Gouttefabpot/goutteBrowser emulationAbandoned — v4.0.3 (Apr 2023)Never. Use BrowserKit
Simple HTML DOMsimplehtmldom/simplehtmldomParserNo stable release since 2019Legacy maintenance only

How do you scrape a page with cURL in PHP?

cURL ships with essentially every PHP install, so a dependency-free scraper is a single function. The thing most tutorials get wrong is disabling TLS verification to make an error go away — that turns every request into a silent man-in-the-middle opportunity. Fix the CA bundle instead; never ship CURLOPT_SSL_VERIFYPEER => false.

<?php

function fetchPage(string $url, array $extraHeaders = []): string
{
    $ch = curl_init();

    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_ENCODING       => '', // accept every encoding this build supports
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
            . 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
        CURLOPT_HTTPHEADER     => array_merge([
            'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language: en-US,en;q=0.9',
        ], $extraHeaders),
    ]);

    $body = curl_exec($ch);

    if ($body === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("Transport error for {$url}: {$error}");
    }

    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status >= 400) {
        throw new RuntimeException("HTTP {$status} for {$url}");
    }

    return $body;
}

Keep that user-agent string current — a Chrome build from three years ago is itself a fingerprint. Our guide to user-agent rotation covers how much rotation actually buys you, and the cURL commands reference maps the command-line flags to these CURLOPT_* constants when you're debugging a request in the shell first.

When is Guzzle worth the extra dependency?

The moment you need more than one page at a time. Pool runs requests over cURL's multi handle with a concurrency cap, which is the difference between a scraper that takes an hour and one that takes two minutes:

<?php

require 'vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\ResponseInterface;

$client = new Client(['timeout' => 20, 'connect_timeout' => 5]);
$urls   = ['https://example.com/p/1', 'https://example.com/p/2', 'https://example.com/p/3'];
$pages  = [];

$requests = static function () use ($urls) {
    foreach ($urls as $url) {
        yield new Request('GET', $url);
    }
};

$pool = new Pool($client, $requests(), [
    'concurrency' => 5,
    'fulfilled'   => static function (ResponseInterface $response, int $i) use (&$pages, $urls) {
        $pages[$urls[$i]] = (string) $response->getBody();
    },
    'rejected'    => static function (Throwable $reason, int $i) use ($urls) {
        fwrite(STDERR, "failed {$urls[$i]}: {$reason->getMessage()}\n");
    },
]);

$pool->promise()->wait();

Guzzle sets no request timeout by default and has no built-in retry option — both are things you configure yourself. Our PHP Guzzle guide covers the exception hierarchy, retry middleware, and proxy options in depth; this post stays on the scraping side.

How do you parse the HTML you fetched?

DomCrawler is the default. Install it with the CSS selector bridge or filter() will throw:

composer require symfony/dom-crawler symfony/css-selector
<?php

require 'vendor/autoload.php';

use Symfony\Component\DomCrawler\Crawler;

$crawler = new Crawler(fetchPage('https://books.toscrape.com/'));

$books = $crawler->filter('article.product_pod')->each(static function (Crawler $node) {
    return [
        'title' => $node->filter('h3 a')->attr('title'),
        // passing a default keeps a missing node from throwing
        'price' => $node->filter('.price_color')->text(''),
        'url'   => $node->filter('h3 a')->link()->getUri(),
    ];
});

print_r($books);

Two gotchas that account for most DomCrawler bug reports:

  • ->text() and ->attr() throw an InvalidArgumentException when the node list is empty. Pass a default (->text('')) or guard with ->count(). A scraper that dies on the one product missing a price is worse than one that records null.
  • filter() needs symfony/css-selector; filterXPath() never does. If you'd rather write XPath — and for text matching or axis traversal you should — our XPath cheat sheet has the expressions, and the CSS selectors FAQ covers the other syntax.

If you're on PHP 8.4 or newer and don't want a Composer dependency at all, the new Dom\HTMLDocument class parses HTML5 to spec — a genuine improvement over DOMDocument::loadHTML(), which is built on libxml's HTML4 parser and mangles modern markup:

<?php

$doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

foreach ($doc->querySelectorAll('article.product_pod h3 a') as $link) {
    echo $link->getAttribute('title'), PHP_EOL;
}

Goutte is abandoned — what replaced it?

Goutte's Packagist page carries a plain abandonment notice recommending symfony/browser-kit, and that's not a demotion: from v4 onward Goutte was already a thin proxy over Symfony's HttpBrowser. The migration is mostly imports.

composer require symfony/browser-kit symfony/http-client symfony/css-selector
<?php

require 'vendor/autoload.php';

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

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

// log in, then scrape the pages behind the session
$crawler = $browser->request('GET', 'https://quotes.toscrape.com/login');

$form = $crawler->selectButton('Login')->form([
    'username' => 'admin',
    'password' => 'admin',
]);

$crawler = $browser->submit($form);

$quotes = $crawler->filter('.quote .text')->each(
    static fn (Crawler $node) => $node->text('')
);

// follow a link the same way a browser would
$crawler = $browser->click($crawler->selectLink('Next')->link());

HttpBrowser keeps cookies across requests, so a login is just the first request in the sequence. It does not execute JavaScript — if the login form posts through a JS handler rather than a plain <form>, you need a real browser.

How do you scrape JavaScript-rendered pages in PHP?

Panther. It speaks the W3C WebDriver protocol to a real Chrome or Firefox, and exposes the same Crawler API as DomCrawler, so the extraction code you already wrote keeps working.

composer require --dev symfony/panther dbrekelmans/bdi
vendor/bin/bdi detect drivers   # downloads a matching chromedriver
<?php

require 'vendor/autoload.php';

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

$client = Client::createChromeClient(null, ['--headless=new', '--disable-gpu', '--window-size=1920,1080']);

$client->request('GET', 'https://example.com/spa/products');

// block until the client-rendered nodes exist, then extract
$crawler = $client->waitFor('.product-grid .product', 10);

$products = $crawler->filter('.product-grid .product')->each(
    static fn (Crawler $node) => [
        'name'  => $node->filter('h2')->text(''),
        'price' => $node->filter('[data-price]')->attr('data-price'),
    ]
);

$client->quit();

The costs are real: a browser binary and a matching driver on every machine that runs the scraper, hundreds of MB of RAM per concurrent instance, and startup time measured in seconds rather than milliseconds. Always quit() — orphaned chromedriver processes are the classic way to fill a scraping box's memory overnight.

Is Simple HTML DOM still safe to use?

Its most recent release on Packagist is 2.0-RC2 from November 2019 — a release candidate that never became stable, now nearly seven years old. It still parses HTML, and existing scripts won't spontaneously break, but you're pinning a core dependency to code that predates PHP 8 entirely and gets no compatibility fixes for new PHP releases.

For new work, DomCrawler does everything Simple HTML DOM does with an actively maintained codebase, and Dom\HTMLDocument on PHP 8.4+ gets you jQuery-style querySelectorAll with no dependency at all. Our Simple HTML DOM FAQ covers the API for people maintaining older code.

What about Roach PHP and PHPScraper?

Two useful libraries a level up from the fetch/parse pair:

  • Roach PHP (v3.2.1, March 2025) is a Scrapy-style crawling framework: spiders, request scheduling, downloader middleware, item processing pipelines. Worth it when you're crawling thousands of pages with dedupe and multi-stage processing, overkill for a nightly price check.
  • PHPScraper (v3.0.0, April 2024) wraps BrowserKit and DomCrawler behind properties like $web->title, $web->links, $web->contentKeywords. Fast for metadata and content extraction; you'll drop to DomCrawler as soon as you need a specific selector.

How do you avoid getting blocked?

The escalation ladder, cheapest first:

  1. Send real headers. A request with no Accept-Language and PHP's default user agent is trivially identifiable. The cURL example above is the floor.
  2. Slow down. One request per second per domain stops most rate limiters. usleep(random_int(800_000, 1_500_000)) between requests, not a fixed sleep(1) — perfectly periodic traffic is itself a signal.
  3. Retry properly. Exponential backoff on 429 and 5xx, and honour Retry-After when the response includes it. Never retry a 403 immediately; it means something about your request, not your timing.
  4. Rotate IPs. Datacenter proxies until you start seeing 403s and CAPTCHAs, then residential. Both ext-curl (CURLOPT_PROXY) and Guzzle (the proxy option) support them directly.
  5. Render only when you have to. A headless browser also fixes some blocks, because it produces a real TLS and JS fingerprint — but at 10–50× the cost per page.

None of this settles whether you should scrape a given target. Terms of service, copyright, and personal-data rules (GDPR, CCPA) all apply independently of the technique; our guide to web scraping legality covers where the lines actually sit.

Scraping with the WebScraping.AI PHP SDK

When maintaining proxies and headless browsers stops being the interesting part of the job, the fetch layer can be an API call. Our PHP SDK is PSR-18 based (it works with Guzzle, Symfony HttpClient, or anything else you already have) and returns strings and arrays you hand straight to DomCrawler:

<?php
// composer require webscraping-ai/webscraping-ai-php
require 'vendor/autoload.php';

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

$client = new Client('YOUR_API_KEY');

// JS-rendered HTML through a residential IP in Germany — no browser on your box
$html = $client->html(
    'https://example.com/products',
    js: true,
    waitFor: '.product-grid .product',
    proxy: 'residential',
    country: 'de',
);

$products = (new Crawler($html))->filter('.product-grid .product')->each(
    static fn (Crawler $node) => $node->filter('h2')->text('')
);

Or skip the selectors entirely. AI field extraction takes plain-English field descriptions and returns structured data, which survives the markup changes that break CSS selectors:

$product = $client->fields('https://example.com/product/1', [
    'name'  => 'Product name',
    'price' => 'Price with currency symbol',
    'stock' => 'Whether the item is in stock',
]);

print_r($product);
// ['name' => '...', 'price' => '$29.99', 'stock' => 'In stock']

The pricing is a published multiplier table, not a mystery: a datacenter fetch costs 1 credit without JS and 5 with it, residential is 10 and 25, stealth is 50, and AI extraction adds 5. Failed requests are never charged. The free tier is 2,000 credits a month with no credit card, which is enough to see whether the pages you care about come back clean. The full parameter list — timeout, device, js_script, custom_proxy, error_on_404 — is in the API docs, and the same endpoints back use cases like price monitoring and product data aggregation.

Frequently asked questions

Is PHP good for web scraping? For static pages, yes — cURL and DomCrawler are as capable as Python's requests and BeautifulSoup, and if your application is already PHP, scraping in the same runtime saves an entire deployment target. Where PHP lags is the JavaScript-heavy end: Panther is the only mainstream option, versus Playwright, Puppeteer, and Selenium in Node and Python. Our PHP scraping FAQ covers more of the day-to-day questions.

Should I use Goutte in 2026? No. It's marked abandoned on Packagist, its last release was April 2023, and the maintainer points at symfony/browser-kit. Any tutorial still recommending Goutte was written before that and hasn't been checked since — treat the rest of its advice with the same suspicion.

What's the difference between DomCrawler and BrowserKit? DomCrawler parses HTML you already have. BrowserKit fetches it and keeps browser-like state — cookies, history, form submission, link clicking — returning a DomCrawler Crawler for each response. Use DomCrawler alone when you fetch with Guzzle or cURL; add BrowserKit when you need a session or have to submit forms.

Can PHP scrape JavaScript-rendered pages? Only by driving a real browser. Symfony Panther over WebDriver is the pure-PHP route; the alternative is calling a rendering API and keeping your PHP process lightweight. There is no PHP equivalent of a JS engine you can embed to run a modern SPA.

Why does DomCrawler's filter() throw a LogicException? You're missing symfony/css-selector, which translates CSS to XPath. Run composer require symfony/css-selector. Until you do, filterXPath() still works — the XPath path never needed the bridge.

Which PHP version should I be on for scraping? PHP 8.4 or 8.5. PHP 8.0 and 8.1 are past end of life, and 8.2 and 8.3 are in security-only support. 8.4 also brings Dom\HTMLDocument and its spec-compliant HTML5 parser, which is a real upgrade over DOMDocument::loadHTML() for anything scraping-related.

Get Started Now

WebScraping.AI provides rotating proxies, Chromium rendering and built-in HTML parser for web scraping
Icon