ScreenshotNeo

BlogHow-to

How to Generate Website Thumbnails for a Directory with Laravel

Capture a thumbnail for each directory entry with Laravel, store it reliably, and queue refreshes without tying up web requests.

By the ScreenshotNeo team4 October 202611 min read

To generate website thumbnails for a directory with Laravel, capture each entry’s website URL with Spatie’s Laravel Screenshot package, save the result under a stable path, and queue the capture when the directory has more than a few entries. A screenshot is the thumbnail source; your application still decides which URLs to capture, where files go, when they should be refreshed, and how failures are retried.

Use a URL capture when the directory should show the target site as it renders in a browser. Use an HTML capture when you want a controlled preview card built by your own application. The package supports both.

1. Choose a capture source

Source Use it when Trade-off
Target URL The thumbnail should represent the live site. Third-party pages can be slow, unavailable, authenticated, or visually different over time.
HTML preview You want consistent branding and layout for every directory entry. You are capturing your preview design, not the target site itself. HTML-provided JavaScript can execute before capture.

For a directory card, start with a fixed viewport and a consistent crop. A viewport capture shows the top of a page at a chosen browser size. A full-page capture includes the entire scrollable page and is often too tall to work well as a small card. An element capture targets a selector on the page, which is useful when the target has a stable hero or preview section.

2. Install Laravel Screenshot and select a driver

The current package documentation lists PHP 8.4+ and Laravel 12+ as requirements. Install the package with Composer:

composer require spatie/laravel-screenshot

The default Browsershot driver uses Chromium and requires spatie/browsershot, Node.js, and a Chrome or Chromium binary. Install the Laravel integration and consult the current Browsershot requirements for runtime dependencies in your operating system or container:

composer require spatie/browsershot

On a server, the PHP worker must be able to find the Node and browser binaries and write to its temporary and output directories. If those paths are not on the worker’s PATH, configure them in the package configuration.

For remote rendering, the Cloudflare driver uses Cloudflare Browser Rendering and does not need Node.js or Chrome installed on the Laravel host. Create a Cloudflare API token with the Account.Browser Rendering permission, then configure the driver and credentials:

LARAVEL_SCREENSHOT_DRIVER=cloudflare
CLOUDFLARE_API_TOKEN=your-api-token
CLOUDFLARE_ACCOUNT_ID=your-account-id

Check Cloudflare’s current account, API, and plan requirements before deploying. The package documentation also notes rate limits and that some Chrome-specific Browsershot options may not be supported by the remote driver. Switch the default through LARAVEL_SCREENSHOT_DRIVER or package configuration; a particular capture can select a driver with ->driver('cloudflare').

Optionally publish the package config to adjust defaults and runtime paths:

php artisan vendor:publish --tag=screenshot-config

The documented defaults include a 1280×800 viewport, 2× device scale factor, PNG output, and waiting for networkidle2. These are package defaults, not a promise of identical rendering time or output across all providers.

3. Capture one directory entry

The facade accepts a URL and saves the image. Pick a filename extension to select PNG, JPEG, or WebP:

<?php

use Spatie\LaravelScreenshot\Facades\Screenshot;

Screenshot::url('https://example.com')
    ->size(640, 400)
    ->deviceScaleFactor(1)
    ->save(storage_path('app/public/site-thumbnails/example-com.webp'));

This is a basic synchronous example. It is useful for a single capture or a local command, but do not run a large directory’s full set of captures inside a normal page request. Browser startup and page rendering can take long enough to tie up a web worker.

4. Generate thumbnails for directory records

Keep a stable output path per record, such as site-thumbnails/{entry-id}.webp. The path should not be derived directly from an untrusted URL: URLs may contain characters unsuitable for filenames, and different URLs can normalize to the same name. A database ID or generated UUID avoids that ambiguity.

A small Artisan command can capture entries synchronously for a one-off import or a small directory. This example assumes a DirectoryEntry model with a validated url column and a thumbnail_path column:

<?php

namespace App\Console\Commands;

use App\Models\DirectoryEntry;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Spatie\LaravelScreenshot\Facades\Screenshot;
use Throwable;

class GenerateDirectoryThumbnails extends Command
{
    protected $signature = 'directory:thumbnails';
    protected $description = 'Generate missing website thumbnails';

    public function handle(): int
    {
        DirectoryEntry::query()
            ->whereNull('thumbnail_path')
            ->orderBy('id')
            ->chunkById(25, function ($entries): void {
                foreach ($entries as $entry) {
                    $path = "site-thumbnails/{$entry->id}.webp";

                    try {
                        Screenshot::url($entry->url)
                            ->size(640, 400)
                            ->deviceScaleFactor(1)
                            ->quality(80)
                            ->save(Storage::disk('public')->path($path));

                        $entry->thumbnail_path = $path;
                        $entry->save();
                    } catch (Throwable $exception) {
                        report($exception);
                        $this->warn("Capture failed for directory entry {$entry->id}");
                    }
                }
            });

        return self::SUCCESS;
    }
}

Run it with:

php artisan directory:thumbnails

For production-sized directories, use a queued job per entry or a bounded batch of entries. The model update and error handling above are application choices, not features the screenshot package adds automatically. Ensure the Laravel public disk is configured for your deployment and that generated files are publicly served or copied to the storage service your application uses.

5. Queue captures and track their state

Laravel Screenshot provides saveQueued(), callbacks, queue and connection selection, disk storage, and a configurable job class. The method dispatches generation to a background queue so the request that schedules the work can return promptly:

<?php

use App\Models\DirectoryEntry;
use Illuminate\Support\Facades\Log;
use Spatie\LaravelScreenshot\Facades\Screenshot;

$entry = DirectoryEntry::findOrFail($entryId);
$path = "site-thumbnails/{$entry->id}.webp";

$entry->thumbnail_status = 'pending';
$entry->save();

Screenshot::url($entry->url)
    ->size(640, 400)
    ->deviceScaleFactor(1)
    ->quality(80)
    ->disk('public')
    ->saveQueued($path, connection: 'redis', queue: 'screenshots')
    ->then(function (string $savedPath, ?string $diskName) use ($entry): void {
        $entry->thumbnail_path = $savedPath;
        $entry->thumbnail_status = 'ready';
        $entry->thumbnail_error = null;
        $entry->save();
    })
    ->catch(function (Throwable $exception) use ($entry): void {
        $entry->thumbnail_status = 'failed';
        $entry->thumbnail_error = $exception->getMessage();
        $entry->save();

        Log::warning('Website thumbnail capture failed', [
            'directory_entry_id' => $entry->id,
            'exception' => $exception->getMessage(),
        ]);
    });

Run a Laravel queue worker that listens to the queue you selected, for example screenshots. Set the worker’s timeout and retry policy to match your capture environment. The package docs show that its queued job can be replaced with a custom class to set values such as tries, timeout, and backoff; those values are choices for your application, not universal defaults.

For recoverable generation, model the lifecycle explicitly: pending, ready, and failed are a useful starting point. Keep the last error and attempt timestamp for diagnosis. Avoid dispatching duplicate work when a capture for the same entry and source URL is already pending. The package documents that saveQueued() cannot be combined with withBrowsershot(), because the closure may not serialize reliably for a queued job.

6. Choose size, format, and capture behavior

Choice Laravel Screenshot option Guidance for directory cards
Viewport size width(), height(), or size() Use one fixed aspect ratio so cards align. A smaller viewport can reduce output pixels; test the crop with representative sites.
Format File extension passed to save(): .png, .jpg/.jpeg, .webp PNG preserves crisp edges but can be larger. JPEG and WebP support a quality setting; compare appearance and storage needs for your content.
Quality quality(0–100) Applies to JPEG and WebP. Tune against actual directory thumbnails rather than assuming one quality level suits all.
Device scale deviceScaleFactor() Higher values produce more pixels and can improve sharpness on high-density screens, at the cost of larger files and more image processing.
Full page fullPage() Captures the scrollable page. Usually choose a viewport for a compact directory card; full-page images need a deliberate crop or resize afterward.
Element selector('.hero') Captures a matching page element. Third-party markup can change, so provide a fallback when the selector is absent.
Clip clip(x, y, width, height) Captures a rectangular region at known coordinates; it may be brittle when page layout changes.
Transparent background omitBackground() Useful for isolated elements saved as PNG; usually unnecessary for a complete website thumbnail.

Wait behavior matters on JavaScript-heavy sites. The documented default waits for network idle. You can wait for a particular state, a selector, or a duration:

Screenshot::url($entry->url)
    ->size(640, 400)
    ->waitForSelector('main')
    ->save($destination);

Screenshot::url($entry->url)
    ->waitForTimeout(1500)
    ->save($destination);

A fixed delay is simple but can waste time on fast pages and still be too short on slow ones. A selector is more meaningful when the page has a reliable marker for the content you need. Persistent network activity can make a strict idle wait problematic, so choose the wait strategy based on the page behavior and supported driver.

For advanced Chromium-specific control, the package offers withBrowsershot(); the underlying Browsershot image documentation covers options such as full-page capture, element selection, clipping, device emulation, and output format. Check driver compatibility before relying on those options with Cloudflare.

7. Refresh policy, storage, and safe URL handling

Decide what makes a thumbnail stale. Common policies include regenerating when an administrator changes the URL, refreshing on a schedule, or regenerating only when no thumbnail exists. Save the source URL or a hash of it alongside the generated file so a changed URL does not silently keep showing an old image.

  • Stable paths: Use a record ID plus a version or content hash when you want cache-busting after a refresh. If you overwrite a stable public path, account for browser or CDN caching.
  • Storage: The package supports saving to Laravel disks, including queued disk saves. Keep path conventions consistent between synchronous and queued flows.
  • Failure display: Show a neutral placeholder while a capture is pending or has failed. Do not serve a partial file as a successful thumbnail.
  • Retries: Retry transient network and provider failures with bounded attempts and backoff. Permanent URL errors should be surfaced for correction rather than retried indefinitely.
  • URL validation: If users can submit directory URLs, validate schemes and restrict destinations according to your product’s network security policy. A browser that can fetch arbitrary URLs may reach internal services if your infrastructure permits it; avoid exposing unrestricted capture as a public endpoint.
  • Concurrency: Set queue throughput and provider throttling based on your deployment capacity and any remote-driver limits. The package documentation does not establish a universal safe concurrency number.

8. Performance, reliability, and cost

Each capture involves page loading and rendering, so total work grows with the number of entries and with the complexity of their pages. A queue keeps that work out of the user’s request path, but it does not make rendering instantaneous. Use bounded batches, avoid recapturing unchanged entries, and monitor queue age, failure counts, and output storage.

Local Browsershot gives you a browser runtime to maintain on your own host; Cloudflare moves rendering to a remote service and introduces its account limits and API dependency. The official documentation reviewed here does not provide an apples-to-apples speed, fidelity, or total-cost comparison. Check the current pricing and operational terms for the deployment you choose.

For a test suite, Laravel Dusk can take screenshots, responsive screenshot sets, and element screenshots as browser-test artifacts. That makes it useful for checking your own directory UI. It is a testing workflow, while Laravel Screenshot is directly designed for application-driven URL or HTML captures and saving. Package screenshot fakes and assertions can verify that your code requested a capture; they do not prove a real browser produced the expected pixels.

9. Troubleshooting common failures

Symptom Likely cause What to check
Node or Chrome executable not found The PHP process environment differs from your interactive shell, or the binary is not installed. Install the Browsershot runtime dependencies and configure the Node, Chrome, and related paths in the published package config.
Browser fails to launch in a container Missing system libraries, permissions, sandbox restrictions, or an incompatible browser installation. Review the Browsershot requirements for the host OS and inspect worker logs. Use the documented no-sandbox configuration only when it matches your security and deployment setup.
Screenshot is blank or content is missing The page has not rendered the required content yet, blocks the browser, or depends on JavaScript/resources that failed. Check the target URL from the worker environment; try a meaningful selector wait or a measured delay; inspect whether remote assets are reachable.
Capture times out or takes too long The target is slow, a network-idle condition never arrives, or the worker timeout is too short. Choose a suitable wait strategy, review queue and worker timeouts, and record failing URLs. Do not increase timeouts without also bounding retries and workload.
Image format does not match expectation The output extension selects the format, or the chosen driver does not support a requested option. Use a supported extension and quality setting, and confirm driver support for advanced options.
Queued capture never completes No worker is listening to the configured queue/connection, or the job repeatedly fails. Check Laravel queue configuration, worker supervision, failed jobs, and the package callback or job logs.
Image saved but not visible in the directory The database path was not updated, the disk URL is not public, or the frontend points at a different disk/path. Verify the completion callback, disk configuration, public URL generation, and file permissions.
Capture fails only with remote driver Cloudflare credentials, permissions, account configuration, limits, or feature differences. Check token scope, account ID, current Browser Rendering limits, and whether the option is supported by the remote driver.

Or skip the browser setup

For a single URL, ScreenshotNeo returns the image directly from one GET request. See the ScreenshotNeo API documentation for parameters and response behavior:

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,
)
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(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo removes cookie banners, popups, and chat widgets 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. See ScreenshotNeo or sign up free for 1,000 screenshots a month, no card required.

FAQ

Should a directory thumbnail show a live website or a preview card?

Use a URL capture when visitors need to recognize the actual site. Use HTML capture when predictable layout and your own branding matter more than representing the live page.

Can I capture a directory of hundreds of URLs in a web request?

Do not make a normal request wait for a large batch. Dispatch bounded queued work and let a worker process entries in the background.

Does Laravel Dusk generate production thumbnails?

Dusk is documented for browser testing and screenshot artifacts. Use Laravel Screenshot for the application workflow that captures URLs or HTML and saves images.

Does a fake screenshot test validate the thumbnail’s appearance?

No. A fake can assert that a capture was requested with expected inputs. Validate actual rendered appearance separately in a browser-based visual check.

References