Skip to main content

Confirm

Are you sure?

PHP · Code recipe

Run a Lighthouse audit in PHP

Score any page for performance, accessibility, best practices and SEO, in PHP 8.1+ with the curl extension. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.

By · Last updated: September 2026

TL;DR

To run a Lighthouse audit in PHP, POST a URL and a device to https://urlpipe.dev/lighthouse and read the JSON with json_decode($body, true): four category scores from 0 to 1 and the lab metrics, LCP, CLS and TBT among them. An audit takes around 15 seconds, so the async variant with a webhook suits production. It costs 2 credits.

Free plan, no credit card. 1,000 credits a month.

One POST to /lighthouse runs a real Lighthouse audit in Chrome and answers with the four category scores — performance, accessibility, best practices, SEO — and the lab metrics behind the performance score. device picks mobile (the default, throttled CPU and network) or desktop.

In PHP the report is read with json_decode($body, true). Scores run from 0 to 1, so the program multiplies by 100 to print what the Lighthouse report shows; metrics carry a ready-formatted displayValue. The guide to reading a Lighthouse audit explains what each number means.

Setup

Before you start

Nothing to install beyond PHP itself: the curl extension ships with it. Check it is loaded, and put your API key in the environment.

Terminal
php -m | grep curl   # prints "curl"
export URLPIPE_API_KEY="your_api_key"

The request

Print the four scores, LCP, CLS and TBT

Without CURLOPT_RETURNTRANSFER, curl_exec() prints the body and returns true — the most common reason a PHP example "returns nothing". curl_exec() returns false only when there was no HTTP answer at all; a 422 is still a string, so read the status too. ?? reaches through nested keys without a warning, so a category or metric that came back null prints as n/a.

lighthouse_audit.php
<?php

function fail(string $message): never
{
    fwrite(STDERR, $message . "\n");
    exit(1);
}

$ch = curl_init("https://urlpipe.dev/lighthouse");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    // Return the body from curl_exec() instead of printing it.
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("URLPIPE_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode(["url" => "https://example.com", "device" => "mobile", "sync" => true]),
    // A sync call can take up to 60 s; curl would otherwise wait forever.
    CURLOPT_TIMEOUT => 90,
]);
$body = curl_exec($ch);
if ($body === false) {
    fail("Request failed: " . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 200) {
    fail("URLpipe answered {$status}: {$body}");
}

$report = json_decode($body, true);
foreach (["performance", "accessibility", "best-practices", "seo"] as $name) {
    $score = $report["categories"][$name]["score"] ?? null;
    echo "{$name}: " . ($score === null ? "n/a" : round($score * 100)) . "\n";
}

$metrics = [
    "LCP" => "largest-contentful-paint",
    "CLS" => "cumulative-layout-shift",
    "TBT" => "total-blocking-time",
];
foreach ($metrics as $label => $key) {
    echo "{$label}: " . ($report["metrics"][$key]["displayValue"] ?? "n/a") . "\n";
}

Run it: php lighthouse_audit.php

Given the example response on the docs page, it prints:

Output
performance: 95
accessibility: 88
best-practices: 92
seo: 90
LCP: 2.5 s
CLS: 0.05
TBT: 150 ms

Async

The async variant: a token, a webhook and a poll

Leave out sync and the answer is a token, straight away. send() uses CURLOPT_CUSTOMREQUEST so one function makes both the POST and the GETs, and array destructuring ([$status, $body] = …) keeps the loop short.

lighthouse_audit_async.php
<?php

const API = "https://urlpipe.dev";

function fail(string $message): never
{
    fwrite(STDERR, $message . "\n");
    exit(1);
}

// One request to the API: returns [status, body].
function send(string $method, string $path, ?array $payload = null): array
{
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer " . getenv("URLPIPE_API_KEY"),
            "Content-Type: application/json",
        ],
        CURLOPT_TIMEOUT => 30,
    ]);
    if ($payload !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
    }
    $body = curl_exec($ch);
    if ($body === false) {
        fail("Request failed: " . curl_error($ch));
    }
    return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body];
}

// No "sync": the request is accepted at once and the work carries on without you.
[$status, $body] = send("POST", "/lighthouse", [
    "url" => "https://example.com",
    "device" => "mobile",
    "report_to" => "https://your-app.com/webhooks/urlpipe",
    "labels" => ["customer" => "acme"],
]);
if ($status !== 200) {
    fail("URLpipe answered {$status}: {$body}");
}
$token = json_decode($body, true)["token"];
echo "Accepted {$token}\n";

// The result is POSTed to report_to when it is ready. Polling by token is the
// other way to collect it: no endpoint needed, and a backup for the webhook.
for ($attempt = 0; $attempt < 60; $attempt++) {
    [$status, $body] = send("GET", "/result/{$token}");
    if ($status !== 202) { // 202 means still processing
        break;
    }
    sleep(2);
}
if ($status === 202) {
    fail("Still processing after two minutes; try the token again later.");
}
if ($status === 422) {
    fail("The analysis failed: " . json_decode($body, true)["error"]);
}
if ($status === 410) {
    fail("The result is past the 30-day window; send the request again.");
}
if ($status !== 200) {
    fail("URLpipe answered {$status}: {$body}");
}

$report = json_decode($body, true);
foreach (["performance", "accessibility", "best-practices", "seo"] as $name) {
    $score = $report["categories"][$name]["score"] ?? null;
    echo "{$name}: " . ($score === null ? "n/a" : round($score * 100)) . "\n";
}

$metrics = [
    "LCP" => "largest-contentful-paint",
    "CLS" => "cumulative-layout-shift",
    "TBT" => "total-blocking-time",
];
foreach ($metrics as $label => $key) {
    echo "{$label}: " . ($report["metrics"][$key]["displayValue"] ?? "n/a") . "\n";
}

Run it: php lighthouse_audit_async.php

Errors

Handle errors and retries

json_decode() returns null for a body that is not JSON (a 401 answers in plain text), and ?: [] turns that into an empty array. CURLOPT_HEADERFUNCTION is how curl hands you response headers one line at a time.

lighthouse_audit_errors.php
<?php

final class URLpipeError extends RuntimeException
{
}

// POST a sync request and return the result body, or throw URLpipeError.
function urlpipe(string $path, array $payload, int $attempts = 5): string
{
    for ($attempt = 0; $attempt < $attempts; $attempt++) {
        $headers = [];
        $ch = curl_init("https://urlpipe.dev{$path}");
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                "Authorization: Bearer " . getenv("URLPIPE_API_KEY"),
                "Content-Type: application/json",
            ],
            CURLOPT_POSTFIELDS => json_encode($payload + ["sync" => true]),
            CURLOPT_TIMEOUT => 90,
            // Collect the response headers: Retry-After is the one needed here.
            CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$headers): int {
                [$name, $value] = array_pad(explode(":", $line, 2), 2, "");
                $headers[strtolower(trim($name))] = trim($value);
                return strlen($line);
            },
        ]);
        $body = curl_exec($ch);
        if ($body === false) {
            throw new URLpipeError("network error: " . curl_error($ch));
        }
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        if ($status === 200) {
            return $body;
        }
        if ($status === 401) {
            throw new URLpipeError("401: the API key is missing or wrong. Check URLPIPE_API_KEY.");
        }

        $error = json_decode($body, true) ?: [];
        $code = $error["error"] ?? "";
        $detail = isset($error["message"]) ? "{$code}: {$error["message"]}" : $code;

        if ($status === 429 && $code === "rate_limited") {
            // Sending too fast: Retry-After says how long the window has left.
            sleep((int) ($headers["retry-after"] ?? 1));
        } elseif ($status === 429 && $code === "concurrency_limit") {
            // Every parallel slot on your plan is busy with your own requests.
            sleep(2 ** $attempt);
        } elseif ($status === 504) {
            // Still running on our side; the token collects it from GET /result/:token.
            throw new URLpipeError("504 processing_timeout: collect it later with token {$error["token"]}");
        } else {
            // 403 email_unverified, 422 (a bad parameter, or a page that would not load),
            // 429 quota_exceeded: sending the same request again gets the same answer.
            throw new URLpipeError("{$status}: {$detail}");
        }
    }
    throw new URLpipeError("429: still refused after {$attempts} attempts");
}

try {
    $body = urlpipe("/lighthouse", ["url" => "https://example.com", "device" => "mobile"]);
} catch (URLpipeError $e) {
    fwrite(STDERR, "URLpipe: {$e->getMessage()}\n");
    exit(1);
}

$report = json_decode($body, true);
foreach (["performance", "accessibility", "best-practices", "seo"] as $name) {
    $score = $report["categories"][$name]["score"] ?? null;
    echo "{$name}: " . ($score === null ? "n/a" : round($score * 100)) . "\n";
}

$metrics = [
    "LCP" => "largest-contentful-paint",
    "CLS" => "cumulative-layout-shift",
    "TBT" => "total-blocking-time",
];
foreach ($metrics as $label => $key) {
    echo "{$label}: " . ($report["metrics"][$key]["displayValue"] ?? "n/a") . "\n";
}

Run it: php lighthouse_audit_errors.php

Details

What to know about /lighthouse

  • Lighthouse 13 reports four categories; the pwa key is always null, kept so the shape does not change.
  • The performance score weights TBT 30%, LCP 25%, CLS 25%, FCP 10% and Speed Index 10%.
  • INP needs real users and cannot be measured in a lab run; TBT is its lab stand-in.
  • "include_audits": "true" adds the full list of audits, with the elements to fix. Mobile and desktop are cached separately.
  • Want the audit run on a schedule, with a history and alerts? Full Stack Audit sells that as a monitored report; URLpipe is the primitive underneath.

Other languages

Run a Lighthouse audit in another language

More PHP: every PHP recipe

FAQ

Frequently asked questions

Mobile or desktop — which device should I audit?
Mobile, unless you know your traffic is desktop. It emulates a 360×640 phone with a 4× slower CPU and a slow 4G network, which gives the lower, more telling scores. Pass device: "desktop" for a 1350×940 window with no throttling.
Why isn't INP in the results?
INP is measured from real users interacting with the page, which a lab audit cannot do. Total Blocking Time is the lab metric that tracks it.
Why use the async variant for Lighthouse?
An audit takes around 15 seconds, longer on a heavy page. Async hands you a token at once and delivers the report to your webhook, so nothing holds a connection open waiting.
Do I need an SDK to call URLpipe from PHP?
No Composer package: the curl extension is part of PHP, and the API is one POST per job.

Get a key and run it.

Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.