Your first PHP scraper with Guzzle + DomCrawler
Install the modern stack with Composer: composer require guzzlehttp/guzzle symfony/dom-crawler symfony/css-selector. The CssSelector component lets DomCrawler accept CSS selectors (not just XPath).
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use Symfony\Component\DomCrawler\Crawler;
$client = new Client(['headers' => ['User-Agent' => 'Mozilla/5.0']]);
$html = $client->get('https://books.toscrape.com/')->getBody()->getContents();
$crawler = new Crawler($html);
$crawler->filter('article.product_pod')->each(function (Crawler $node) {
$title = $node->filter('h3 a')->attr('title');
$price = $node->filter('.price_color')->text();
echo "$title | $price\n";
});filter() takes a CSS selector and returns a new Crawler; each() iterates matches; text() reads text and attr() reads an attribute. For XPath instead of CSS, use filterXPath('//...').
The modern replacement for Goutte
Many older tutorials still teach Goutte. It is now deprecated and archived — its code was merged into Symfony. The direct replacement is Symfony\Component\BrowserKit\HttpBrowser, which adds clicking links and submitting forms on top of DomCrawler. Install with composer require symfony/browser-kit symfony/http-client:
<?php
require 'vendor/autoload.php';
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;
$browser = new HttpBrowser(HttpClient::create());
$crawler = $browser->request('GET', 'https://books.toscrape.com/');
while (true) {
$crawler->filter('article.product_pod h3 a')->each(function ($node) {
echo $node->attr('title') . "\n";
});
$next = $crawler->filter('li.next a');
if ($next->count() === 0) break;
$crawler = $browser->click($next->link()); // follow pagination
}This gives you the same convenience Goutte offered — a browsing session that follows links and submits forms — using maintained Symfony components.
Scraping JavaScript-rendered pages with Panther
Guzzle and DomCrawler do not run JavaScript. For client-side-rendered pages, Symfony Panther drives a real Chrome/Firefox browser and shares the DomCrawler API you already know. Install: composer require symfony/panther.
<?php
require 'vendor/autoload.php';
use Symfony\Component\Panther\Client;
$client = Client::createChromeClient();
$client->request('GET', 'https://quotes.toscrape.com/js/');
$client->waitFor('.quote'); // wait for JS to render
$crawler = $client->getCrawler();
$crawler->filter('.quote')->each(function ($node) {
$text = $node->filter('.text')->text();
$author = $node->filter('.author')->text();
echo "$author: $text\n";
});
$client->quit();Panther uses the same selectors as DomCrawler, so moving from static to dynamic scraping is mostly swapping the client. php-webdriver is the lower-level alternative if you need direct Selenium control.
Which PHP library should you use?
| Library | Type | Runs JS? | Best for |
|---|---|---|---|
| Guzzle | HTTP client | No | Fetching pages and APIs |
| Symfony DomCrawler | HTML parser | No | Extraction — CSS and XPath |
| BrowserKit (HttpBrowser) | HTTP + browsing session | No | Links, forms — the Goutte replacement |
| Symfony Panther | Browser automation | Yes | JavaScript-rendered pages |
| Goutte | (deprecated) | No | Avoid — use BrowserKit instead |
The canonical 2026 stack is Guzzle + Symfony DomCrawler, with HttpBrowser for sessions and Panther for JavaScript.
The hard part: handling anti-bot blocking
The PHP code is straightforward; anti-bot defenses are the real obstacle. Guzzle's TLS fingerprint and headers flag it as non-browser to Cloudflare, DataDome, and Akamai, and headless Panther is detectable too. DomCrawler cannot extract data from a challenge page.
Solving this means residential proxies, a real browser fingerprint, and CAPTCHA handling. A scraping API does it server-side — your PHP code posts the URL with Guzzle and parses the returned HTML with DomCrawler:
