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

Color Extractor Laravel Package

league/color-extractor

League ColorExtractor is a PHP package for extracting representative colors from images, with palette helpers, most-used color detection, and transparency handling via Composer.

View on GitHub
Deep Wiki
Context7

Getting Started

Confirm GD before you write a line of code. league/color-extractor is pure PHP but hard-depends on ext-gd (and, since 0.6.0, can use Imagick where available). The failure mode is a runtime Class "GdImage" not found or a silent "unsupported image" deep inside a queued job:

composer require league/color-extractor
php -m | grep -i gd

Then declare it so deploys fail at composer install rather than at 2am:

"require": {
    "php": "^8.2",
    "ext-gd": "*",
    "league/color-extractor": "^0.6"
}

Pin ^0.6 specifically — 0.6.0 fixed the ΔE₀₀ computation and added Imagick support, both of which change the output of extract(). Older pins give you subtly different palettes on the same image. Also add gd to your CI matrix (shivammathur/setup-php with extensions: gd); the stock php:8.x-cli Docker image does not ship it, so "works locally, fails in CI" is the default state.

Read three classes, in this order. Everything public lives in src/:

  • Palette — static factories fromFilename(), fromContents(), fromUrl(); implements Iterator and Countable, yielding int color => int pixelCount sorted by count descending.
  • ColorExtractor — wraps a Palette, extract(int $n) returns the representative colors (not simply the top-N by count).
  • Color — fromIntToHex() / fromHexToInt().

The one thing to internalise immediately: colors are integers (0xRRGGBB). The palette gives you ints; hex is a presentation concern you apply at the boundary. Store hex in the database (readable, greppable, works in SQL), convert on write.

There is no service provider and no config file. The package has zero Laravel coupling — that's a feature, but it means the integration is entirely yours. Put a thin wrapper in app/Support/Color/ so call sites never touch the vendor namespace.

First use case: dominant color on upload, computed in a job. Ship this before anything more ambitious — it validates GD in every environment and gives you the primitive the rest of your features hang off.

// database/migrations/...
$table->string('dominant_hex', 7)->nullable();
$table->json('palette')->nullable();
namespace App\Support\Color;

use League\ColorExtractor\Color;
use League\ColorExtractor\ColorExtractor;
use League\ColorExtractor\Palette;

class PaletteReader
{
    /** @return array{dominant: ?string, palette: list<string>} */
    public function read(string $bytes, ?string $backgroundHex = null, int $size = 5): array
    {
        $palette = Palette::fromContents($bytes, $this->backgroundInt($backgroundHex));

        if (count($palette) === 0) {
            return ['dominant' => null, 'palette' => []];
        }

        $dominant = $palette->getMostUsedColors(1);

        return [
            'dominant' => strtolower(Color::fromIntToHex($dominant[0])),
            'palette'  => array_map(
                static fn (int $c): string => strtolower(Color::fromIntToHex($c)),
                (new ColorExtractor($palette))->extract($size),
            ),
        ];
    }

    private function backgroundInt(?string $hex): ?int
    {
        return $hex === null ? null : Color::fromHexToInt($hex);
    }
}

Note the count($palette) === 0 guard — it is not paranoia. A fully transparent PNG with the default null background yields an empty palette, and getMostUsedColors(1)[0] will throw an undefined-index error inside your queue worker.

Implementation Patterns

Always queue it, never in the request cycle. A 4000×4000 JPEG is 16M pixels; GD allocates for the whole thing and Palette builds a hash of every distinct color. Do the work in a ProcessImagePalette job dispatched after the file is stored, and keep the job on a dedicated queue so a burst of uploads doesn't starve your mailers.

Downscale before extraction — this is the single biggest win. Accuracy does not improve past ~256px on the long edge; CPU and memory do get worse, dramatically. Decode once, scale, re-encode to a temp stream, feed fromContents():

private function downscale(string $bytes, int $max = 256): string
{
    $source = @imagecreatefromstring($bytes);
    if ($source === false) {
        throw new UnsupportedImageException();
    }

    $scaled = imagescale($source, $max, $max);
    ob_start();
    imagepng($scaled);
    $small = ob_get_clean();

    imagedestroy($source);
    imagedestroy($scaled);

    return $small;
}

If intervention/image is already in the project, use it for the resize and hand the resulting bytes to fromContents() — don't add a second image pipeline just for this.

Pick the right file source for your storage. fromFilename() needs a real local path and throws InvalidArgumentException when the file is missing or unreadable (since 0.4.0). That's fine for $request->file('photo')->getRealPath(), but on S3 or any non-local disk there is no path. Use Palette::fromContents(Storage::disk('s3')->get($key)). Avoid fromUrl() in production — it makes a synchronous outbound HTTP call from inside your worker, with whatever timeout and failure semantics your PHP config happens to give you.

Wire it to model events, not controllers. An Observer on saved/created that checks wasChanged('path') and dispatches the job centralises the logic, so imports, admin re-uploads, and API writes all get palettes without anyone remembering to call it.

class ProductObserver
{
    public function saved(Product $product): void
    {
        if (! $product->wasChanged('image_path') || ! $product->image_path) {
            return;
        }

        ProcessImagePalette::dispatch($product)->onQueue('images');
    }
}

Store hex lowercase and include the # consistently — you will otherwise end up with a filter UI that matches on #FF0000 and a database that holds #ff0000.

Decide between "most used" and "representative" deliberately. getMostUsedColors(5) is a raw histogram; on a product photo, five shades of near-identical beige will fill it. (new ColorExtractor($palette))->extract(5) clusters into colors that a human would name. For UI (theme color, blur-up placeholder) use extract(). For "is this item red?", use getMostUsedColors() and apply a share-of-pixels threshold:

$total = array_sum(iterator_to_array($palette));
$top   = $palette->getMostUsedColors(3);
foreach ($top as $color) {
    $share = $palette->getColorCount($color) / $total;   // 0.0–1.0
    // only claim a color if it occupies > 15% of usable pixels
}

getColorCount() returns 0 for a color not in the palette (fixed in 0.4.0), so this loop is safe.

Backfill with a command, not a one-off tinker script. chunkById(200) over records with dominant_hex IS NULL, dispatch one job per chunk, rate-limited. Then spot-check a sample of 50 against the images by eye before you expose anything user-facing — accuracy on your actual catalog is the only metric that matters, and it varies wildly between editorial photography and flat-lay product shots.

Wrap it. Really. A PaletteReader interface with one implementation means swapping to Imagick histogram code, an image CDN, or a cloud vision API later is a container binding change. The package has a tiny surface and no recorded dependents — that's acceptable because the abstraction cost is one interface.

Testing. Never hit the real extractor in feature tests. Bind a fake that returns a canned palette:

$this->mock(PaletteReader::class, fn ($m) => $m->shouldReceive('read')
    ->andReturn(['dominant' => '#ff0000', 'palette' => ['#ff0000', '#ffffff']]));

For the real thing, keep two or three small PNG fixtures (<5KB) in tests/Fixtures and assert on count and shape, not exact hex — assert count($result['palette']) === 5 and that dominant matches /^#[0-9a-f]{6}$/. Exact-value assertions will break on any patch release that touches ΔE.

Gotchas and Tips

Transparency is a product decision, not a config default. Out of the box, every pixel with alpha > 0 is discarded. For a cut-out logo on a transparent canvas, that means a large transparent region contributes nothing — fine — but the moment you pass a background to fromContents($bytes, Color::fromHexToInt('#FFFFFF')), that entire transparent area is blended to white and added to the histogram. A 90%-transparent PNG will report white as its dominant color. This is technically correct ("as perceived on white") and almost always the wrong answer for a "what color is this product?" feature. Two habits:

  • For logos/theming against a known page background, blending is what you want — pass the background explicitly and document it.
  • For product photography, leave it null and make sure your upload pipeline rejects or flattens transparent PNGs, or you'll get empty palettes.

Set the choice in a config value (config('images.palette_background')), never inline at the call site.

Empty and single-color images are the common crash. extract() can return fewer colors than requested, getMostUsedColors($n) returns at most count($palette), and both can return []. Guard with count($palette) === 0 before touching [0]. A solid-white image is a legitimate input and must not 500.

GD's format coverage is narrower than what users upload. HEIC from iPhones, AVIF, some CMYK JPEGs, and large animated GIFs will produce imagecreatefromstring(): false or a fatal. Treat "unsupported image" as a first-class branch: catch it, leave dominant_hex null, log with the file key, and let the admin UI show "no palette" rather than retrying the job forever. Note that 0.6.0's Imagick support helps here if Imagick is also installed — but don't assume both extensions exist on every environment.

Memory and timeouts bite in production, not locally. Set memory_limit explicitly on the queue worker (--memory=512) and a job $timeout of 30s or so. A single 50MB TIFF can otherwise take down a worker and poison the queue.

There are no color names. "Navy" and "burgundy" require your own hex→name table plus a nearest-match in a perceptual space. RGB Euclidean distance is wrong enough to be embarrassing (it thinks dark blue and dark green are close). If you need names, compute in LAB/ΔE — the package gives you counts and representative colors and deliberately stops there.

Color::fromIntToHex() returns uppercase with a leading #. Normalise on write. When building CSS, don't double up the # or try to append alpha — this package emits opaque #RRGGBB only.

Don't re-extract on read. Compute once, store on the record, and expose a small API field. Recomputing in an accessor will quietly add hundreds of milliseconds to every index page and will be invisible in profiling until someone pages through the media library.

Version sensitivity in snapshots. If you ever snapshot-test palette output, expect churn across minor versions (0.5.0 changed behaviour, 0.6.0 changed the ΔE maths). Keep those fixtures in a test that says so, so a future upgrade fails loudly in CI rather than silently shifting every product's theme color.

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