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

Imagehash Laravel Package

jenssegers/imagehash

Perceptual image hashing for PHP and Laravel. Generate hashes to detect duplicate images and find visually similar pictures using aHash, dHash, pHash and more. Works with Intervention Image in any PHP project. Fast, lightweight, and great for deduplication and content moderation.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and verify the real prerequisite

composer require jenssegers/imagehash
php -m | grep -Ei 'gd|imagick'

The package is pure PHP; the thing that fails is the image driver. Confirm GD or Imagick is compiled into every runtime that will hash — web FPM, Horizon/queue workers, Octane, and CI. Pick one driver and pin it (Dockerfile + composer.json config.platform); GD and Imagick decode edge-case files slightly differently and can shift a hash by a bit or two.

Read these four files first

Path Why
Jenssegers\ImageHash\ImageHash Entry point: hash(), compare(), distance()
Jenssegers\ImageHash\Implementations\DifferenceHash Default 64-bit dHash — fastest sane choice
Jenssegers\ImageHash\Implementations\PerceptualHash pHash — slower, more robust to re-encoding/resizing
Jenssegers\ImageHash\Hash Value object: toHex(), toBits(), fromHex()

Skip AverageHash (least accurate) and BlockHash (256-bit, column-size footgun) unless you have a specific reason.

First use case: "is this a near-duplicate?"

use Jenssegers\ImageHash\ImageHash;
use Jenssegers\ImageHash\Implementations\DifferenceHash;

$hasher = new ImageHash(new DifferenceHash());

$hash = $hasher->hash(storage_path('app/photo.jpg'));
echo $hash->toHex();                     // "8f8f8f8f0f0f8f8f"

$distance = $hasher->compare($a, $b);    // 0 = perceptually identical

Start with DifferenceHash. Only switch to PerceptualHash if a spike on real uploads shows re-encoded copies slipping past your threshold.

Bind it once, don't instantiate in controllers

// App\Providers\AppServiceProvider::register()
$this->app->singleton(ImageSimilarity::class, fn () =>
    new ImageHashHasher(new DifferenceHash())
);

Define a small ImageSimilarity interface (hash(string $path): string, distance(string $a, string $b): int) and let the package satisfy it. That one indirection is your exit ramp to a managed service or embeddings later — costs nothing now.

First migration

Schema::table('images', function (Blueprint $table) {
    $table->char('image_hash', 16)->nullable()->index(); // 64 for BlockHash
    $table->string('hash_algorithm', 32)->nullable();    // 'dhash'
    $table->unsignedSmallInteger('hash_version')->default(1);
});

Store hex, not bits. hash_algorithm and hash_version are cheap insurance — they turn "we changed algorithm, re-hash everything" into a backfill command instead of a silent data-loss event.

Implementation Patterns

Persist once, compare from storage. compare($aPath, $bPath) decodes both files every call. In a loop that re-decodes the same images N times. On upload, hash once and store the hex; afterwards only decode the incoming candidate:

use Jenssegers\ImageHash\Hash;

$distance = $hasher->distance(
    Hash::fromHex($image->image_hash),
    Hash::fromHex($candidate->image_hash)
);

Hash in a queued job, off the request path. Tens of milliseconds is cheap, but it's a full decode and large uploads spike memory. Dispatch from an observer on created, make the job idempotent (if ($image->image_hash) return;), and use ShouldBeUnique keyed on the image ID so a retried upload doesn't fan out duplicates.

S3 / non-local disks. The hasher wants a readable path or an Intervention Image. Either stream to a temp file and delete in finally, or build an in-memory image from Storage::get(). Don't hand it an untested stream-wrapper URL — every failure costs a decode.

Avoid the O(N) scan with a cheap prefilter. 64-bit Hamming distance has no B-tree story. Full scans are fine up to ~10k assets. Past that, use banding: split the 16-char hex into four 4-char chunks, store in image_hash_bands (image_id, band_index, band_value). Candidate lookup becomes "images sharing at least one band," then compute exact distance only over that set. Poor man's LSH; enough for most catalogues.

Ship dedupe dark before you ship dedupe. Sequence it:

  1. Schema.
  2. Backfill command via chunkById + Bus::batch()->throttle() so interactive workers stay healthy.
  3. Offline images:duplicates report command dumping pairs and distances.
  4. Hand-review that report.
  5. Advisory badge in the UI.
  6. Only then a feature-flagged merge with manual override.

Backfill skeleton

Image::query()->whereNull('image_hash')->chunkById(500, function ($images) {
    foreach ($images as $image) {
        HashImageJob::dispatch($image->id);
    }
}, column: 'id');

Run off-peak, watch queue depth, fail open on missing files (image deleted mid-backfill) rather than retrying five times.

Make the threshold configuration, not a constant.

// config/imagehash.php
return [
    'algorithm' => env('IMAGEHASH_ALGORITHM', 'dhash'),
    'thresholds' => [
        'duplicate' => (int) env('IMAGEHASH_DUPLICATE', 5),
        'similar'   => (int) env('IMAGEHASH_SIMILAR', 12),
    ],
];

Two tiers map to two product behaviours ("block/merge" vs. "nudge/suggest"), and you can tune per image domain without a redeploy. Log the observed distance distribution for a few weeks before tightening anything.

Test against fixture files, not mocks. Keep 8–10 real images in tests/Fixtures: original, resized copy, re-encoded JPEG, screenshot, solid-colour placeholder, and two genuinely different photos. Assert distance ranges, not exact hashes — exact-hash assertions break on a GD/Imagick bump and teach you nothing.

Gotchas and Tips

compare() is a testing convenience, not production code. It decodes both arguments every call. Use it in feature tests and Tinker; use distance() with stored hashes everywhere else.

Never put toBits() in a JSON response or a varchar column. Raw binary through JSON serialization or a UTF-8 column corrupts silently. Use toHex(). If you must store numeric, BIGINT UNSIGNED and beware PHP signedness on 32-bit builds — hex sidesteps the whole class of bug.

BlockHash is 256-bit. A char(16) column truncates it. Size the column to the algorithm and record the algorithm so mixed rows are detectable.

GD memory exhaustion on large sources is your most likely production incident. A 6000×4000 JPEG decodes to roughly 100 MB. Downscale before hashing — Intervention's resize(256, 256) is plenty since the hash discards detail anyway — and cap accepted dimensions in your FormRequest.

Fail open on decode errors. Corrupt files, CMYK JPEGs, animated GIFs, HEIC, and 0-byte uploads all throw. The dedupe check must never be the reason an upload fails:

try {
    $hash = $hasher->hash($path);
} catch (\Throwable $e) {
    report($e);
    return null; // upload proceeds, hash stays null, backfill can retry later
}

Transparency changes the hash. A PNG with alpha composited on white vs. black hashes differently. If your library mixes PNGs and JPEGs, flatten transparency to a consistent background before hashing, or accept noise in that domain.

Systematic false positives are the real UX risk. Solid colours, gradients, heavy letterboxing, watermarks, and low-detail placeholders produce near-identical hashes — exactly the images a user will notice being merged. Ship a manual override and an audit record of which pair triggered a merge; you'll need it to unwind a bad threshold.

No rotation invariance. dHash and pHash are not rotation-invariant, and EXIF orientation may or may not be applied depending on the decoder. Decide explicitly whether a rotated photo should count as a duplicate — if yes, this package alone won't give it to you.

Hash drift after upgrades. Bump GD, Imagick, or intervention/image and hashes can shift; old and new rows become silently incomparable. The hash_version column is how you detect it: bump it, re-hash in the background, compare only within a version. Without it, this failure is invisible until someone complains about wrong duplicates.

The dependency that will actually break is intervention/image. This package wraps it, so its major-version bumps are your real upgrade risk. Check what Composer resolves against the rest of your stack, and pin the imagehash version explicitly — the public API is small, but edge-case behaviour isn't semver-guaranteed.

Don't hash user-supplied URLs. "Paste an image URL" is an SSRF vector with a file_get_contents flavour. Download through your own hardened HTTP client with timeouts and size caps, or don't offer it.

Tune on your corpus, not the internet's. Every blog post gives a different magic number; product photography, screenshots, and scanned documents are three different thresholds. The spike — a few hundred real assets, a distance histogram, a hand-reviewed sample — is the actual work, and it's a product-policy decision, not a library setting.

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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core