Website Scraper

PHP Web Scraping Tutorial (2026): DOMDocument, Step by Step

By · 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.

FAQ

Can you scrape a website with PHP?
Yes, and you don't need a single library to do it. PHP ships with DOMDocument and DOMXPath, which parse HTML and query it with XPath, plus file_get_contents or cURL to download the page. That built-in toolchain handles any static site. You only reach for Composer packages like Goutte or Symfony's DomCrawler for convenience, not capability.
Do I need Composer or a library to scrape with PHP?
No. The examples in this tutorial use only PHP's standard library — DOMDocument for parsing, DOMXPath for selecting, file_get_contents for fetching, and fputcsv for export. Libraries like Guzzle (HTTP) and Symfony DomCrawler (parsing) are nicer ergonomics on top of the same primitives, worth adding once a project grows, but never required to get started.
Why does DOMDocument print warnings when I load HTML?
Real-world HTML rarely validates, and DOMDocument complains loudly about every unclosed tag. The fix is two lines: call libxml_use_internal_errors(true) before loadHTML to suppress the warnings, then libxml_clear_errors() after. The parser still builds a usable tree; you're just silencing noise about markup you don't control.
Why does my PHP scraper get a 403 or empty response?
Two common causes. Either the site blocks the default PHP user agent — set a browser-like User-Agent via a stream context or cURL and many sites relent — or the data is rendered by JavaScript after load, which file_get_contents never executes. If the values are missing from 'view source', PHP alone can't see them and you need a headless browser or an AI scraper that renders the page.
Is PHP good for web scraping?
For static, server-rendered pages, PHP is perfectly capable and its DOM tools are genuinely pleasant. Where it struggles is JavaScript-heavy sites, since PHP has no built-in browser engine, and large concurrent crawls, where languages with easy async have an edge. For a WordPress plugin, a Laravel job, or a quick data pull, PHP is a fine choice.

Keep reading