Contents

10/06/2026

PHP: Check Every Image on a Page for Broken URLs

Short answer: this script loads a page, reads every <img src>, and requests each image URL. It prints the HTTP status next to each address and exits with code 1 if any image is broken, so a deploy script can fail on it. It needs PHP with the cURL and DOM extensions. For a quick check without a server, use the broken image checker or the console snippet.

What the script checks

  • Image addresses

    Every distinct img src, resolved to a full address. Empty and inline data: images are skipped.

  • HTTP status

    The final status after up to five redirects. HEAD first, with a one-byte GET as the fallback.

  • Exit code

    0 when every image answers, 1 when any is broken, 2 when the page itself cannot be loaded.

  • Not covered

    srcset, picture sources, lazy-load attributes, CSS backgrounds and images added by JavaScript.

The script

Save it as check-images.php:

<?php
declare(strict_types=1);

/**
 * Lists every <img> on a page and reports the HTTP status of each image URL.
 * Usage: php check-images.php https://example.com/page/
 * Exit code 1 when any image is broken, so it can run in a deploy check.
 */

/** Turns a link found on a page into an absolute URL. */
function resolveUrl(string $pageUrl, string $ref): string
{
    if (preg_match('#^https?://#i', $ref) === 1) {
        return $ref;
    }
    $parts = parse_url($pageUrl);
    $origin = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '');
    if (str_starts_with($ref, '//')) {
        return $parts['scheme'] . ':' . $ref;
    }
    if (str_starts_with($ref, '/')) {
        return $origin . $ref;
    }
    $path = $parts['path'] ?? '/';
    $directory = str_ends_with($path, '/') ? rtrim($path, '/') : rtrim(dirname($path), '/');
    return $origin . $directory . '/' . $ref;
}

/** Every distinct <img src> on the page, as absolute URLs. Inline data: images are skipped. */
function imageUrls(string $pageUrl, string $html): array
{
    $document = new DOMDocument();
    libxml_use_internal_errors(true);
    $document->loadHTML($html);
    libxml_clear_errors();

    $urls = [];
    foreach ($document->getElementsByTagName('img') as $image) {
        $src = trim($image->getAttribute('src'));
        if ($src === '' || str_starts_with($src, 'data:')) {
            continue;
        }
        $urls[] = resolveUrl($pageUrl, $src);
    }
    return array_values(array_unique($urls));
}

/** One request; returns the final HTTP status, or 0 when the server could not be reached. */
function requestStatus(string $url, bool $headOnly): int
{
    $handle = curl_init($url);
    curl_setopt_array($handle, [
        CURLOPT_NOBODY         => $headOnly,
        CURLOPT_RANGE          => $headOnly ? null : '0-0',
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_USERAGENT      => 'image-checker/1.0',
    ]);
    curl_exec($handle);
    return (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
}

/** HEAD first. Some servers refuse HEAD, so a 405, 501 or no answer is retried as a one-byte GET. */
function imageStatus(string $url): int
{
    $status = requestStatus($url, true);
    if ($status === 0 || $status === 405 || $status === 501) {
        return requestStatus($url, false);
    }
    return $status;
}

function fetchPage(string $url): string
{
    $handle = curl_init($url);
    curl_setopt_array($handle, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_USERAGENT      => 'image-checker/1.0',
    ]);
    $html = curl_exec($handle);
    $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
    if (!is_string($html) || $status >= 400) {
        fwrite(STDERR, "Could not load the page (status $status).\n");
        exit(2);
    }
    return $html;
}

function main(array $argv): int
{
    $pageUrl = $argv[1] ?? '';
    if (filter_var($pageUrl, FILTER_VALIDATE_URL) === false) {
        fwrite(STDERR, "Usage: php check-images.php https://example.com/page/\n");
        return 2;
    }

    $broken = 0;
    foreach (imageUrls($pageUrl, fetchPage($pageUrl)) as $url) {
        $status = imageStatus($url);
        $isBroken = $status === 0 || $status >= 400;
        $broken += $isBroken ? 1 : 0;
        printf("%s  %s\n", $status === 0 ? 'ERR' : (string) $status, $url);
    }
    printf("%d broken\n", $broken);
    return $broken > 0 ? 1 : 0;
}

if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
    exit(main($argv));
}

Run it

php check-images.php https://example.com/page/

Each line is the final HTTP status and the image URL. A status of ERR means the server could not be reached. The last line counts the broken images.

What it does, and what we tested

  • It resolves relative addresses against the page URL, including root-relative paths, paths relative to the page's folder and protocol-relative addresses, and it skips empty and inline data: images. It lists each address once.
  • It tries a HEAD request first, because that avoids downloading the image. Some servers refuse HEAD, so it retries a 405, a 501 or no answer as a one-byte GET.
  • It follows up to five redirects and reports the final status.
  • It parses the page with DOMDocument::loadHTML, which the PHP manual says does not need well-formed HTML but may raise warnings on bad markup, and that libxml's error handling functions can be used to handle them (PHP manual: DOMDocument::loadHTML, page undated). The script turns on libxml's internal error handling so a messy page does not flood the output.

We tested it on PHP 8.5.5 with unit cases for URL resolution and against a live page of eight images, which returned 200 for each and exit code 0, and with a missing image URL, which returned 404. Testing found one real bug during writing: PHP's dirname() treats a path that ends in a slash as a file, which resolved relative addresses one folder too high. The script now handles that case.

Limits

  • It reads <img src> only. It does not read srcset, picture sources, lazy-load attributes such as data-src, or CSS background images. The broken image checker covers those.
  • It checks the HTML the server returns. An image added by JavaScript after the page loads is not seen.
  • Some hosts block unfamiliar user agents or rate-limit repeated requests, so a failure is worth confirming in a browser.
  • It checks one page. Loop it over a sitemap for a whole site, and add a short pause between pages.

FAQ

How do I check for broken images with PHP?

Load the page, read each img src, request each URL and test the HTTP status. The script on this page does that and exits with code 1 if any status is 400 or higher or the request fails.

Why does the script send a HEAD request first?

A HEAD request returns the status without downloading the image. Some servers reject it, so the script retries those as a one-byte GET.

Does it check lazy-loaded images?

Not those that keep the real address in an attribute such as data-src. The broken image checker on this site reads those attributes.