lakshmaji/thumbnail
Lakshmaji Thumbnail is a Laravel package for generating image thumbnails with a simple, fluent API. It wraps Intervention Image to resize, crop, and cache thumbnails on the fly, making it easy to serve optimized image sizes in Laravel apps.
Prerequisites (non-negotiable). The package is a thin wrapper around the ffmpeg binary — it does not ship FFmpeg. Confirm ffmpeg -version works from the shell under the same user PHP runs as before touching composer.
# Debian/Ubuntu
sudo apt-get install ffmpeg
# macOS
brew install ffmpeg
Install.
composer require lakshmaji/thumbnail
Because the last release is from 2017, expect a platform-constraint fight on PHP 8 / Laravel 10+. If composer refuses:
composer require lakshmaji/thumbnail --ignore-platform-reqs
# or pin the constraint already present in your lockfile
Then sanity-check the service provider/facade registered in config/app.php (older Laravel) or that auto-discovery picked it up (php artisan package:discover).
Configure the binary path first. Publish the config so FFmpeg's location is explicit rather than relying on PATH:
php artisan vendor:publish --provider="Lakshmaji\Thumbnail\ThumbnailServiceProvider"
In config/thumbnail.php set the FFmpeg (and FFprobe, if present) paths to the absolute binary location (/usr/bin/ffmpeg), and prefer an .env value (FFMPEG_PATH) so staging/production can differ.
First use case — grab a frame on upload:
use Lakshmaji\Thumbnail\Facade\Thumbnail;
Thumbnail::src(storage_path('app/videos/clip.mp4'))
->smart(true)
->save(storage_path('app/thumbnails'), 'clip.jpg');
Where to look first, in order:
config/thumbnail.php — binary paths and defaults; almost every failure traces back here.Lakshmaji\Thumbnail\Facade\Thumbnail; if your alias differs, import the class directly rather than debugging the alias).src(), smart(), timeToImage(), save().ffmpeg command it builds — you will need it when debugging (see Gotchas).Pattern 1 — Never generate in the request cycle. FFmpeg is CPU-bound and can take seconds to minutes. Queue it:
// app/Jobs/GenerateVideoThumbnail.php
public function handle(): void
{
$video = storage_path("app/videos/{$this->filename}");
$dir = storage_path('app/thumbnails');
Thumbnail::src($video)
->timeToImage(3) // deterministic & fast
->save($dir, "{$this->basename}.jpg");
}
Dispatch from the controller/observer after the upload is persisted, and store the resulting path on the model.
Pattern 2 — Smart vs. timed capture, chosen per content type.
smart(true) — scans the video with FFmpeg's thumbnail filter and picks a "representative" frame. Best for user-generated content with black fades/credits at the head. Slower (it decodes multiple frames).timeToImage($seconds) — single seek. Use for known-format content (screen recordings, trailers, camera footage) where a fixed offset is reliable. Much faster and predictable.Rule of thumb in production: timeToImage() by default, smart(true) only for UGC where the intro is untrusted.
Pattern 3 — Wrap it in a small service, not scattered facade calls. This isolates the 2017-era API so you can swap it later:
class Thumbnailer
{
public function make(string $videoAbsPath, string $outDir, string $name, bool $smart = false): string
{
if (!is_file($videoAbsPath)) {
throw new InvalidArgumentException("Missing video: {$videoAbsPath}");
}
if (!is_dir($outDir)) {
mkdir($outDir, 0755, true);
}
Thumbnail::src($videoAbsPath)
->smart($smart)
->save($outDir, $name);
return $outDir . DIRECTORY_SEPARATOR . $name;
}
}
Pattern 4 — Model observer on upload. Attach to created on your Video model, dispatch the job, and have the job write thumbnail_path back. Keeps controllers thin and makes regeneration a matter of re-dispatching.
Pattern 5 — Batch/backfill command. Add an artisan command iterating over rows where thumbnail_path IS NULL, calling the same service. Wrap each iteration in try/catch so one corrupt file doesn't kill the batch; log failures with the file path.
Pattern 6 — Post-process with Intervention Image. This package only extracts a frame; it does not resize or crop. Feed its output into intervention/image to produce the actual 16:9 card, webp variants, etc. Treat the FFmpeg output as an intermediate artifact.
Integration tips.
storage_path()/public_path(); never a bare filename or a URL.save() does not create parent directories. If the destination folder is missing, FFmpeg fails with a cryptic exit code. mkdir($dir, 0755, true) before calling.
Filename must carry the extension. ->save($dir, 'thumb.jpg'), not 'thumb'. The extension determines the encoder FFmpeg picks, and jpg/png behave differently on odd-dimension frames.
Return value is easy to misread. save() typically returns $this (for chaining) rather than the path. Don't return Thumbnail::...->save(...) and expect a string — construct the path yourself from the pieces you passed in.
The binary path is the #1 failure. Symptoms: empty/black output, exit code 127, or "not found" in logs. Debug by copying the exact command out of the package source and running it manually as the web/queue user:
sudo -u www-data /usr/bin/ffmpeg -i /var/www/app/storage/app/videos/clip.mp4 -ss 3 -vframes 1 /tmp/out.jpg
If that works and the package doesn't, the config path is wrong.
Smart mode on long videos is slow and memory-hungry. A 2-hour upload run through smart(true) can exceed PHP's max_execution_time/memory_limit even in a queue worker. Set generous timeouts on the job ($this->timeout = 600) and consider timeToImage() for anything over ~10 minutes.
Paths with spaces or non-ASCII characters are a classic breakage point for shell-wrapped FFmpeg calls. Keep storage filenames slugged/Str::random()-based; never pass raw user-supplied original filenames.
Black-frame results usually mean seeking landed on an intro fade. Bump timeToImage() from 0/1s to 3–5s, or switch to smart(true). Verify by actually eyeballing a sample — automated checks won't catch it.
Package age is the real risk. PHP 7-era code, no active maintenance, no Laravel 10/11 updates guaranteed. Treat it as an implementation detail behind your Thumbnailer service: if it breaks on a framework upgrade, replacing it with a direct Symfony\Component\Process\Process call to FFmpeg is ~20 lines and gives you far better error output and cancellation. Plan for that migration; don't let facade calls leak into controllers.
Extension point worth knowing: since it shells out, any FFmpeg option you need (quality -q:v, scaling via -vf scale=640:-1, sprite sheets) has to be done either by editing the package (don't) or by post-processing / replacing the call. Budget for the direct-FFmpeg path as soon as you need non-default output.
Logging: wrap calls in try/catch and log $e->getMessage() plus the source path and FFmpeg exit code. The package's exceptions are terse; the exit code plus a manual rerun of the command is what actually gets you to the cause.
How can I help you explore Laravel packages today?