- Where automated screenshots earn their keep
- Option 1: headless Chrome from the command line
- Option 2: Browsershot and Puppeteer
- Option 3: a managed screenshot API
- Hiding cookie banners and sticky headers
- Waiting for the page to settle
- A scheduled capture command
- Picking between the three
Sooner or later you need screenshots of live web pages without a human pressing a key. A visual record of a client's homepage before and after a deploy, a preview image for a link, an archive of how a pricing page looked last quarter. Taking them by hand stops scaling at about three sites. This article covers three ways to automate the job from a Laravel application, then fixes the things that ruin most captures: cookie banners, lazy-loaded content and pages taller than the viewport.
Where automated screenshots earn their keep
Agencies use them as a weekly visual record of every client site, which settles "the site looked different last month" conversations in seconds. Product teams capture competitor pricing pages on a schedule. Content sites render rich previews of external links. Monitoring tools attach a screenshot to the alert so you can see the broken page, not just the failing status code.
All of these reduce to the same primitive: give me a PNG of this URL, reliably, from code. There are three sensible ways to get it.
Option 1: headless Chrome from the command line
Chrome ships with a screenshot mode. If the binary is installed on your server, you can drive it with Laravel's Process facade and nothing else:
use Illuminate\Support\Facades\Process;
$result = Process::run([
'google-chrome',
'--headless=new',
'--disable-gpu',
'--window-size=1440,900',
'--screenshot=' . storage_path('app/screenshots/example.png'),
'https://example.com',
]);
if (! $result->successful()) {
throw new RuntimeException($result->errorOutput());
}
This is the zero-dependency option and it is fine for one-off captures on a box you control. The drawbacks show up quickly. Chrome's CLI flags change between versions, there is no built-in way to capture the full scrollable page, and you cannot wait for a selector or inject CSS before the capture. You also now own a full browser install on your production server, with the dependency tree that implies.
Treat it as the duct tape option. It works, but the next two approaches exist because of its limits.
Option 2: Browsershot and Puppeteer
Browsershot is Spatie's wrapper around Puppeteer, and it is the standard answer for in-process screenshots in Laravel. You need Node on the server alongside PHP:
composer require spatie/browsershot
npm install puppeteer
The basic capture is one chain:
use Spatie\Browsershot\Browsershot;
Browsershot::url('https://example.com')
->windowSize(1440, 900)
->waitUntilNetworkIdle()
->save(storage_path('app/screenshots/example.png'));
Full-page captures and element crops are built in:
// The whole scrollable page, not just the viewport
Browsershot::url('https://example.com')
->windowSize(1440, 900)
->fullPage()
->save($path);
// Just the pricing table
Browsershot::url('https://example.com/pricing')
->select('.pricing-table')
->save($path);
Browsershot gives you the most control of the three options because you are scripting a real browser. The cost is operational. Puppeteer downloads its own Chromium build, which is a large binary your deploys now ship or your servers cache. Each render holds a browser instance in memory, so a queue worker churning through fifty captures needs watching. And serverless or heavily containerised setups need extra work to get Chromium running at all.
If you run your own servers and render at modest volume, none of that is a dealbreaker. It is just maintenance you should price in before choosing this route.
Option 3: a managed screenshot API
The third approach moves the browser off your infrastructure entirely. A service such as HTML to Image runs the Chrome fleet and exposes it as an endpoint: you POST a URL, it returns a hosted PNG. From Laravel that is a plain Http call:
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders([
'X-API-Key' => config('services.html2img.key'),
])->post('https://app.html2img.com/api/screenshot', [
'url' => 'https://example.com',
'width' => 1440,
'height' => 900,
'fullpage' => true,
]);
$imageUrl = $response->json('url');
The response contains a CDN URL for the finished image. You can hot-link it directly or pull the bytes down and store them on your own disk, which we will do in the scheduled command below.
Two practical notes. Page load times on third-party sites are unpredictable, so for slow targets the screenshot endpoint supports a webhook_url parameter: the API POSTs the finished URL to you instead of making your request wait. And the target page must be publicly reachable. The render happens on their servers, so localhost and intranet URLs are out, which is the one thing Browsershot can do that an API cannot.
The trade is the inverse of option 2. You give up self-hosting and gain zero browser maintenance, which is why this route suits shared hosting, serverless and teams who would rather not babysit Chromium.
Hiding cookie banners and sticky headers
Most raw captures of real websites are ruined by a consent banner parked over the content. The fix is the same in both serious options: inject CSS that hides the offending elements before the capture.
With Browsershot, use the addStyleTag option:
Browsershot::url('https://example.com')
->setOption('addStyleTag', json_encode([
'content' => '#cookie-banner, .cc-window, [class*="consent"] { display: none !important; }',
]))
->save($path);
With the API, the same rules go in the css parameter:
Http::withHeaders(['X-API-Key' => $key])
->post('https://app.html2img.com/api/screenshot', [
'url' => 'https://example.com',
'css' => '#cookie-banner, .cc-window, [class*="consent"] { display: none !important; }',
]);
The same trick removes sticky navigation bars from full-page captures, where the header would otherwise be baked over the top of the content. Build up a small library of selectors for the sites you capture regularly. It is mundane work that pays off every single render.
Waiting for the page to settle
The second most common defect is capturing too early: lazy-loaded images still grey, charts half drawn, web fonts not yet swapped in. You have two levers.
A fixed delay is the blunt one. Browsershot has setDelay(2000) for two seconds, and the API takes ms_delay in the payload. Simple, but you always pay the full wait even when the page was ready sooner.
Waiting for a condition is better when the page gives you one. waitUntilNetworkIdle() in Browsershot holds the capture until requests stop, and the API's wait_for_selector parameter delays until a CSS selector exists in the DOM. If the page renders a .chart-loaded class when its JavaScript finishes, wait for that and the screenshot is correct every time at minimum cost.
A scheduled capture command
Here is the pattern that ties it together: a console command that walks a list of sites weekly and files a dated screenshot of each. This version uses the API so it runs anywhere PHP runs, but the loop body swaps cleanly for a Browsershot call.
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class CaptureSiteScreenshots extends Command
{
protected $signature = 'screenshots:capture';
protected $description = 'Capture a dated screenshot of each configured site';
public function handle(): int
{
foreach (config('screenshots.sites') as $name => $url) {
$response = Http::withHeaders([
'X-API-Key' => config('services.html2img.key'),
])->post('https://app.html2img.com/api/screenshot', [
'url' => $url,
'width' => 1440,
'height' => 900,
'fullpage' => true,
'css' => '#cookie-banner, [class*="consent"] { display: none !important; }',
]);
if (! $response->successful()) {
$this->error("{$name} failed: " . $response->body());
continue;
}
Storage::put(
"screenshots/{$name}/" . now()->format('Y-m-d') . '.png',
Http::get($response->json('url'))->body()
);
$this->info("Captured {$name}");
}
return self::SUCCESS;
}
}
The site list is a config file, so adding a client is a one-line change:
// config/screenshots.php
return [
'sites' => [
'acme' => 'https://acme.example',
'initech' => 'https://initech.example',
],
];
Schedule it in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('screenshots:capture')->weeklyOn(1, '06:00');
After a few months you have a dated visual archive of every site you look after, built from about forty lines of code.
Picking between the three
Use the Chrome CLI for quick one-off captures on a machine that already has Chrome, and nothing more ambitious than that. Use Browsershot when you want everything in-process, you run your own servers, and you are happy owning a Chromium install in exchange for the deepest control, including private URLs. Use a managed API when you want the captures without the browser maintenance, you are on shared or serverless hosting, or render volume is spiky enough that you would rather it was someone else's scaling problem.
Whichever you choose, set up the cookie banner CSS and the settle condition first. Those two details, not the rendering engine, are what separate a usable archive from a folder of half-loaded pages with consent popups over them.