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-selectoris the entire toolchain for static sites. Omittingsymfony/css-selectoris the most common beginner error — DomCrawler'sfilter()throws aLogicExceptionwithout it, whilefilterXPath()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-kitinstead. ReplacingGoutte\ClientwithSymfony\Component\BrowserKit\HttpBrowseris 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:
| Layer | Job | Native option | Package option |
| Fetch | HTTP request, redirects, cookies, headers, proxies | ext-curl | guzzlehttp/guzzle, symfony/http-client |
| Parse | Turn HTML into queryable nodes | ext-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?
| Library | Package | Role | Status (July 2026) | Reach for it when |
| cURL | bundled (ext-curl) | HTTP | Core extension | Zero dependencies, one-off scripts, full control over the request |
| Guzzle | guzzlehttp/guzzle | HTTP | 8.0 (Jul 2026); 7.x maintained | Concurrency, retry middleware, anything beyond a single GET |
| Symfony HttpClient | symfony/http-client | HTTP | Active | You're already on Symfony, or you want BrowserKit's default client |
| DOMDocument / DOMXPath | bundled (ext-dom) | Parser | Core extension | XPath queries with no Composer dependency |
Dom\HTMLDocument | bundled, PHP 8.4+ | Parser | Core extension | Spec-compliant HTML5 parsing plus querySelectorAll |
| Symfony DomCrawler | symfony/dom-crawler | Parser | v8.1.1 (Jun 2026) | The default. CSS selectors and XPath over a jQuery-ish API |
| Symfony BrowserKit | symfony/browser-kit | Browser emulation | Active | Logins, form submission, link following without a real browser |
| Symfony Panther | symfony/panther | Headless browser | v2.4.0 (Jan 2026) | The data only exists after JavaScript runs |
| Roach PHP | roach-php/core | Crawl framework | v3.2.1 (Mar 2025) | Scrapy-style spiders, middleware, item pipelines |
| PHPScraper | spekulatius/phpscraper | High-level utility | v3.0.0 (Apr 2024) | Quick metadata, headings, links from a page without wiring anything |
| Goutte | fabpot/goutte | Browser emulation | Abandoned — v4.0.3 (Apr 2023) | Never. Use BrowserKit |
| Simple HTML DOM | simplehtmldom/simplehtmldom | Parser | No stable release since 2019 | Legacy 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 anInvalidArgumentExceptionwhen 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 recordsnull.filter()needssymfony/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:
- Send real headers. A request with no
Accept-Languageand PHP's default user agent is trivially identifiable. The cURL example above is the floor. - Slow down. One request per second per domain stops most rate limiters.
usleep(random_int(800_000, 1_500_000))between requests, not a fixedsleep(1)— perfectly periodic traffic is itself a signal. - Retry properly. Exponential backoff on 429 and 5xx, and honour
Retry-Afterwhen the response includes it. Never retry a 403 immediately; it means something about your request, not your timing. - Rotate IPs. Datacenter proxies until you start seeing 403s and CAPTCHAs, then residential. Both
ext-curl(CURLOPT_PROXY) and Guzzle (theproxyoption) support them directly. - 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.