Using PHP Symfony with a Screenshot Capture API
Call a screenshot API from Symfony, save PNG or PDF bytes safely, handle errors, and automate reliable captures with PHP.
Symfony can call a screenshot capture API with the HttpClient component, receive PNG, JPEG, WebP, or PDF bytes, and save or stream the result. Keep the API key on the server, check the status before treating the response as an image, and use an explicit timeout for page rendering.
1. Install Symfony HttpClient
Install the official Symfony client in your application:
composer require symfony/http-client
Symfony registers the http_client service and supports autowiring Symfony\Contracts\HttpClient\HttpClientInterface. The component supports PHP stream wrappers and cURL, JSON request bodies, status inspection, retries, concurrent requests, and streaming responses. See the Symfony HttpClient documentation.
2. Store the provider key as a secret
Do not put an API key in browser JavaScript, public HTML, a repository, logs, or a query string. Put it in an environment variable or your deployment secret store:
# .env.local (never commit this file)
SCREENSHOT_API_KEY=replace-with-your-key
Expose it through Symfony configuration rather than reading it in a controller:
# config/services.yaml
services:
App\Service\ScreenshotClient:
arguments:
$apiKey: '%env(SCREENSHOT_API_KEY)%'
3. Create a reusable screenshot service
The following service uses a provider with a POST endpoint, Bearer authentication, JSON input, and a direct binary success response. Replace the endpoint and options with the provider you use.
<?php
// src/Service/ScreenshotClient.php
namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ScreenshotClient
{
public function __construct(
private HttpClientInterface $http,
private string $apiKey,
) {}
public function capture(
string $url,
string $format = 'png',
string $height = 'full',
): string {
$response = $this->http->request('POST', 'https://api.screenshotengine.com/v1/screenshot', [
'headers' => [
'Authorization' => 'Bearer '.$this->apiKey,
'Content-Type' => 'application/json',
'Accept' => 'image/png, image/jpeg, image/webp, application/pdf',
],
'json' => [
'url' => $url,
'format' => $format,
'height' => $height,
],
'timeout' => 120,
]);
$status = $response->getStatusCode();
if ($status < 200 || $status >= 300) {
throw new \RuntimeException(
'Screenshot API failed: '.$status.' '.$response->getContent(false)
);
}
return $response->getContent();
}
}
Symfony’s json option serializes the request and sets the JSON content type. getContent() returns the response body and throws for failed statuses unless you pass false. The example checks the status first so an error JSON document is never written as a PNG.
4. Save the returned PNG, JPEG, WebP, or PDF
Write the bytes to a controlled directory and check that the write succeeds:
$bytes = $screenshotClient->capture($targetUrl, 'png', 'full');
$path = $this->getParameter('kernel.project_dir').'/var/screenshots/page.png';
if (false === file_put_contents($path, $bytes)) {
throw new \RuntimeException('Could not write screenshot file.');
}
Use a generated filename rather than a user-supplied path. For PDF output, use a .pdf extension and keep the same binary handling.
5. Return the file from a Symfony controller
Inject the service and return a Response with the correct media type:
<?php
namespace App\Controller;
use App\Service\ScreenshotClient;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ScreenshotController
{
#[Route('/screenshots/{id}.png', methods: ['GET'])]
public function show(string $id, ScreenshotClient $screenshots): Response
{
// Resolve $id to an allow-listed URL in your application.
$bytes = $screenshots->capture('https://example.com', 'png', 'full');
return new Response($bytes, Response::HTTP_OK, [
'Content-Type' => 'image/png',
'Content-Disposition' => 'inline; filename="screenshot.png"',
'Cache-Control' => 'private, max-age=300',
]);
}
}
Use application/pdf for PDF responses. If you need a download, change Content-Disposition to attachment.
6. Complete cURL, Python, and Node.js examples
cURL
curl -X POST "https://api.screenshotengine.com/v1/screenshot" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
-H "Content-Type: application/json" \
--data '{"url":"https://example.com","format":"png","height":"full"}' \
-o screenshot.png
Python
import os
import requests
response = requests.post(
"https://api.screenshotengine.com/v1/screenshot",
headers={
"Authorization": f"Bearer {os.environ['SCREENSHOT_API_KEY']}",
"Content-Type": "application/json",
},
json={"url": "https://example.com", "format": "png", "height": "full"},
timeout=120,
)
response.raise_for_status()
with open("screenshot.png", "wb") as file:
file.write(response.content)
Node.js
const fs = require('node:fs/promises');
const res = await fetch('https://api.screenshotengine.com/v1/screenshot', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SCREENSHOT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: 'https://example.com', format: 'png', height: 'full' }),
});
if (!res.ok) throw new Error(`Screenshot API failed: ${res.status} ${await res.text()}`);
await fs.writeFile('screenshot.png', Buffer.from(await res.arrayBuffer()));
7. Capture options to decide before coding
| Requirement | Decision |
|---|---|
| Output | PNG, JPEG, WebP, or PDF; confirm whether success is binary bytes or JSON containing a URL. |
| Page size | Viewport dimensions versus full-page height. Full-page captures may be much taller and slower. |
| Authenticated pages | Check whether the provider accepts cookies, custom headers, user agents, or target-site Authorization. A public-URL-only service cannot use a visitor’s login session. |
| Rendering | Check support for CSS, JavaScript, delayed rendering, network idle, lazy images, and element selectors. |
| Operations | Review quotas, rate limits, caching, batch requests, webhooks, and maximum timeout. |
Screenshot API providers commonly differ on these controls. Compare response mode, authentication placement, full-page behavior, PDF support, CSS and JavaScript hooks, batch support, timeout limits, target-page access, caching, and pricing before switching.
8. Validate target URLs and protect your application
If a user supplies a URL, validate it before submitting it. Allow only http and https, reject credentials in the URL, and consider an allow-list of domains. This prevents your application from becoming an unrestricted fetch proxy. Do not pass arbitrary user input into a local filename, shell command, or log line.
9. Handle errors correctly
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 or 403 | Missing, invalid, or incorrectly formatted key. | Check the deployment secret and the required authentication header; never move the key into a public URL. |
| 400 | Invalid URL, unsupported format, or invalid option. | Read getContent(false), validate input, and use the provider’s documented option values. |
| HTML or JSON saved as an image | Error body was written without checking status. | Check status and, where available, the Content-Type before saving bytes. |
| Timeout | Slow target, heavy JavaScript, full-page rendering, or provider queue. | Set an explicit timeout, reduce capture scope, use a queue, and retry only transient failures. |
| Blank or incomplete page | Capture happened before JavaScript or lazy images finished. | Use the provider’s wait, delay, network-idle, or full-page options when available. |
| Login page instead of content | The provider cannot access your session or target requires authentication. | Use a provider that supports the required cookies or headers, or capture from an authorized internal worker. |
| Write failure | Missing directory or insufficient filesystem permissions. | Create the directory during deployment and check the return value from file_put_contents(). |
10. Timeouts, retries, queues, and concurrency
Set a timeout long enough for the slowest expected render, but bounded so a web request cannot hang indefinitely. Symfony supports configurable retries for transient status codes. Retry idempotent captures with exponential backoff and a small attempt limit; do not retry authentication or validation errors. Record the provider status and error body, while redacting keys and cookies.
For many URLs, enqueue jobs and persist queued, running, succeeded, and failed states. Symfony HttpClient also supports concurrent requests and streaming responses, but respect the provider’s rate limits. Cache identical captures when freshness allows it; cache keys should include the URL and every visual option that changes the output.
11. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a clean PNG, JPEG, WebP, or PDF. Before capture it accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo API documentation for the current request options.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo also supports full-page capture with lazy images loaded, CSS-selector element capture, dark mode, 12 device presets and custom viewports, retina scale, PDF paper size and margins, custom CSS and JavaScript, clicks, selector waits and delays, network-idle waits, blocking ads, trackers, requests or resource types, custom headers, cookies, user agents and Authorization, timezone, geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. Its parameter names are compatible with those used by other screenshot APIs, which helps when migrating.
An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients. The Free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
12. Cost and reliability checklist
- Count rendered pages, retries, and batch items against the provider’s quota.
- Use caching for unchanged URLs and choose a TTL that matches your freshness needs.
- Prefer asynchronous jobs for large full-page or PDF workloads.
- Track status, latency, output size, and error category without logging secrets.
- Keep a provider-specific adapter so changing endpoint, authentication, or response mode does not affect controllers.
FAQ
Can Symfony return a screenshot without saving it?
Yes. Return the binary string from a Symfony Response and set the matching Content-Type.
How do I know whether the response is an image or an error?
Check the HTTP status first. Successful binary responses and JSON error responses must be handled separately.
Can a screenshot API capture a page behind my login?
Only if that provider supports the required cookies, headers, or authentication flow. Public-URL-only services cannot use a user’s browser session.
Should I call the API from browser JavaScript?
No. Keep the key in Symfony’s server-side configuration and call the provider from your backend.


