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/sdkAdd the API key to .env:
SNAPSHARP_API_KEY=sk_live_YOUR_API_KEYRegister 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 -mdatabase/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 CaptureSiteScreenshotapp/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>
@endsectionStep 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">
@endpushWhen 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:installConfigure 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: 5Set 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.logsupervisorctl 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