lakshmajim/thumbnail
Laravel package for generating and caching image thumbnails on the fly. Define sizes, crop or resize, and serve optimized images through simple routes and helpers, with built-in cache support for fast repeated requests.
Minimal path from zero to first thumbnail:
ffmpeg binary, so nothing works without it:
# Debian/Ubuntu
sudo apt-get install ffmpeg
# macOS
brew install ffmpeg
# verify
ffmpeg -version
composer require lakshmajim/thumbnail
composer.json for extra.laravel):
// config/app.php
'providers' => [
Lakshmajim\Thumbnail\ThumbnailServiceProvider::class,
],
'aliases' => [
'Thumbnail' => Lakshmajim\Thumbnail\Facades\Thumbnail::class,
],
use Thumbnail;
$video = $request->file('video')->store('videos');
$path = storage_path('app/public/thumbnails/'.$name.'.jpg');
Thumbnail::get($videoPath, $path); // confirm exact signature against the README
Where to look first: the repo's README.md for the exact method names/signatures (the package is small, so the source in src/ is 1–2 files and worth reading — it is just an FFmpeg command wrapper). Then check composer.json for the supported Laravel/PHP constraint before wiring it into a modern app.
First realistic use case: an upload endpoint that accepts a video, stores the original on a disk, and generates a thumbnail alongside it for listing pages / cards in the UI.
Wrap it in a small application service. Because this is a thin FFmpeg wrapper, keep the vendor call behind your own class so you can swap implementations (e.g., php-ffmpeg/php-ffmpeg, spatie/image) later without touching controllers:
class VideoThumbnailer
{
public function generate(string $videoPath, string $outputPath, ?string $atSecond = null): string
{
$outputPath = $this->ensureDirectory($outputPath);
if (! Thumbnail::get($videoPath, $outputPath, $atSecond)) {
throw new ThumbnailFailedException("Could not thumbnail {$videoPath}");
}
return $outputPath;
}
}
Generate asynchronously on upload. Thumbnailing is blocking I/O and can take seconds per file — never do it inline in the request for anything larger than a small clip:
// Controller
$video = $request->file('video')->store('videos', 'public');
GenerateVideoThumbnail::dispatch($video);
// Job
public function handle(VideoThumbnailer $thumbnailer)
{
$thumbnailer->generate(
Storage::disk('public')->path($this->videoPath),
Storage::disk('public')->path('thumbnails/'.pathinfo($this->videoPath, PATHINFO_FILENAME).'.jpg')
);
}
Deterministic, content-addressed filenames. Don't trust the uploaded filename; hash it so re-uploads and cache busting behave:
$key = hash_file('sha1', $tmpPath).'.jpg';
Choose a sensible timestamp. Frame 0 is often a black frame or a fade-in. Seek ~10% into the video (capped) for a representative poster:
$duration = $this->probeDuration($videoPath); // via ffprobe, packaged separately
$seekSecond = min(10, (int) floor($duration * 0.1));
Model integration via accessor / attribute. Store the thumbnail path on the media model and expose a URL accessor so views stay dumb:
public function getThumbnailUrlAttribute(): ?string
{
return $this->thumbnail_path
? Storage::disk('public')->url($this->thumbnail_path)
: null;
}
Pipeline pattern. Chain with other media processing steps using Laravel's Bus::chain so the thumbnail only runs after transcoding succeeds, and so failures bubble up in one place.
Integration tip — storage disks. Resolve absolute paths with Storage::disk(...)->path(); the FFmpeg wrapper needs a real filesystem path, not a URL or relative path.
Backfill / batch regeneration. Because it's a simple ->get() call, an artisan command that iterates existing rows and regenerates missing thumbnails is trivial — useful after a storage migration:
Video::whereNull('thumbnail_path')->chunkById(100, function ($videos) {
foreach ($videos as $video) { /* dispatch job */ }
});
composer require fails, either fork it (a one-file change to the composer.json constraint usually suffices) or drop it and call ffmpeg directly — that's all the package does.PATH of the process that runs PHP. This bites hardest with queue workers, PHP-FPM pools, or containers: php-fpm and your SSH shell often have different environments. Use an absolute binary path (/usr/bin/ffmpeg) if the package/config allows it, or symlink into a known location.file_exists($outputPath) && filesize($outputPath) > 0 — a failed FFmpeg invocation can return true or produce a zero-byte file depending on how the wrapper shells out.max_execution_time need to accommodate it. Set your queue worker --timeout higher than php.ini's limit, otherwise the worker is killed mid-run and the job is retried forever.try/finally with unlink() — a throwing job leaves orphans in /tmp and these fill up fast in containers.storage/ ownership issues after deploys (chown -R www-data storage) are the most common "it worked locally" bug.-ss before -i) is quicker but can land a few frames off; exact seek (-ss after -i) is slower. For posters it rarely matters, but if you need a specific frame, test it.php artisan vendor:publish --provider="Lakshmajim\Thumbnail\ThumbnailServiceProvider" and check the tag; older packages sometimes use a tag-less --provider publish that overwrites silently. Commit the config and, importantly, make the binary path configurable via .env.VideoThumbnailer service. Treat the vendor package as an implementation detail so a future migration is a one-file change.@requires ffmpeg test; otherwise your thumbnail tests are skipped/inconclusive and regressions slip through.How can I help you explore Laravel packages today?