PHP Web Scraping Tutorial (2026): DOMDocument, Step by Step
By Ashesh Dhakal · Updated
If you already run PHP — a WordPress site, a Laravel app, a plain server script — you don't need to leave the language to scrape a web page. PHP has shipped a competent HTML parser in its standard library for years, and most tutorials bury it under Composer packages you don't need. This one uses nothing but built-in PHP: file_get_contents to fetch, DOMDocument and DOMXPath to parse, and fputcsv to export. I ran every snippet in this post against the live site on PHP 8.4; the numbers you see are the numbers I got.
We'll scrape books.toscrape.com, a sandbox built for exactly this: 1,000 fictional books across 50 pages, no terms-of-service worries, no bot detection. If you'd rather skip code entirely, an AI website scraper does this from a pasted URL — I'll compare the two honestly at the end.
What do I need to scrape a website with PHP?
PHP 7.4 or newer (I'm on 8.4) and nothing else. The DOM extension that powers DOMDocument is enabled in virtually every PHP build, so there's no composer require, no API key, no browser driver. Confirm your version:
php --version
Two built-in pieces do all the work. DOMDocument turns a string of HTML into a navigable tree. DOMXPath runs XPath queries against that tree — XPath being a compact language for "find me the elements that look like this." That's the entire toolkit for static sites.
How do I download a web page in PHP?
The simplest fetch is one function. file_get_contents reads a URL into a string just as it reads a file:
<?php
$html = file_get_contents("https://books.toscrape.com/");
echo strlen($html) . " bytes\n";
Running that printed 51294 bytes — the full HTML of the homepage. For a well-behaved sandbox that's enough, but two upgrades matter in the real world. First, many sites reject the default PHP user agent, so send a browser-like one. Second, file_get_contents fails silently by returning false, so check for it. A stream context handles the header:
<?php
$context = stream_context_create([
"http" => [
"header" => "User-Agent: Mozilla/5.0 (compatible; MyScraper/1.0)",
"timeout" => 20,
],
]);
$html = @file_get_contents("https://books.toscrape.com/", false, $context);
if ($html === false) {
exit("Failed to fetch the page\n");
}
For anything beyond a simple GET — cookies, POST bodies, fine-grained timeouts — reach for cURL instead, which PHP also bundles. But for reading a public page, the context above is plenty.
How do I parse HTML with DOMDocument?
Hand the HTML string to DOMDocument and it builds the tree. One wrinkle catches everyone: real HTML almost never validates, and the parser prints a wall of warnings about it. Silence them with libxml and move on — the tree is fine, the warnings are about markup you didn't write:
<?php
$doc = new DOMDocument();
libxml_use_internal_errors(true); // suppress messy-HTML warnings
$doc->loadHTML($html);
libxml_clear_errors();
$xpath = new DOMXPath($doc);
Now $xpath can answer questions about the page. To ask the right questions, look at the page's structure first. On books.toscrape.com each book is an <article class="product_pod">, with its title in an <h3><a title="...">, its price in a <p class="price_color">, and its stock in a <p class="instock availability">. That maps directly to XPath.
How do I extract the data I want?
DOMXPath::query returns a list of matching nodes. Select every book, then pull each field with a relative query (the leading . means "search inside this node, not the whole document"):
<?php
$books = $xpath->query("//article[@class='product_pod']");
echo "Found " . $books->length . " books\n";
foreach ($books as $book) {
$title = $xpath->query(".//h3/a", $book)->item(0)->getAttribute("title");
$price = $xpath->query(".//p[@class='price_color']", $book)->item(0)->textContent;
$avail = trim($xpath->query(".//p[contains(@class,'instock')]", $book)->item(0)->textContent);
printf("%-45s %s %s\n", $title, $price, $avail);
}
That printed 20 rows — the whole first page — starting exactly like this:
Found 20 books
A Light in the Attic £51.77 In stock
Tipping the Velvet £53.74 In stock
Soumission £50.10 In stock
Two small choices did a lot of work. getAttribute("title") reads the full book name from the link's title attribute, because the visible text is truncated with an ellipsis. And contains(@class,'instock') matches on a partial class, which is sturdier than demanding the exact string instock availability — a good habit, since class lists shift.
How do I scrape multiple pages?
One page is a warm-up; catalogs span dozens. On this site the pages follow a clean pattern — catalogue/page-1.html, page-2.html, and so on — so wrap the fetch-and-parse in a function and loop, stopping when a page returns nothing:
<?php
function scrapePage(string $url): array {
$html = @file_get_contents($url);
if ($html === false) return [];
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML($html);
libxml_clear_errors();
$xpath = new DOMXPath($doc);
$rows = [];
foreach ($xpath->query("//article[@class='product_pod']") as $book) {
$rows[] = [
"title" => $xpath->query(".//h3/a", $book)->item(0)->getAttribute("title"),
"price" => $xpath->query(".//p[@class='price_color']", $book)->item(0)->textContent,
"availability" => trim($xpath->query(".//p[contains(@class,'instock')]", $book)->item(0)->textContent),
];
}
return $rows;
}
$all = [];
for ($page = 1; $page <= 2; $page++) {
$rows = scrapePage("https://books.toscrape.com/catalogue/page-$page.html");
if (empty($rows)) break; // no data means we ran past the last page
echo "page $page: " . count($rows) . " books\n";
$all = array_merge($all, $rows);
sleep(1); // be a polite guest
}
echo "TOTAL: " . count($all) . " books\n";
I capped the loop at two pages to keep the demo light. The output:
page 1: 20 books
page 2: 20 books
TOTAL: 40 books
Raise the limit to 50 and you'd collect all 1,000 books, but note the sleep(1) between requests. That one line is the difference between a scraper and a nuisance: it keeps you from hammering a server dozens of times a second, which is both rude and the fastest way to get blocked.
How do I export scraped data to CSV?
PHP's fputcsv writes a properly escaped CSV row — quoting fields with commas, escaping quotes — so you never hand-roll the format. Write the header, then every row:
<?php
$fp = fopen("books.csv", "w");
fputcsv($fp, ["title", "price", "availability"]);
foreach ($all as $row) {
fputcsv($fp, $row);
}
fclose($fp);
The resulting books.csv opened cleanly, with fputcsv quoting the titles that contain commas and leaving the plain ones alone:
title,price,availability
"A Light in the Attic",£51.77,"In stock"
"Tipping the Velvet",£53.74,"In stock"
Soumission,£50.10,"In stock"
That file opens directly in Excel, Google Sheets, or any database import. Forty books, three columns, zero dependencies — the whole program is under fifty lines.
When does PHP scraping stop working?
Two walls, and it's worth knowing them before you hit them. The first is JavaScript. file_get_contents downloads the HTML the server sends and nothing more; it never runs scripts. If a page builds its content in the browser after load — an infinite-scroll feed, a React storefront — the data simply isn't in what PHP receives. View the page source (not the inspector) and if your values aren't there, no XPath will find them. You'd need a headless browser like Chrome driven by a tool that speaks to PHP, which is a different weight class of setup.
The second is anti-bot defense. A missing User-Agent is easy to fix, but CAPTCHAs, IP rate limits, and fingerprinting are not — they're designed to stop exactly what you're doing, and PHP gives you no special leverage against them.
Do you actually need to write PHP for this?
Everything above is genuinely simple, and for a static site it's a fine tool for the job — especially if PHP is already in your stack. But be honest about what a scraper needs over time: the selectors break when the site's markup changes, JavaScript pages need a browser you now have to host, and "re-run it every morning and tell me what changed" means building scheduling, storage, and diffing on top of the parser.
That's the gap an AI scraper closes. Website Scraper reads the rendered page the way a person does — no product_pod selector to maintain, JavaScript executed before extraction — and turns "watch this page" into an alert without any infrastructure on your side. If you're writing PHP because you enjoy it or need it in your app, this tutorial is the right path. If you're writing it only to get a spreadsheet out of a web page, paste the URL into the no-code scraper and skip the maintenance. For the broader picture, the how to scrape data from a website guide covers the workflow, and the Python web scraping tutorial shows the same job in another language.