Laravel Glide Helper: on-the-fly image optimization in Blade
Table of contents
laravel-glide-helper is a small Composer package that adds one function to a Laravel application: glide(). Hand it an image and the size you want, and it returns the URL of a resized, compressed WebP -or the type you choose- copy, generated on the first request and served as a plain file on every one after that. It treats an image committed with the theme and one a client uploaded through the back office an hour ago exactly the same way.
The story behind it
I wrote it as CTO of a software company delivering client projects on a monthly cadence. Part of that role was to continuously optimize the projects build time.
A developer would finish a page — markup done, tests green — and then spend the rest of the afternoon on its images. Stock photos arrive at five thousand pixels wide. Client photos arrive as whatever the phone or camera produced. Each one had to be opened, cropped, resized to the two or three widths the layout used, exported, renamed and committed.
Across a team of four to six developers and a new project every month, that adds up to days of senior time a year spent doing by hand what a machine does automatically.
The images nobody on the team ever sees
The second problem starts after the handover. Almost everything we delivered came with a back office. So the client uploads a news photo straight off the camera or a product shot from a supplier's catalogue and the page is now several megabytes heavier than the one we delivered.
Nothing breaks, which is what makes it expensive. The page still looks right. It just loads slower, on a phone especially, the largest image on top of a page is usually its Largest Contentful Paint element, and it’s the Core Web Vital Google reads as how fast that page loads. A site handed over with a good performance score drifts down one upload at a time, and the search rankings the client is paying for drifts with it.
A client is not going to learn to export WebP at the right width, and they should not have to. The fix had to be in the code, applied to every image on its way to the page, whoever supplied it.
What Statamic already got right
I had been using the solution in Statamic which ships a Glide tag: the template names an image and the size it will be drawn at, and Statamic produces that version the first time it is requested and serves the stored file after.
<img src="{{ glide:hero_image width='1200' height='600' fit='crop_focal' format='webp' }}"
width="1200" height="600" alt="{{ hero_image:alt }}">
The decision that matters is where the size lives. It is written in the template, beside the markup that displays the image, which is the only place that knows how large the image will actually be drawn. The original file is never touched, nobody resizes anything by hand, and it makes no difference whether the file came from a developer's commit or from a client's upload form.
Blade had nothing like it. spatie/laravel-glide wraps the League's Glide library well, but it gives you an image object to manipulate and save, not a URL to put in a src attribute. So the helper started as a single function in one client project's helpers file, and once it had proved itself there I extracted it into a package and added it to the company's starter kit, so every new project had it from its first commit.
Installing laravel-glide-helper
The package needs PHP 8.1 or later and Laravel 10 or later, and brings spatie/laravel-glide in with it.
composer require mehdismekouar/laravel-glide-helper
# Optional: publish config/glide-helper.php to change the defaults
php artisan vendor:publish --tag="glide-helper-config"
# Required once per environment, if the link is not already there
php artisan storage:link
Using glide() in Blade templates
The signature is glide(string $src, array $params = []): string. The parameters are Glide's own short names — w and h for the size, fit for how the image fills that box, fm for the format, q for the quality — so anything the Glide documentation lists works here unchanged.
Images that ship with the theme
For an image that lives in the repository, pass its public URL. Use Vite::asset() for images under /resources folder and asset() for images under /public folder, and it works the same inside an inline style:
<!-- In case of Vite::asset() -->
<img src="{{ glide(Vite::asset('resources/images/home/desert-sunset.jpg'), ['w' => 800]) }}"
width="800" height="533" alt="Sunset over the dunes" loading="lazy">
<!-- In case of asset() -->
<section style="background-image: url('{{ glide(asset('images/hero-bg.jpg'), ['w' => 1600, 'q' => 75]) }}')">
...
</section>
The original stays in the repository at full resolution, which is what you want: the day the design asks for a larger version, you change one number instead of going back to find the source photo. During npm run dev, Vite::asset() points at the dev server, which the helper treats as an external URL and returns unchanged, so you see the original locally and the optimized copy on the build.
Images a client uploads through the back office
This is the most interesting case the package was written for. Whatever stores the upload — a plain file input, Filament, Spatie's Media Library — ends up holding a path on the public disk or a URL under /storage, and glide() resolves both:
{{-- A path stored by $request->file('cover')->store('posts', 'public') --}}
<img src="{{ glide($post->cover, ['w' => 1200, 'h' => 630, 'fit' => 'crop']) }}"
width="1200" height="630" alt="{{ $post->title }}">
{{-- A URL from Spatie Media Library --}}
<img src="{{ glide($circuit->getFirstMediaUrl('featured-image'), ['w' => 500]) }}"
width="500" alt="{{ $circuit->name }}" loading="lazy">
The client can upload an eight-megabyte photo straight off their phone. The page gets an optimized WebP -or another type you have chosen- at the width the layout draws, and nobody on the team is involved.
Responsive images with srcset
Each call produces one size, so a srcset is the same call at several widths, and the browser picks the smallest one that fills the slot:
@php
$srcset = collect([480, 800, 1200, 1600])
->map(fn ($width) => glide($product->image, ['w' => $width]).' '.$width.'w')
->implode(', ');
@endphp
<img src="{{ glide($product->image, ['w' => 800]) }}"
srcset="{{ $srcset }}"
sizes="(min-width: 1024px) 50vw, 100vw"
alt="{{ $product->name }}" loading="lazy">
Keep loading="lazy" off the image at the top of the page. That one is usually the Largest Contentful Paint element, and it wants loading="eager" and fetchpriority="high" instead — a lazy hero image is the most common way to give back everything the resize just bought.
How the helper works under the hood
The whole package is one function of about forty lines, and it is worth knowing what it does on each call:
It merges your parameters over the defaults in config/glide-helper.php.
A URL on another domain is returned untouched. That covers a CDN, an S3 bucket and the Vite dev server.
Otherwise it looks for the file on the disk first.
If it finds nothing, it returns what you passed in. A missing file renders as the broken image it already was, instead of an exception taking the page down.
It names the output after an MD5 of the file's path, its modification time and the parameters, and generates the image only if that file does not exist yet.
It returns the public URL of the generated file.
$hash = md5($sourcePath.filemtime($sourcePath).json_encode($params));
$hashedName = "{$hash}.{$extension}";
// ...
if (! file_exists($outputPath)) {
GlideImage::create($sourcePath)
->modify($params)
->save($outputPath);
}
return Storage::disk('public')->url($outputRelativePath);
The modification time in that hash is the detail I would keep if I rewrote everything else. A client who replaces a photo with a new file of the same name gets a new version on the next request, with no cache to clear.
After the first request the image is a static file. The web server serves it straight off disk, and on each render PHP does no more than compute a hash and check that a file exists.
Setting the defaults once
The defaults are what make a bare glide($src, ['w' => 800]) produce a WebP at quality 90. Change them in the published config rather than call by call.
return [
'defaults' => [
'q' => 90, // quality, 1-100
'fm' => 'webp', // output format
'fit' => 'max', // fit inside the box, never upscale
],
'output_dir' => 'manipulated', // on the public disk
];
For large background images, where nobody inspects the detail, a per-call 'q' => 75 is usually indistinguishable on screen and noticeably lighter.
What it changes for developers and teams
A developer drops the file the designer supplied into the repository, writes the size the layout needs, and moves on; almost nobody opens an image editor for a web build any more. Code review got simpler too: a glide() call states the size and the format in the template, where a reviewer reads it.
The larger win is the one nobody sees. A page keeps the weight it launched with however many photos the client uploads afterwards, because the client's upload is never what gets served, it’s the optimized version of it that does.
Limits worth knowing
The first request for each size pays for generating it. A very large original can add a noticeable delay to that one page load; every request after it is served as a static file.
Old versions are never deleted. Replacing a file produces a new hash and leaves the previous output behind in storage/app/public/manipulated -or your custom defined output_dir-, so on a site with a lot of uploads that folder needs an occasional clean-out. Emptying it is safe: everything regenerates on demand.
Only local files are processed. Images on S3 or any other remote disk pass through unchanged.
Where to get it
The package is on GitHub and Packagist, under the MIT licence.
If you are planning a Laravel project and want performance and SEO treated as part of the build, that is the work I do.