Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Thumbhash Laravel Package

srwiez/thumbhash

PHP implementation of ThumbHash for generating ultra-compact image placeholders. Encode images into short hashes, decode them into tiny blurred previews, and integrate easily with Laravel to improve perceived loading performance and layout stability. Ideal for progressive image loading.

Deep Wiki
Context7

Getting Started

Install and verify the driver.

composer require srwiez/thumbhash
php -m | grep -Ei 'imagick|gd'   # you need at least one

The package ships a Laravel service provider and a ThumbHash facade that are auto-discovered. Optional config can be published with php artisan vendor:publish --tag=thumbhash-config — you mostly won't need it unless you want to force Imagick vs GD or change the default PNG output format.

Encode → decode in two lines. ThumbHash takes image bytes (not a URL) and returns a short base64 string; decoding returns a tiny PNG that you scale up with CSS and blur.

use SrwieZ\ThumbHash\Facades\ThumbHash;

$hash    = ThumbHash::encode(storage_path('app/public/uploads/photo.jpg'));
$dataUri = ThumbHash::decode($hash); // "data:image/png;base64,..."

First real use case: a progressive image component. Store $hash on the media record during upload, emit $dataUri into the <img src> while the real file loads, and swap it out on onload. That's 90% of what this package is for.

If you prefer no facade, resolve the class directly — app(\SrwieZ\ThumbHash\ThumbHash::class) — which keeps you honest in queues and tests.

Implementation Patterns

Compute the hash at upload time, not render time. Encoding is cheap for small images but pointless on every request. A model observer is the natural hook:

class MediaObserver
{
    public function creating(Media $media): void
    {
        if (! $media->path) {
            return;
        }

        $media->thumb_hash = ThumbHash::encode(
            Storage::disk($media->disk)->path($media->path)
        );
    }
}

Add a cached accessor so the data URI is never rebuilt twice per request.

protected function thumbHashUri(): Attribute
{
    return Attribute::get(fn () => $this->thumb_hash
        ? ThumbHash::decode($this->thumb_hash)
        : null
    )->shouldCache();
}

Ship it through an API resource. Consumers (Nuxt, React Native, Inertia) get a ready-to-use placeholder:

return [
    'url'       => $this->url,
    'placeholder' => $this->thumb_hash_uri, // full data URI
];

Don't send the raw hash and make the frontend reimplement decoding — ThumbHash decoders exist in JS, but you already paid for the PHP one.

Offload to a job when uploads are large or batched.

class GenerateThumbHash implements ShouldQueue
{
    public function __construct(public int $mediaId) {}

    public function handle(): void
    {
        $media = Media::find($this->mediaId);
        if (! $media) return;

        $media->updateQuietly([
            'thumb_hash' => ThumbHash::encode(
                Storage::disk($media->disk)->path($media->path)
            ),
        ]);
    }
}

Fire it from the controller after store(), and give the column a nullable default so the UI can fall back to a solid color while the job runs.

Blade component for the swap. Keep the hash decoding server-side and let Alpine (or a few lines of vanilla JS) handle the swap:

<img
    src="{{ $placeholder }}"
    data-src="{{ $url }}"
    x-data
    x-init="$el.src = $el.dataset.src"
    class="thumbhash"
>
.thumbhash { filter: blur(12px); transform: scale(1.05); object-fit: cover; }

The blur-and-scale trick is required — ThumbHash decodes to something like 32×32, so the browser upscales it; without scale() you'll see soft edges.

Batch/decode reuse. If you're rendering a grid, decode once per unique hash and reuse the data URI string across rows. A simple Cache::remember("thumbhash:{$hash}", 86400, fn () => ThumbHash::decode($hash)) is enough; the hash itself makes a perfect cache key.

Gotchas and Tips

The hash is short and base64 — size your column accordingly. ThumbHash payloads are at most 28 binary bytes, which base64-encodes to roughly 38 characters. A string(64) column is generous; text is overkill. Storing the data URI instead is wasteful and makes cache keys ugly.

encode() wants bytes, not a path on some builds. Verify which signature you need — path, resource, or binary string. In tests and queues, file_get_contents() + the binary variant avoids surprises with Storage::path() and stream wrappers (S3 disks have no local path).

S3 and any non-local disk will bite you. Storage::path() only works on local disks. Use Storage::get($path) and feed the binary to encode(), or copy to a temp file first. If you're hashing user uploads on Spaces/R2, this is the number-one failure.

Imagick vs GD is an install-time decision, not config. Locally you may have Imagick, in production probably GD. Both work, but transparency handling differs slightly. Test one PNG with alpha before assuming parity. Forcing a driver via config is safer than letting the package autodetect across environments.

Massive uploads eat memory. An 8000×6000 JPEG decoded by GD can spike past memory_limit. Resize to a sane max dimension (say 1000 px) before encoding, or cap upload sizes at the validation layer. ThumbHash internally reduces to ~100×100, but the decode step still holds the full bitmap.

decode() returns a data URI string; if you need raw bytes, base64-decode it. Don't echo it into JSON with Content-Type: image/* — it's a URI, and the mime prefix is part of the payload. If your API consumers strip data: prefixes, document it.

Data URIs inflate JSON. Roughly 4/3 × PNG size, which is small (a few hundred bytes) but not free across hundreds of rows. For list endpoints, send the bare hash and let the frontend decode it with a JS thumbhash library; reserve the server-side data URI for the detail view.

Debugging with a known vector. When a placeholder looks wrong, encode a known reference image (e.g. the one from the ThumbHash spec) and compare against the published hash. If it matches, your input handling is fine and the bug is in CSS or in which column you're reading.

Extension points are thin, by design. There's no filesystem adapter, no queue middleware, no custom encoder interface. Treat the facade as a pure function: encode(bytes) → hash and decode(hash) → uri. Anything else (storage, resizing, caching) belongs in your service class so you can swap drivers or pre-process images without forking the package. Keep a thin ThumbHasher wrapper around it and inject that instead of calling the facade everywhere — it makes tests trivial and gives you one place to add the S3 workaround from above.

Version pin conservatively. The package is small and the ThumbHash spec is stable, but the PHP API surface is not semver-guaranteed to be enormous — pin a minor version in composer.json and let Renovate bump it deliberately.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings