How to Use wkhtmltoimage in PHP to Screenshot a Web Page
Call wkhtmltoimage from PHP with safely handled arguments, check the process result, and verify the output. Includes URL and local HTML examples, troubleshooting, and an API alternative.
PHP can take a web page screenshot by launching the installed wkhtmltoimage executable as a child process. Pass the input URL or local HTML file and an output image path, then check the process exit status and confirm the output file exists and is non-empty.
The safer approach on PHP 7.4 and later is proc_open() with an array of arguments. It starts the program directly without going through a shell. Set the executable path yourself, validate URLs and output paths, and check the exact options supported by the deployed binary. The upstream project describes wkhtmltoimage as a headless Qt WebKit renderer; its repository is archived and read-only.
1. Install and check the executable
Install a build appropriate for the server operating system using your environment’s package or deployment process. The executable must be available to the same user and runtime that runs PHP. Do not assume a binary installed on a developer workstation is available to PHP-FPM, a queue worker, or a container.
Check the installed program before writing deployment-specific options:
wkhtmltoimage --version
wkhtmltoimage --help
If it is not on the service’s PATH, find its absolute path and configure it explicitly, for example /usr/bin/wkhtmltoimage. The upstream project says the tools run headlessly without a display service. Confirm that statement against your actual build and runtime, especially for packaged builds with different dependencies. The project’s archived status means you should treat the renderer as legacy software and evaluate its compatibility for your use case; archive status alone does not establish a specific vulnerability.
2. Capture a URL with PHP
This PHP CLI example uses array-form proc_open(), supported since PHP 7.4.0. It is written for Unix-like systems: set $binary and the writable output directory to match your deployment. It uses only the basic input and output arguments, avoiding unverified renderer switches.
<?php
// capture.php — run with: php capture.php https://example.com
$binary = '/usr/bin/wkhtmltoimage';
$outputDirectory = __DIR__ . '/screenshots';
$url = $argv[1] ?? '';
if (!is_file($binary) || !is_executable($binary)) {
fwrite(STDERR, "Renderer not found or not executable: {$binary}\n");
exit(1);
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
fwrite(STDERR, "Pass a valid URL as the first argument.\n");
exit(1);
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
fwrite(STDERR, "Only HTTP and HTTPS URLs are allowed.\n");
exit(1);
}
if (!is_dir($outputDirectory) && !mkdir($outputDirectory, 0750, true)) {
fwrite(STDERR, "Could not create output directory.\n");
exit(1);
}
if (!is_writable($outputDirectory)) {
fwrite(STDERR, "Output directory is not writable.\n");
exit(1);
}
$output = $outputDirectory . '/capture-' . bin2hex(random_bytes(8)) . '.png';
$command = [$binary, $url, $output];
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) {
fwrite(STDERR, "Could not start wkhtmltoimage.\n");
exit(1);
}
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$exitCode = proc_close($process);
if ($exitCode !== 0 || !is_file($output) || filesize($output) === 0) {
@unlink($output);
fwrite(STDERR, "Capture failed (exit {$exitCode}).\n");
if ($stderr !== '') fwrite(STDERR, $stderr);
if ($stdout !== '') fwrite(STDERR, $stdout);
exit(1);
}
echo $output . PHP_EOL;
For a web application, do not accept arbitrary destinations just because a URL passes syntax validation. A server-side screenshot endpoint can create server-side request forgery (SSRF) risk if callers can make it request internal services. Apply an allowlist or destination policy appropriate to the app, account for redirects and DNS resolution, and keep the renderer’s network access constrained. Generate output names on the server; do not let a request choose arbitrary filesystem paths.
proc_open() array commands bypass the shell, but callers still control what the invoked program receives. In particular, do not let untrusted input supply arbitrary options or replace the executable. The PHP documentation for proc_open() describes array commands from PHP 7.4.0 onward and notes platform-specific argument behavior.
3. Capture local HTML
Write the HTML to a controlled file and pass that file as the input. Use an absolute path to avoid relying on the PHP worker’s current directory:
<?php
$binary = '/usr/bin/wkhtmltoimage';
$htmlFile = realpath(__DIR__ . '/report.html');
$output = __DIR__ . '/screenshots/report-' . bin2hex(random_bytes(6)) . '.png';
if ($htmlFile === false || !is_file($htmlFile)) {
throw new RuntimeException('HTML input file does not exist.');
}
if (!is_dir(dirname($output)) || !is_writable(dirname($output))) {
throw new RuntimeException('Output directory is unavailable.');
}
$process = proc_open(
[$binary, $htmlFile, $output],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
if (!is_resource($process)) {
throw new RuntimeException('Could not start wkhtmltoimage.');
}
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
$exitCode = proc_close($process);
if ($exitCode !== 0 || !is_file($output) || filesize($output) === 0) {
@unlink($output);
throw new RuntimeException("Render failed ({$exitCode}): {$stderr} {$stdout}");
}
echo $output, PHP_EOL;
Local HTML may reference stylesheets, fonts, scripts, and images through relative paths. Test with the same file layout and permissions used in production. If the page loads external assets, the renderer also needs network access to those hosts. Avoid placing sensitive files in a directory that a caller can choose or influence.
4. Handle arguments safely on older PHP versions
Array-form commands for proc_open() require PHP 7.4 or later. If you must use a shell-based function, escape each dynamic argument separately with escapeshellarg(). Keep the executable path fixed and trusted. Do not escape an entire assembled command as one argument.
<?php
$binary = '/usr/bin/wkhtmltoimage';
$url = 'https://example.com';
$output = '/var/app/screenshots/example.png';
$command = escapeshellarg($binary)
. ' ' . escapeshellarg($url)
. ' ' . escapeshellarg($output)
. ' 2>&1';
exec($command, $lines, $exitCode);
if ($exitCode !== 0 || !is_file($output) || filesize($output) === 0) {
throw new RuntimeException("Capture failed: " . implode("\n", $lines));
}
PHP documents escapeshellarg() for escaping an individual shell argument. It does not validate that a URL is safe, constrain where the renderer can connect, or decide whether an argument should be permitted.
5. Choose output and renderer settings carefully
The basic command shape is wkhtmltoimage [options] INPUT OUTPUT. Use wkhtmltoimage --help and the documentation matching the installed version before adding switches. The gathered upstream material establishes the tool’s command-line rendering purpose and image conversion support, but not a complete current list of CLI options or universal behavior for dimensions, full-page height, JavaScript timing, or image quality.
| Need | What to do | What to verify |
|---|---|---|
| PNG, JPEG, or another format | Choose an output filename with the intended extension only if the installed build supports it. | Confirm supported output formats in that build’s help or matching documentation; inspect the resulting file rather than trusting its suffix. |
| Viewport or image dimensions | Use only sizing options documented by the deployed version. | Check whether dimensions describe the viewport, rendered page, or final image and test long pages. |
| JavaScript-rendered content | Test the real page under the target runtime. | Verify that scripts execute and that the capture waits until the content exists; do not assume a delay option or its semantics. |
| Authenticated pages | Use a controlled test account and a deliberate authentication approach supported by your build. | Do not put secrets in logs or expose them through user-controlled arguments. |
| Local files and linked assets | Run with the intended filesystem permissions and resource layout. | Check file access behavior and build-specific local-file restrictions. |
The upstream repository includes an image API example that selects JPEG output, but that is not a guarantee that every CLI build accepts the same settings. Use version-matched references for the exact switches you deploy.
6. Add operational safeguards
- Set a wall-clock limit. A child process can take longer than a web request should wait. Run captures in a queue for work that may be slow, and configure a process supervisor or job-level timeout appropriate to your environment. The examples above do not implement a timeout.
- Limit concurrency. Rendering consumes CPU and memory. Bound simultaneous jobs and use a queue when requests can arrive in bursts.
- Manage temporary files. Use unique names, restrictive directory permissions, and cleanup rules. Avoid collisions when two workers capture the same page.
- Capture diagnostics. Record exit status and useful stderr, while redacting tokens, cookies, and sensitive URLs.
- Pin and identify the build. Record the binary version and operating system in deployment metadata. Recheck output after changing the binary, OS image, fonts, or dependencies.
- Check resource limits. Restrict CPU, memory, runtime, filesystem access, and network destinations at the worker or container level where available.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Could not start wkhtmltoimage |
Wrong executable path, missing execute permission, missing runtime dependency, or PHP process restrictions. | Check the binary path and permissions as the PHP service user; check deployment logs and PHP configuration for disabled process functions. |
| Exit code is nonzero and no image exists | Input could not load, output directory is unavailable, invocation failed, or the binary reported a rendering error. | Inspect stderr and exit code; try the same input and output locations as the service user; check DNS, TLS, permissions, and the binary’s own help. |
| Output file exists but is empty | Rendering failed before writing useful output, or output handling was interrupted. | Require both a successful exit code and a non-zero file size; remove failed output and retain diagnostics. |
| Page is blank or missing content | The page depends on scripts, delayed data, inaccessible assets, authentication, or browser behavior unsupported by the installed renderer. | Inspect the page from the renderer’s network environment; test the exact build and target page. Do not assume modern browser behavior from a Qt WebKit renderer. |
| Image differs from the browser | Rendering engine, fonts, viewport, asset loading, or page state differs from the comparison environment. | Make the runtime and inputs reproducible; compare representative pages after binary or OS changes; use a renderer that matches required page behavior if fidelity is insufficient. |
| “Permission denied” for output | The PHP service user cannot write to the selected directory. | Use a dedicated writable directory with restricted permissions; do not make the whole application tree world-writable. |
| Arguments with spaces or punctuation break | Shell command was assembled without per-argument escaping, or platform argument parsing differs. | Prefer PHP 7.4+ array-form proc_open(); otherwise apply escapeshellarg() to each dynamic argument and test on the target OS. |
| Capture hangs or overloads the web worker | Slow page, stalled network, expensive scripts, or too many concurrent renders. | Use bounded worker jobs, apply an external timeout and resource limits, and restrict concurrency. The sample code does not enforce a timeout. |
8. Performance, reliability, and cost
Each screenshot starts an external process and loads a page with its assets. That means latency and resource use depend on the page, the server, and the installed build; this guide makes no benchmark claim. For occasional captures, synchronous execution may be adequate. For user-facing requests or batches, a queue helps keep rendering time and resource use out of the request path.
Reliability depends on more than a zero exit code: verify the output exists, has non-zero size, and is in the format your downstream code expects. Retain enough diagnostics to distinguish a renderer failure from a page-load problem. Since the upstream repository is archived, validate compatibility during OS, PHP, and binary upgrades and decide whether a legacy Qt WebKit renderer meets your ongoing maintenance needs.
The subprocess route has no per-capture API charge in the code shown, but operating it still uses server resources and requires deployment, monitoring, updates, and capacity. Compare that operational cost with a managed screenshot service if you capture at scale or do not want to maintain the browser runtime.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. One GET request returns an image or PDF. The endpoint accepts a URL and supports PNG, JPEG, and WebP output. See the ScreenshotNeo API documentation for request parameters and response behavior.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.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://example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card required.
FAQ
Does PHP render the page itself?
No. PHP starts the external wkhtmltoimage program and checks its result. The renderer performs the page loading and image conversion.
Can I use this with PHP 7.3?
You cannot use array-form proc_open() on PHP 7.3. Upgrade to PHP 7.4 or later where possible; otherwise use a carefully constructed shell command with each dynamic argument escaped individually.
Is wkhtmltoimage suitable for every modern website?
No universal compatibility claim is safe. It uses Qt WebKit and the upstream repository is archived. Test the pages and features your application depends on against the exact binary and deployment environment.
Can I return the screenshot directly from a PHP endpoint?
Yes. After validating successful process completion and the output file, a web endpoint can set an appropriate content type and stream the file. Keep capture authorization, request limits, SSRF protections, and file cleanup in the endpoint design.
Sources
- wkhtmltopdf project repository: tool description, Qt WebKit, headless operation, and archived/read-only status.
- PHP proc_open() documentation: array-form commands and version support.
- PHP escapeshellarg() documentation: escaping individual shell arguments.


