bepsvpt/blurhash
A Laravel/PHP implementation of BlurHash for generating compact image placeholders and decoding them into tiny blurred previews. Add smooth loading states to media-heavy apps with simple, framework-friendly APIs.
Install the package with Composer; Laravel auto-discovers Bepsvpt\Blurhash\BlurhashServiceProvider and registers the Blurhash facade.
composer require bepsvpt/blurhash
php artisan vendor:publish --provider="Bepsvpt\Blurhash\BlurhashServiceProvider"
First things to open:
config/blurhash.php — controls the default components_x / components_y (the "resolution" of the hash) and related encode/decode defaults. Tune 4x3 or 4x4 for most photos; lower for portraits, higher for detailed scenes.Bepsvpt\Blurhash\Facades\Blurhash facade — the two methods you will actually call are encode() and decode().First use case — encode an uploaded image and store the hash next to the record:
use Bepsvpt\Blurhash\Facades\Blurhash;
$hash = Blurhash::encode($request->file('photo')->getRealPath());
// "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
Persist that string on your images / posts / users table, ship it to the client, and decode it there with blurhash-js (or Blurhash::decode() on the server, see below) to paint a placeholder while the real image loads.
Store the hash on the model, not in cache. Blurhashes are deterministic — regenerating them on every page view is wasted CPU. Add a blurhash string column, populate it once at upload time, regenerate only when the source image changes.
// migration
$table->string('blurhash', 64)->nullable();
Hook it into the upload pipeline, not the controller. A model observer or an event listener keeps it out of request handlers and works for console/queue imports too:
class ImageObserver
{
public function saving(Image $image): void
{
if ($image->isDirty('path') && $image->path) {
$image->blurhash = Blurhash::encode(
Storage::disk($image->disk)->path($image->path)
);
}
}
}
Do the encode on a queue. Encoding is a full image decode plus a DCT — 50–300 ms for a typical photo, more for large ones. For user-facing uploads, dispatch a job, save the model without the hash, and let the job update it:
dispatch(function () use ($image) {
$image->update(['blurhash' => Blurhash::encode($image->absolutePath())]);
})->afterResponse();
Expose it in API resources / Inertia props alongside the real URL so the frontend can render in one round-trip:
return [
'url' => $image->url,
'blurhash' => $image->blurhash,
'width' => $image->width,
'height' => $image->height,
];
Server-side decode for <img src> placeholders. When you cannot (or do not want to) run a JS decoder, Blurhash::decode() returns an encoded image you can inline as a data URI, making the placeholder a zero-round-trip, cacheable asset:
$placeholder = 'data:image/png;base64,' . base64_encode(
Blurhash::decode($image->blurhash, 32, 32)
);
Keep those decode dimensions tiny (32–64 px) — the browser upscales and applies the blur via CSS. Do not decode to the real display size.
Blade usage with a JS decoder (most flexible, no server CPU):
<div class="relative">
<canvas
data-blurhash="{{ $image->blurhash }}"
class="absolute inset-0 h-full w-full blur-xl scale-110"
></canvas>
<img src="{{ $image->url }}" onload="this.previousElementSibling.remove()" />
</div>
Combine with Spatie Media Library by registering the conversion pipeline on a custom MediaObserver, or simply read $media->getPath() inside a job after the media has been added. Do not compute the hash on a URL — always resolve it to a local path first.
Backfill command for existing rows. Because the operation is idempotent, a small chunked Artisan command is safe to run and re-run:
Post::whereNull('blurhash')->chunkById(100, function ($posts) {
foreach ($posts as $post) {
$post->update(['blurhash' => Blurhash::encode($post->imagePath())]);
}
});
Wrap it in a small service class (e.g. ImagePlaceholderService) that resolves disk → local path, checks the file is readable, resizes if needed, and returns ?string. That is the seam you want for tests and for swapping drivers later.
encode() needs a local filesystem path, not a URL or a stream. For S3/R2/Spaces you must download to a temp file first (Storage::disk('s3')->readStream() into tempnam()), or use Storage::path() only for the local disk. Passing a URL is the single most common first failure.null on failure rather than always throwing. Guard against null before writing to the DB, and log the context (file size, mime) — otherwise you will silently get empty placeholders on a subset of uploads.php -m and gd_info() first if everything else looks right.memory_limit on shared hosting. Downscale to ~64×64 with Intervention Image or Imagick first, then encode the thumbnail.4x3 = 12 is already at the practical edge). The library validates this; passing components_x => 8, components_y => 8 throws. 4x3, 4x4, 5x4, 3x3 are the sane defaults. More components = sharper placeholder but a longer string and heavier decode.string(64) is cheap insurance; string(255) costs nothing and avoids future truncation surprises.blurhash value a safe cache key for a derived placeholder.components_x / components_y in config/blurhash.php, remember php artisan config:cache in production. Forgetting this is the usual reason "my config change did nothing".encode(). Forking the package for custom drivers is rarely worth it — the facade is thin enough to wrap.tests/Fixtures/sample.jpg) and assert on the returned string shape (is_string, length, no exceptions) rather than the exact hash, since the hash will change if you bump components or replace the library.How can I help you explore Laravel packages today?