An image that has not finished downloading leaves a hole in the page. The usual fixes are a gray box, a blurred thumbnail encoded into the HTML, or nothing at all. A cheaper option that covers most of the value is to fill the space with the average color of the photo, so the layout reads as intentional before a single pixel of the real image arrives.
Laravel 13.24 added dominantColor() to the first-party image API, which gives you that color without a separate package. This article covers how to get the color, where to store it, and what to do with it once you have it.
Setup
The image component's GD and Imagick drivers are backed by Intervention Image, which is a suggested dependency rather than a required one:
composer require intervention/image:^4.0
Nothing else is needed. Dominant color detection works on both drivers.
Getting the Dominant Color
Call dominantColor() on any image instance and you get a hex string back:
use Illuminate\Support\Facades\Image; $color = Image::fromPath(storage_path('app/photo.jpg'))->dominantColor();// "#8a6f4c"
The implementation resizes a copy of the image down to a single pixel and reads that pixel's value. That is an average rather than a most-frequent color, so a photo of a red barn in a green field gives you a muddy olive, not red. For a placeholder that is exactly what you want, since the color needs to sit behind the whole image rather than represent its subject.
The hash prefix is always included. Images with an alpha channel return the eight digit form (#rrggbbaa), so size the column accordingly if you store it.
Transformations are applied before the color is sampled, which means you get the color of the image you are about to save rather than the original:
$color = Image::fromStorage('uploads/photo.jpg') ->crop(800, 600, x: 200, y: 0) ->dominantColor();
Crop away the sky and the color changes.
Storing the Dominant Color With the Upload
The color is only useful if it is available when you render the page, so compute it once at upload time and keep it on the model. Start with a column:
Schema::create('photos', function (Blueprint $table) { $table->id(); $table->string('path'); $table->string('dominant_color', 9)->nullable(); $table->timestamps();});
Then in the controller, build the image once and reuse the instance:
use App\Models\Photo; public function store(Request $request){ $request->validate([ 'photo' => ['required', 'image', 'max:12288'], ]); $photo = $request->image('photo') ->orient() ->scale(width: 1600) ->optimize(); return Photo::create([ 'path' => $photo->store('photos'), 'dominant_color' => $photo->dominantColor(), ]);}
Order matters here: store() runs the pipeline, caches the processed bytes on the instance, and clears the pipeline. A later dominantColor() call on the same instance samples those cached bytes and does no extra work. Reverse the two lines and the color is computed against a throwaway clone of the pipeline, leaving store() to process the image a second time. Same result, twice the work.
The value is also memoized per instance, so calling dominantColor() again is free. Because every transformation returns a new instance, a thumbnail branched off the same source computes its own color rather than inheriting one:
$source = Image::fromStorage('uploads/photo.jpg')->orient(); $thumbnail = $source->cover(300, 300);$display = $source->scale(width: 1600); $thumbnail->dominantColor(); // sampled from the cropped square$display->dominantColor(); // sampled from the full frame
Using the Dominant Color as a Placeholder
With the color on the model, the template is a background color on a box that already has the right aspect ratio:
<div class="aspect-[3/2] overflow-hidden rounded-lg" style="background-color: {{ $photo->dominant_color }}"> <img src="{{ Storage::url($photo->path) }}" alt="{{ $photo->caption }}" loading="lazy" class="h-full w-full object-cover"></div>
The colored box is painted with the first frame, the image fades in over it when it arrives, and the page never reflows. This pairs well with loading="lazy" on a long gallery, where most images are not fetched until the reader scrolls.
Readable Text Over the Color
Captions or icons placed on top of the placeholder need to stay legible whether the photo is a snowfield or a night sky. Relative luminance gives you a reasonable threshold in a few lines:
namespace App\Support; class Color{ public static function isLight(string $hex): bool { [$r, $g, $b] = sscanf(substr($hex, 0, 7), '#%02x%02x%02x'); // Rec. 709 luma, 0-255. return (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) > 140; }}
<figcaption class="{{ Color::isLight($photo->dominant_color) ? 'text-gray-900' : 'text-white' }}"> {{ $photo->caption }}</figcaption>
Letterbox and Rotation Fills
The same sampling is available as a background value inside a transformation. Passing dominant to contain() fills the letterbox bars with the image's own color instead of a fixed one you chose in advance:
Image::fromUpload($request->file('photo')) ->contain(1200, 800, background: 'dominant') ->store('photos');
A portrait photo padded to a landscape frame gets bars that belong to the photo, which reads far better in a grid than white or black bars on every third tile. rotate() takes the same value for the corners it exposes:
$image->rotate(8, background: 'dominant');
In both cases the sampling happens against the image at that point in the pipeline, so a crop earlier in the chain changes the fill.
What It Is Not
dominantColor() returns one averaged color. It is not a palette extractor, and it will not find the accent color a designer would pick out of a photo. If you need a set of representative swatches, or perceptual clustering that ignores a large flat background, this is not that feature and a dedicated library is still the right call.
For placeholders, letterbox fills, and tinting a card behind its own artwork, one averaged color is the whole job, and it now ships with the framework.
Further Reading
- A Practical Guide to Laravel's First-Party Image Processing covers the rest of the API: resizing, cropping, format conversion, and storage
- Image Dominant Color and HEIC Support in Laravel 13.24 has the full release notes
- The feature was contributed by @dr-codswallop in #60932