Menu
laravelphptutorialscreenshot-apiqueues

Laravel Queue + Storage Screenshot Tutorial — Async Captures with Horizon

SnapSharp Team·April 26, 2026·4 min read

Laravel's queue system, Storage facade, and Eloquent events combine into one of the cleanest patterns for async media generation. You drop SnapSharp into a queued job, attach the result via Storage, and use a model event to trigger captures automatically. The pattern works on Forge, Vapor, and any VPS — wherever Laravel runs.

This tutorial walks through the full integration. You'll build a Site model that auto-captures a thumbnail when created, an admin re-capture endpoint, and dynamic OG images for blog posts.

Prerequisites

  • PHP 8.2+, Composer, and Laravel 11.
  • A Redis or database queue driver configured (QUEUE_CONNECTION=redis).
  • A free SnapSharp API key from snapsharp.dev/sign-up.
  • (Optional) Horizon for queue monitoring (composer require laravel/horizon).

Step 1: install the SDK

The PHP SDK is on Packagist as snapsharp/sdk.

composer require snapsharp/sdk

Add the API key to .env:

SNAPSHARP_API_KEY=sk_live_YOUR_API_KEY

Register it in config/services.php:

'snapsharp' => [
    'api_key' => env('SNAPSHARP_API_KEY'),
    'timeout' => env('SNAPSHARP_TIMEOUT', 60),
],

Step 2: a service binding

Register a singleton client in a service provider so dependency injection works everywhere. Edit app/Providers/AppServiceProvider.php:

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Snapsharp\Client as SnapsharpClient;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(SnapsharpClient::class, function ($app) {
            return new SnapsharpClient(
                apiKey: config('services.snapsharp.api_key'),
                timeout: config('services.snapsharp.timeout'),
            );
        });
    }
}

Now any controller, job, or service can type-hint SnapsharpClient and Laravel's container resolves it.

Step 3: a service class

Wrap common screenshot operations in a service so call sites stay clean:

<?php
// app/Services/SnapsharpService.php

namespace App\Services;

use Snapsharp\Client as SnapsharpClient;

class SnapsharpService
{
    public function __construct(private SnapsharpClient $client) {}

    public function captureScreenshot(string $url, array $options = []): string
    {
        $defaults = [
            'width' => 1280,
            'height' => 720,
            'format' => 'png',
            'block_ads' => true,
        ];
        return $this->client->screenshot($url, array_merge($defaults, $options));
    }

    public function generateOgImage(string $template, array $variables, array $options = []): string
    {
        $defaults = ['width' => 1200, 'height' => 630];
        return $this->client->ogImage(
            template: $template,
            variables: $variables,
            options: array_merge($defaults, $options),
        );
    }
}

Step 4: a Site model with auto-capture

Generate the migration and model:

php artisan make:model Site -m

database/migrations/..._create_sites_table.php:

public function up(): void
{
    Schema::create('sites', function (Blueprint $table) {
        $table->id();
        $table->string('url')->unique();
        $table->string('title');
        $table->string('screenshot_path')->nullable();
        $table->string('screenshot_status')->default('pending');
        $table->timestamp('captured_at')->nullable();
        $table->timestamps();
    });
}

app/Models/Site.php:

<?php
namespace App\Models;

use App\Jobs\CaptureSiteScreenshot;
use Illuminate\Database\Eloquent\Model;

class Site extends Model
{
    protected $fillable = ['url', 'title', 'screenshot_path', 'screenshot_status', 'captured_at'];
    protected $casts = ['captured_at' => 'datetime'];

    protected static function booted(): void
    {
        static::created(function (Site $site) {
            CaptureSiteScreenshot::dispatch($site);
        });

        static::updated(function (Site $site) {
            if ($site->wasChanged('url')) {
                CaptureSiteScreenshot::dispatch($site);
            }
        });
    }
}

The model events queue a screenshot capture whenever a Site is created or its URL changes. Controllers and admin tools never have to remember to dispatch the job manually.

Step 5: a queued job with retries

Generate the job:

php artisan make:job CaptureSiteScreenshot

app/Jobs/CaptureSiteScreenshot.php:

<?php
namespace App\Jobs;

use App\Models\Site;
use App\Services\SnapsharpService;
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\Storage;
use Illuminate\Support\Facades\Log;
use Snapsharp\Exceptions\SnapsharpException;

class CaptureSiteScreenshot implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $maxExceptions = 3;
    public int $timeout = 120;
    public array $backoff = [10, 30, 60, 120, 300];

    public function __construct(public Site $site) {}

    public function handle(SnapsharpService $snap): void
    {
        $this->site->update(['screenshot_status' => 'processing']);

        try {
            $imageBytes = $snap->captureScreenshot($this->site->url);
        } catch (SnapsharpException $exc) {
            // Permanent failures shouldn't retry
            if (in_array($exc->status, [400, 401, 403, 404], true)) {
                Log::warning("Permanent failure for site {$this->site->id}", ['exc' => $exc]);
                $this->site->update(['screenshot_status' => 'failed']);
                return;
            }
            throw $exc;
        }

        $path = "screenshots/site-{$this->site->id}.png";
        Storage::disk('public')->put($path, $imageBytes);

        $this->site->update([
            'screenshot_path' => $path,
            'screenshot_status' => 'ready',
            'captured_at' => now(),
        ]);

        Log::info("Captured screenshot for site {$this->site->id}");
    }

    public function failed(\Throwable $exception): void
    {
        $this->site->update(['screenshot_status' => 'failed']);
    }
}

The $backoff array gives Laravel an exponential schedule. $tries = 5 plus $backoff means retries at 10s, 30s, 60s, 120s, 300s. Plenty of breathing room for transient SnapSharp errors.

Step 6: controllers and views

app/Http/Controllers/SiteController.php:

<?php
namespace App\Http\Controllers;

use App\Jobs\CaptureSiteScreenshot;
use App\Models\Site;
use Illuminate\Http\Request;

class SiteController extends Controller
{
    public function index()
    {
        $sites = Site::where('screenshot_status', 'ready')
            ->latest('captured_at')
            ->paginate(20);
        return view('sites.index', compact('sites'));
    }

    public function show(Site $site)
    {
        return view('sites.show', compact('site'));
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'url' => 'required|url|max:500',
            'title' => 'required|string|max:200',
        ]);
        $site = Site::create($data);
        return redirect()->route('sites.show', $site)
            ->with('success', 'Site created. Screenshot will be captured shortly.');
    }

    public function recapture(Site $site)
    {
        CaptureSiteScreenshot::dispatch($site);
        return back()->with('success', 'Screenshot capture queued.');
    }
}

Routes in routes/web.php:

Route::resource('sites', SiteController::class);
Route::post('sites/{site}/recapture', [SiteController::class, 'recapture'])->name('sites.recapture');

resources/views/sites/show.blade.php:

@extends('layouts.app')

@section('content')
<article>
    <h1>{{ $site->title }}</h1>
    <p><a href="{{ $site->url }}" target="_blank">{{ $site->url }}</a></p>

    @if($site->screenshot_status === 'ready' && $site->screenshot_path)
        <img src="{{ Storage::disk('public')->url($site->screenshot_path) }}"
             alt="Screenshot of {{ $site->title }}"
             loading="lazy"
             style="max-width:100%">
    @elseif($site->screenshot_status === 'failed')
        <p>Capture failed.</p>
        <form method="POST" action="{{ route('sites.recapture', $site) }}">
            @csrf
            <button type="submit">Retry</button>
        </form>
    @else
        <p>Capturing screenshot…</p>
    @endif
</article>
@endsection

Step 7: dynamic OG images for blog posts

Add an OG image route for posts:

// routes/web.php
Route::get('/blog/{slug}/og.png', [PostController::class, 'ogImage'])->name('posts.og');

app/Http/Controllers/PostController.php:

public function ogImage(string $slug, SnapsharpService $snap)
{
    $post = Post::where('slug', $slug)->firstOrFail();

    $image = $snap->generateOgImage(
        template: 'blog-post',
        variables: [
            'title' => $post->title,
            'author' => $post->author->name,
            'date' => $post->published_at->format('Y-m-d'),
            'tag' => $post->tags->first()?->name ?? '',
        ],
    );

    return response($image, 200, [
        'Content-Type' => 'image/png',
        'Cache-Control' => 'public, max-age=86400, stale-while-revalidate=604800',
    ]);
}

Reference it from the post view:

@push('head')
    <meta property="og:title" content="{{ $post->title }}">
    <meta property="og:description" content="{{ $post->excerpt }}">
    <meta property="og:image" content="{{ route('posts.og', $post->slug) }}">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="630">
    <meta name="twitter:card" content="summary_large_image">
@endpush

When LinkedIn or Twitter scrape the post URL, they fetch og.png and your controller returns a SnapSharp-generated PNG with the post's title baked in. See OG image best practices for design tips.

Step 8: production patterns

Storage to S3 / R2

Switch the Storage disk to S3 for production. In config/filesystems.php:

'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION', 'eu-west-1'),
        'bucket' => env('AWS_BUCKET'),
        'visibility' => 'public',
    ],
],

In the job, change Storage::disk('public') to Storage::disk('s3'). The Storage::disk('s3')->url($path) helper returns a CDN-friendly URL.

Horizon for queue monitoring

Install Horizon:

composer require laravel/horizon
php artisan horizon:install

Configure workers in config/horizon.php:

'environments' => [
    'production' => [
        'screenshots' => [
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'auto',
            'maxProcesses' => 10,
            'tries' => 1, // job-level retries handle this
            'timeout' => 120,
        ],
    ],
],

Run php artisan horizon (or use Supervisor in production). Visit /horizon for a real-time dashboard of jobs, throughput, and failures.

Rate limiting

To keep within SnapSharp's rate limits during bulk imports, throttle the queue:

// In handle()
use Illuminate\Cache\RateLimiter;
use Illuminate\Support\Facades\RateLimiter as RateLimit;

public function handle(SnapsharpService $snap): void
{
    $executed = RateLimit::attempt(
        'snapsharp',
        $perMinute = 30,
        function () use ($snap) {
            // ... actual capture logic
        },
        $decaySeconds = 60,
    );

    if (! $executed) {
        $this->release(60); // try again in 60s
        return;
    }
}

Step 9: deploying

Forge

Add a queue worker daemon: in Forge, go to Server → Daemons → New Daemon:

  • Command: php /home/forge/yourapp/artisan queue:work redis --sleep=3 --tries=1 --max-time=3600
  • User: forge
  • Directory: /home/forge/yourapp

Add SNAPSHARP_API_KEY to your environment file via Forge's UI.

Vapor

In vapor.yml:

id: 12345
name: my-app
environments:
  production:
    runtime: 'php-8.3:al2'
    queue-memory: 1024
    queue-timeout: 120
    queue-tries: 5

Set the secret:

vapor secret production SNAPSHARP_API_KEY sk_live_...

Vapor uses SQS by default. The same dispatch call works without code changes.

Manual VPS

Set up Supervisor:

[program:queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/yourapp/artisan queue:work redis --sleep=3 --tries=1
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/queue.log

supervisorctl reread && supervisorctl update. Four workers running 24/7.

Common pitfalls

Pitfall 1: synchronous SnapSharp calls in controllers. Like every framework, don't call SnapSharp inline from a request handler. Use jobs.

Pitfall 2: model events firing during seeding. When you seed thousands of Site rows, every created event dispatches a job. Either disable events with Site::withoutEvents(fn() => Site::create(...)) or batch-enqueue separately.

Pitfall 3: storing binary in DB. Don't add a binary column for screenshots. Use Storage. Postgres + binary blobs = pain.

Pitfall 4: missing failed() method. Without it, a permanently failed job leaves the model in processing forever. Always implement failed() to mark the model as failed.

Pitfall 5: Vapor 30-second timeout. Lambda functions cap at 15 minutes max but Vapor queue jobs default to a much shorter limit. Bump queue-timeout if you're capturing huge full-page screenshots.

Final code

Six files:

  • app/Services/SnapsharpService.php — service wrapper.
  • app/Jobs/CaptureSiteScreenshot.php — queued job with retries.
  • app/Models/Site.php — model with events.
  • app/Http/Controllers/SiteController.php — REST endpoints.
  • app/Http/Controllers/PostController.php — OG image action.
  • app/Providers/AppServiceProvider.php — DI binding.

Around 200 lines of integration code.

Conclusion

Laravel's combination of queues, model events, and the Storage facade makes this integration practically writing itself. SnapSharp slots in as a service class. Horizon gives you observability for free. Vapor and Forge handle deployment. The result: screenshots and OG images on autopilot with no Chromium binaries in your repo.

Next steps: explore the PHP tutorial, read about webhooks, or compare the Rails ActiveJob pattern.


Related: PHP tutorial · Pricing · Custom OG templates

Laravel Queue + Storage Screenshot Tutorial — Async Captures with Horizon — SnapSharp Blog