ScreenshotNeo

BlogHow-to

Screenshot Webpages as JPEG in PHP

Capture JavaScript-rendered webpages as JPEG in PHP with Browsershot, full-page options, waits, quality controls, troubleshooting, and an API alternative.

By the ScreenshotNeo team29 September 202610 min read

Screenshot Webpages as JPEG in PHP

To screenshot a webpage as a JPEG in PHP, run a headless Chromium browser and set the screenshot format to JPEG. Spatie Browsershot is the shortest path for most PHP applications: it accepts a URL or HTML, delegates rendering to Puppeteer and Chrome, and saves an image or returns image bytes.

<?php

require __DIR__ . '/vendor/autoload.php';

use Spatie\Browsershot\Browsershot;

Browsershot::url('https://example.com')
    ->setScreenshotType('jpeg', 80)
    ->windowSize(1440, 900)
    ->save(__DIR__ . '/page.jpg');

The setScreenshotType('jpeg', 80) call selects JPEG output and a quality value of 80. windowSize fixes the viewport so repeated captures have predictable dimensions. Browsershot starts Chromium through Puppeteer, waits for the page to render, and writes page.jpg.

1. Install the PHP and browser dependencies

Browsershot is a Composer package, but it also needs Node.js, Puppeteer, and a working Chromium or Chrome executable. Install the PHP package in your application:

composer require spatie/browsershot

Install Puppeteer in the project or in the location configured for your deployment:

npm install puppeteer

Check the package requirements for your Browsershot version before deploying. Pin compatible PHP, Node.js, Chromium, and Puppeteer versions together; mismatched browser binaries are a common source of failures. In containers, ensure the image includes the shared libraries Chromium requires and that the process has permission to start a sandbox or is configured according to your container security policy.

2. Save a webpage as a JPEG

This is a complete command-line PHP script. It creates the output directory, captures the URL, and writes a JPEG file.

A PHP process hands a URL to a headless browser, which returns rendered JPEG bytes.
A PHP process hands a URL to a headless browser, which returns rendered JPEG bytes.
<?php

require __DIR__ . '/vendor/autoload.php';

use Spatie\Browsershot\Browsershot;

$url = 'https://example.com';
$output = __DIR__ . '/screenshots/example.jpg';

if (!is_dir(dirname($output))) {
    mkdir(dirname($output), 0755, true);
}

Browsershot::url($url)
    ->setScreenshotType('jpeg', 82)
    ->windowSize(1440, 900)
    ->save($output);

echo "Saved {$output}\n";

JPEG quality is a trade-off between detail and file size. Start around 80–85 for web previews and raise it when small text or diagrams show visible compression. JPEG has no transparency and is not ideal for screenshots that contain sharp interface text, flat colors, or line art; use PNG when lossless edges matter. The question here is JPEG, so set the type explicitly instead of relying on a default.

3. Control viewport, density, and output area

Screenshot dimensions are determined by the viewport and the capture mode. Browsershot documents viewport sizing, clipping, element selection, full-page capture, and device scale controls in its image guide.

Viewport screenshots

Browsershot::url('https://example.com')
    ->setScreenshotType('jpeg', 80)
    ->windowSize(1366, 768)
    ->save(__DIR__ . '/viewport.jpg');

A viewport capture is useful for a browser preview or a responsive breakpoint. Pick widths that match your product requirements and keep them fixed in automated jobs.

Full-page screenshots

Browsershot::url('https://example.com')
    ->setScreenshotType('jpeg', 80)
    ->windowSize(1440, 900)
    ->fullPage()
    ->save(__DIR__ . '/full-page.jpg');

fullPage() expands the capture to the document’s full height. Long pages can create very tall JPEGs, consume more memory, and take longer when images or scripts are lazy-loaded. If the page relies on intersection observers, scroll it or use a readiness strategy before capture so below-the-fold content is present.

Capture one element or a rectangular region

// Element selected by CSS selector
Browsershot::url('https://example.com/pricing')
    ->setScreenshotType('jpeg', 85)
    ->select('.pricing-table')
    ->save(__DIR__ . '/pricing-table.jpg');

// Rectangle in viewport coordinates
Browsershot::url('https://example.com')
    ->setScreenshotType('jpeg', 85)
    ->clip(80, 120, 1000, 650)
    ->save(__DIR__ . '/region.jpg');

Use select when the page has a stable semantic component. Use clip when the coordinates are known and the viewport is fixed. A selector that matches nothing, changes after rendering, or is hidden will cause an element capture to fail or produce an unexpected image.

Increase pixel density

Browsershot::url('https://example.com')
    ->setScreenshotType('jpeg', 82)
    ->windowSize(1440, 900)
    ->deviceScaleFactor(2)
    ->save(__DIR__ . '/retina.jpg');

A device scale factor of 2 or 3 creates more pixels for the same CSS viewport. It improves detail on high-density displays but increases memory use, processing time, and file size. Keep the factor at 1 unless the consuming system needs a denser image.

4. Wait for JavaScript and lazy content

A screenshot records the rendered browser, not the original HTML response. Client-side components, fonts, API data, and lazy images must be ready before the capture. Prefer a page-specific readiness selector over an arbitrary delay.

Browsershot::url('https://example.com/dashboard')
    ->setScreenshotType('jpeg', 80)
    ->windowSize(1440, 900)
    ->waitForSelector('.dashboard-loaded')
    ->save(__DIR__ . '/dashboard.jpg');

Choose a selector that is added only after the important data is visible. For pages without a readiness marker, use a short delay or a network-idle strategy supported by your Browsershot version. A delay is less deterministic: a slow API or a blocked third-party script can require more time than expected, while a fast page wastes the remainder.

For full-page captures, make sure lazy images are loaded before the screenshot. A page-specific script that scrolls through the document can trigger lazy loading. Keep that script narrowly scoped and verify that it does not alter the page layout.

5. Return JPEG bytes from a PHP response

You do not have to create a temporary file. Browsershot can return screenshot bytes with screenshot() or a base64 representation with base64Screenshot(). Streaming bytes avoids a write-read-delete cycle for HTTP endpoints.

<?php

require __DIR__ . '/vendor/autoload.php';

use Spatie\Browsershot\Browsershot;

$jpeg = Browsershot::url('https://example.com')
    ->setScreenshotType('jpeg', 80)
    ->windowSize(1440, 900)
    ->screenshot();

header('Content-Type: image/jpeg');
header('Content-Length: ' . strlen($jpeg));
echo $jpeg;

For a Laravel controller, return the bytes with an image/jpeg content type. Set cache headers only when the URL and capture options are safe to cache. If callers can provide URLs, validate and restrict them before passing them to a browser process; unrestricted rendering can expose internal network services or consume excessive resources.

6. Add authentication, cookies, and page-specific setup

Private pages often need cookies or custom headers before they render. Browsershot exposes browser and page configuration methods; use the method names documented for the version installed in your project. A typical flow is:

  1. Open the target URL.
  2. Set the required cookies, headers, or authentication state.
  3. Wait for a selector that proves the authenticated view loaded.
  4. Capture the viewport, full page, element, or clip.

Keep secrets out of URLs and screenshot filenames. Redact sensitive areas with CSS or capture only a safe element. If a page renders differently by locale, timezone, user agent, or viewport, set those values explicitly and record them with the job so another worker can reproduce the image.

7. Alternative PHP libraries

Approach Best fit Trade-offs
Spatie Browsershot Most PHP applications High-level API; requires Node.js, Puppeteer, and Chromium
chrome-php/chrome Lower-level Chrome control More control over screenshot options; more browser lifecycle code
Raw Puppeteer Advanced browser-side JavaScript Requires a Node integration boundary instead of a PHP-only API

The lower-level chrome-php/chrome library documents JPEG format, quality, clipping, and full-page capture through captureBeyondViewport and a full-page clip. Raw Puppeteer’s Page.screenshot() returns image bytes or base64 according to its options. Choose based on rendering fidelity, deployment complexity, control requirements, and JPEG size targets.

8. Or skip the browser setup

If you want PHP to make one HTTP request instead of installing and operating Chromium, use ScreenshotNeo, a website screenshot API. The API returns PNG, JPEG, WebP, or PDF and supports full-page and element capture, waits, custom CSS and JavaScript, cookies and headers, device presets, dark mode, blocking rules, resizing, caching, signed links, asynchronous jobs, bulk capture, and a usage API. See the ScreenshotNeo API documentation for the complete option list.

Consent banners, popups, and chat widgets can be removed before an API capture.
Consent banners, popups, and chat widgets can be removed before an API capture.

For a JPEG response, pass the URL and your access key. The endpoint can be called directly from PHP with cURL:

<?php

$ch = curl_init('https://api.screenshotneo.com/v1/shot?' . http_build_query([
    'access_key' => 'YOUR_API_KEY',
    'url' => 'https://stripe.com',
    'format' => 'jpeg',
]));

curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 90,
]);

$image = curl_exec($ch);
if ($image === false) {
    throw new RuntimeException(curl_error($ch));
}

$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status < 200 || $status >= 300) {
    throw new RuntimeException("ScreenshotNeo returned HTTP {$status}");
}

file_put_contents(__DIR__ . '/stripe.jpg', $image);

The equivalent requests are:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', image);

Set the output format to JPEG using the documented format parameter when you need a JPEG file. ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and whether the request was billed. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account to get 1,000 screenshots a month without a card.

9. Troubleshooting checklist

Chromium cannot start

Cause: Chromium is missing, its shared libraries are absent, or the process cannot access the configured executable. Fix: install the browser dependencies, verify the executable path, and run the same PHP user and environment used by your worker.

“Node” or “Puppeteer” not found

Cause: PHP is running with a different PATH than your shell, or Puppeteer was installed in another directory. Fix: install dependencies in the deployed application and configure Browsershot with the correct Node and npm paths for that environment.

The JPEG is blank

Cause: the page is still loading, a bot check is displayed, JavaScript failed, or the selected element has no dimensions. Fix: wait for a readiness selector, inspect browser console and network errors, confirm the selector exists, and capture a known visible element as a diagnostic.

Dynamic content is missing

Cause: the screenshot ran before an API request or lazy image completed. Fix: wait for a page-specific selector, use a bounded delay when no selector exists, and ensure full-page workflows trigger lazy loading.

Full-page output is unexpectedly short

Cause: the page height was measured before content expanded, or an application uses an internal scroll container. Fix: wait for the content marker, scroll the relevant container, or capture the container element instead of the document.

Text or images look soft

Cause: a low device scale factor or aggressive JPEG compression. Fix: try deviceScaleFactor(2) and raise quality, then check resulting memory use and file size.

Requests hang in production

Cause: a page waits indefinitely on a third-party resource, a browser process is exhausted, or the URL is unreachable from the server. Fix: set application and browser timeouts, limit concurrency, block unnecessary resource types, and log the URL, viewport, wait condition, and browser error for each job.

10. Performance, reliability, and cost

  • Reuse workers carefully: launching Chromium for every request adds startup cost. Queue captures and control concurrency so browser processes do not exhaust CPU or memory.
  • Keep captures bounded: full-page, high-density, and very large viewports create more pixels. Capture an element when the requirement is a component image.
  • Wait on facts: a readiness selector is usually more predictable than a long fixed sleep. Keep a maximum timeout so broken pages do not occupy workers forever.
  • Cache deliberately: cache only when the URL, authentication state, and page data permit it. Include capture options in your cache key.
  • Validate inputs: restrict user-supplied URLs and HTML, limit output dimensions, and isolate browser workers from internal services.
  • Measure the whole job: record queue delay, browser startup, page load, wait time, screenshot encoding, output bytes, and failure reason. These measurements reveal whether to tune the page or the worker.

With a self-hosted browser, your costs are infrastructure, browser maintenance, and engineering time. With ScreenshotNeo, clean shots are the only billed shots, and the response reports billing status. The free plan includes 1,000 shots each month without a card; paid plans are $5 for 3,000, $15 for 15,000, $39 for 60,000, $99 for 250,000, and $249 for 1,000,000, with two months free on yearly billing.

11. PHP JPEG screenshot FAQ

Can PHP take a screenshot without a browser?

Not for a JavaScript-rendered webpage. PHP can request HTML, but a browser engine is needed to execute JavaScript, apply layout, load fonts, and render the page. Use Browsershot, chrome-php/chrome, or a screenshot API.

Should I use JPEG or PNG?

Use JPEG for smaller photographic or preview images and choose a quality value explicitly. Use PNG when lossless text edges, transparency, or flat interface colors are more important than file size.

How do I screenshot a page after login?

Provide the browser with the required cookies or headers, open the page, wait for an authenticated selector, and capture only after that selector is visible. Never put credentials in a URL or output filename.

Why does full-page capture miss images?

Lazy-loaded images may not request their files until they approach the viewport. Trigger lazy loading by scrolling or use a page-specific readiness script before calling fullPage().

Can I return the JPEG directly from Laravel?

Yes. Call Browsershot’s byte-returning screenshot method and return the result with an image/jpeg content type, or save the file and return a streamed response.