Browsershot is the well-known way to take screenshots in Laravel, and it drags Node.js, Puppeteer and a Chrome binary into your PHP deployment. On shared hosting or a small container that is often the end of the story. The alternative is to treat capture as a remote call: one request with the Http facade, the rest is normal Laravel.
Configuration
// config/services.php
'snaprender' => [
'key' => env('SNAPRENDER_API_KEY'),
'endpoint' => 'https://app.snap-render.com/v1/screenshot',
],
# .env
SNAPRENDER_API_KEY=sk_live_your_key
Screenshots as an API call
Send a GET request, get a PNG back. Ads and cookie banners blocked by default.
200 free renders a month. Paid plans start at $9.
A controller action
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class ScreenshotController extends Controller
{
public function show(Request $request)
{
$request->validate(['url' => ['required', 'url']]);
$response = Http::withHeaders(['X-API-Key' => config('services.snaprender.key')])
->timeout(60)
->get(config('services.snaprender.endpoint'), [
'url' => $request->string('url'),
'format' => 'png',
'full_page' => 'true',
'block_cookie_banners' => 'true',
'cache_ttl' => 86400,
]);
if (! $response->successful()) {
return response()->json(['error' => 'capture failed', 'detail' => $response->body()], 502);
}
return response($response->body(), 200)
->header('Content-Type', 'image/png')
->header('Cache-Control', 'public, max-age=86400');
}
}
Route it and /screenshot?url=https://example.com streams a full-page PNG. timeout(60) keeps a slow page from tying up a PHP-FPM worker indefinitely.
A queued job with storage
Captures for bookmarks, monitoring or reports belong in the queue:
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class CapturePage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public string $url, public string $name) {}
public function handle(): void
{
$response = Http::withHeaders(['X-API-Key' => config('services.snaprender.key')])
->timeout(60)
->get(config('services.snaprender.endpoint'), [
'url' => $this->url,
'format' => 'jpeg',
'full_page' => 'true',
'cache_ttl' => 86400,
]);
if ($response->status() === 429) {
$this->release(60); // burst or quota exhausted: try again in a minute
return;
}
$response->throw();
Storage::disk('s3')->put("captures/{$this->name}.jpeg", $response->body());
}
}
Dispatch with CapturePage::dispatch($url, $name). Swap s3 for any disk in config/filesystems.php.
For lists of URLs, the API's batch endpoint takes up to 50 per request and calls a webhook when the set is done; a single job that submits the batch and a controller that receives the webhook replaces a loop of jobs.
Caching on both sides
cache_ttl on the request makes repeated captures of the same URL and options free: they come from the API's cache and do not count against the monthly quota. On your side, cache the storage path:
$path = Cache::remember("shot:$url", 86400, fn () => $this->capture($url));
PDFs and other formats
format=pdf returns a page image inside a PDF, which is right for archives and wrong for text-searchable documents. To render your own Blade output as an image or PDF, POST the HTML to the same endpoint instead of a URL (see the HTML to PDF API).
Related: PHP quickstart, batch screenshots, webhooks.
Frequently asked questions
Is this a replacement for Browsershot?
For capturing URLs, yes. Browsershot needs Node.js, Puppeteer and Chrome on the same server as PHP; this approach needs only an HTTP client. If you need to run custom JavaScript inside the page before capturing, Browsershot still has its place.
Where does the API key go?
In .env as SNAPRENDER_API_KEY, read through config/services.php. Never reference env() directly outside config files so config caching keeps working.
Which disk should captures use?
Any disk configured in config/filesystems.php. The example writes through Storage::disk(), so local, S3 and the rest work unchanged.
How do I handle rate limits?
A 429 response means the per-minute burst or monthly quota is used up. Queued jobs should release themselves with a delay; do not retry synchronously in a controller.