laravel-image maintained by eolica
laravel-image
Nuxt Image-inspired image URL generation for Laravel. Switch between CDN and on-the-fly resize providers via configuration, and render responsive images with Blade components.
Requirements
- PHP 8.4+
- Laravel 13+
- Optional: league/glide (^3.0) for the Glide provider
Installation
The package is auto-discovered via Composer. Publish the config when wiring a new app:
php artisan vendor:publish --tag=laravel-image-config
Required setup: Publish
config/image.phpand wire it for your environment. The package stub ships with all provider entries commented out and no default forIMAGE_PROVIDER— an unpublished or unconfigured config will fail at runtime. See Configuration for a complete example.
Configuration
Configuration lives in config/image.php (publish with --tag=laravel-image-config).
IMAGE_PROVIDER
The IMAGE_PROVIDER environment variable selects the default provider. It must match a key in the providers array.
| Value | Provider | Typical use |
|---|---|---|
imgproxy |
ImgproxyImageProvider | Signed URLs; imgproxy reads originals from S3 (s3://bucket/key) |
bunny |
BunnyImageProvider | Bunny CDN query-string transforms |
glide |
GlideImageProvider | Signed URLs via League Glide (requires composer require league/glide) |
IMAGE_PROVIDER=imgproxy
Individual Blade components can override the default with a provider prop (e.g. provider="bunny").
Recommended config/image.php
<?php
use Eolica\LaravelImage\Providers\BunnyImageProvider;
use Eolica\LaravelImage\Providers\GlideImageProvider;
use Eolica\LaravelImage\Providers\ImgproxyImageProvider;
return [
'provider' => env('IMAGE_PROVIDER', 'imgproxy'),
'screens' => [
'xs' => 320,
'sm' => 640,
'md' => 768,
'lg' => 1024,
'xl' => 1280,
'2xl' => 1536,
],
'densities' => [1, 2],
'providers' => [
'imgproxy' => [
'driver' => ImgproxyImageProvider::class,
'base_url' => env('IMGPROXY_BASE_URL', 'http://localhost:8080'),
'key' => env('IMGPROXY_KEY'),
'salt' => env('IMGPROXY_SALT'),
'bucket' => env('AWS_BUCKET', 'public'),
'defaults' => [
'quality' => 80,
],
],
'glide' => [
'driver' => GlideImageProvider::class,
'base_path' => '/images',
'sign_key' => env('GLIDE_SIGN_KEY'),
'app_url' => env('APP_URL'),
],
'bunny' => [
'driver' => BunnyImageProvider::class,
'base_url' => env('BUNNY_BASE_URL', 'https://cdn.example.com'),
'defaults' => [
'quality' => 80,
],
],
],
'presets' => [
'og' => [
'width' => 1200,
'height' => 630,
'fit' => 'cover',
'format' => 'webp',
'quality' => 80,
],
],
];
Environment variables
| Variable | Used by | Description |
|---|---|---|
IMAGE_PROVIDER |
All | Default provider key (imgproxy, bunny, or glide) |
IMGPROXY_BASE_URL |
imgproxy | imgproxy server URL (default http://localhost:8080) |
IMGPROXY_KEY |
imgproxy | Hex-encoded signing key (64 hex chars / 32 bytes) |
IMGPROXY_SALT |
imgproxy | Hex-encoded signing salt (64 hex chars / 32 bytes) |
AWS_BUCKET |
imgproxy | S3 bucket name imgproxy reads from (imgproxy bucket setting) |
AWS_ACCESS_KEY_ID |
imgproxy server | S3 credentials configured on the imgproxy instance |
AWS_SECRET_ACCESS_KEY |
imgproxy server | S3 credentials configured on the imgproxy instance |
AWS_DEFAULT_REGION |
imgproxy server | S3 region configured on the imgproxy instance |
GLIDE_SIGN_KEY |
glide | HMAC signing key for Glide URLs |
BUNNY_BASE_URL |
bunny | Bunny CDN origin (e.g. https://cdn.example.com) |
Provider-level defaults (e.g. quality => 80) are the least specific layer when building transform URLs. Presets and explicit props override defaults (most specific wins).
Named presets (presets)
Define reusable modifier bundles in presets and reference them via the preset prop in Blade or the preset argument in PHP:
'presets' => [
'og' => [
'width' => 1200,
'height' => 630,
'fit' => 'cover',
'format' => 'webp',
'quality' => 80,
],
],
Merge order (least → most specific):
provider defaults → preset → explicit props / ImageModifiers
Display attributes on ResolvedImage (width, height, format) come from preset + props only — not provider defaults like quality.
Responsive breakpoints (screens)
The screens array defines breakpoint names used by <x-image::source> and the sizes prop. Breakpoints are mobile-first, like Tailwind: each key maps to a min-width media query, so a screen applies from that width up:
mdwith value768→(min-width: 768px)
Screen widths are also the viewport widths sizes generates candidates for (see Responsive images).
Default densities (densities)
The pixel densities generated for sizes when a component does not set densities. Defaults to [1, 2].
imgproxy provider
The imgproxy driver builds signed URLs that point at an imgproxy server. imgproxy fetches originals from S3 using paths like s3://{bucket}/{key} — the bucket comes from provider config (AWS_BUCKET by default).
Transform options are emitted as Nuxt-style path segments (via resolveModifiers() + OperationsSerializer), for example:
/{signature}/rt:fit/w:640/h:320/q:90/f:webp/plain/s3://public/images/web/hero.webp
Cover crops use rt:fill with gravity (g:ce or g:nowe). Single-dimension resize uses 0 on the auto axis (rt:fit/w:640/h:0).
Requirements
- A running imgproxy instance with S3 access to the bucket configured in
config/image.php. - Matching signing key and salt in Laravel (
IMGPROXY_KEY,IMGPROXY_SALT) and on the imgproxy server. - Original files already stored in the bucket at the paths referenced by your
srcvalues.
This package generates URLs only; uploading originals to S3 (or any S3-compatible store) is the consuming application's responsibility.
Generate key and salt
imgproxy requires 64-character hex strings (32 bytes each):
openssl rand -hex 32 # → IMGPROXY_KEY
openssl rand -hex 32 # → IMGPROXY_SALT
Set the same values in your Laravel .env and in the imgproxy server environment, then restart imgproxy.
Example .env
IMAGE_PROVIDER=imgproxy
IMGPROXY_BASE_URL=http://localhost:8080
IMGPROXY_KEY=…
IMGPROXY_SALT=…
AWS_BUCKET=public
Configure S3 credentials and endpoint on the imgproxy server itself (see imgproxy S3 documentation). For local development, any S3-compatible backend (MinIO, RustFS, LocalStack, etc.) works as long as imgproxy can read from it.
Verify
curl -I "${IMGPROXY_BASE_URL}/health"
Reference bucket-relative paths in Blade — a file stored as images/hero.webp in the bucket:
<x-image::image src="images/hero.webp" width="640" height="320" />
Blade components
Components are registered under the image namespace: <x-image::…>.
Shared props for <x-image::image> and <x-image::picture>:
| Prop | Type | Description |
|---|---|---|
src |
string | Required. Path relative to the bucket/CDN root (leading / is stripped) |
preset |
string | Named modifier bundle from config('image.presets') |
width |
int/string | Target width in pixels |
height |
int/string | Target height in pixels |
fit |
string | Resize/crop mode (see below) |
format |
string | Output format (e.g. webp, jpg) |
quality |
int/string | Output quality (1–100) |
provider |
string | Override the default IMAGE_PROVIDER |
sizes |
string | Mobile-first rendered sizes (100vw sm:50vw lg:400px); renders a width-descriptor srcset and sizes. See Responsive images |
densities |
string | Pixel densities (1x 2x); on their own they render a density-descriptor srcset |
original-width |
int | Width of the original image; srcset candidates never exceed it. See Original size |
original-height |
int | Height of the original image; limits cover candidates. See Original size |
preload |
bool | Publish a <link rel="preload" as="image"> via ImagePreloadPublisher, including imagesrcset and imagesizes (default false) |
Additional HTML attributes (alt, class, loading, etc.) are forwarded to the rendered <img>. A srcset attribute is ignored: use sizes or densities instead.
Fit modes
| Value | Behavior |
|---|---|
cover |
Crop to fill dimensions, center gravity |
cover-center |
Same as cover |
cover-top-left |
Crop to fill, top-left gravity |
| (unset) | Fit within dimensions without cropping |
<x-image::image>
Renders a single <img> with a provider-generated URL.
<x-image::image
src="images/web/hero.webp"
width="640"
height="320"
fit="cover"
format="webp"
alt="Hero"
loading="lazy"
class="w-full object-cover"
/>
Set :preload="true" to publish a <link rel="preload" as="image"> for the resolved URL, with its srcset and sizes as imagesrcset and imagesizes. By default this uses StackImagePreloadPublisher, which pushes onto the Blade @stack('head') — your layout must include @stack('head'). See Preload publishing to integrate with a custom head manager.
Use a preset for common sizes (e.g. Open Graph):
<x-image::image src="images/web/hero.webp" preset="og" alt="Hero" />
<x-image::picture>
Renders a <picture> wrapper. The fallback <img> uses the props on the picture element and covers the smallest screens; <x-image::source> children override it from their breakpoint up.
The browser uses the first <source> whose media query matches, so list sources from the largest screen to the smallest.
<x-image::picture
src="images/web/hero.webp"
width="640"
height="800"
fit="cover"
format="webp"
alt="Hero"
loading="lazy"
class="w-full object-cover"
>
<x-image::source screen="lg" width="1280" height="640" fit="cover" format="webp" />
<x-image::source screen="md" width="1024" height="640" fit="cover" format="webp" />
</x-image::picture>
Set :preload="true" to preload every <source> and the fallback <img>, each with a media range that does not overlap the others, so the browser only downloads the one it will render. Pair with fetchpriority="high" and loading="eager" for LCP heroes. See Preload publishing.
<x-image::source>
Must be nested inside <x-image::picture>. Inherits src, optional provider, and optional preset from the parent via @aware. An explicit preset on the source overrides the parent.
| Prop | Type | Description |
|---|---|---|
screen |
string | Required. Breakpoint key from config('image.screens') |
preset |
string | Override parent preset (inherits from picture when omitted) |
width |
int/string | Required. Width for this breakpoint |
height |
int/string | Required. Height for this breakpoint |
fit |
string | Fit mode |
format |
string | Output format |
quality |
int/string | Output quality |
sizes |
string | Rendered sizes for this source; candidates start at its screen |
densities |
string | Pixel densities for this source |
sizes and densities are not inherited from the picture; original-width and original-height are, since every source crops the same image. Renders <source media="(min-width: …px)" srcset="…" />, plus sizes when set. Additional HTML attributes (for example type) are forwarded; media and srcset attributes are ignored, since they come from screen, sizes and densities.
Responsive images (sizes and densities)
Without sizes or densities, components render a single URL. With them, they render a srcset and let the browser pick the smallest candidate that covers the rendered width times the device pixel ratio.
Fixed-size images: densities
For images that always render at the same CSS size (avatars, logos), list the densities to generate. width and height are multiplied by each one:
<x-image::image src="images/avatar.webp" width="64" height="64" fit="cover" densities="1x 2x" />
<img src="…/w:64/h:64…" srcset="…/w:64/h:64… 1x, …/w:128/h:128… 2x" width="64" height="64">
Densities accept 1x 2x, x1 x2 (Nuxt style), commas and fractions (1.5x). A width or a height is required.
Fluid images: sizes
For images whose width depends on the layout, describe the rendered width per screen, mobile-first:
<x-image::image src="images/cover.webp" width="800" height="600" fit="cover" sizes="100vw sm:50vw lg:400px" />
- An entry without a screen applies from 0px;
screen:valueapplies from that screen up, until the next larger entry. Without an unprefixed entry, smaller screens fall back to100vw. - Values are
vw(fluid) orpx(fixed); a bare number ispx.
This renders:
<img src="…/w:800/h:600…"
srcset="…/w:320/h:240… 320w, …/w:384/h:288… 384w, …/w:400/h:300… 400w, …/w:512/h:384… 512w,
…/w:640/h:480… 640w, …/w:768/h:576… 768w, …/w:800/h:600… 800w, …/w:1024/h:768… 1024w,
…/w:1280/h:960… 1280w"
sizes="(min-width: 1024px) 400px, (min-width: 640px) 50vw, 100vw"
width="800" height="600">
With the default screens and densities, candidates are computed per range and multiplied by each density (the densities prop, or config('image.densities')):
- A
pxrange contributes its own width:400. - A
vwrange contributes its width at every configured screen inside it.100vwup tosmgives320(atxs) and640;50vwfromsmtolggives384(atmd) and512(atlg). The open-ended last range uses every larger screen, or its own screen when there is none.
When both width and height are set, each candidate keeps their aspect ratio; otherwise candidates only set the width. src, width and height keep the given values, for the fallback and to reserve layout space.
Original size
Providers do not enlarge images, so a candidate wider than the original would be served at the original size under a larger descriptor. Pass the original dimensions to cap the candidates:
<x-image::image
:src="$media->path"
width="400"
height="300"
fit="cover"
sizes="100vw sm:50vw lg:30vw"
:original-width="$media->width"
:original-height="$media->height"
/>
- Width candidates above the largest width the original allows are replaced by one candidate at that width:
…, 922w, 960winstead of…, 922w, 1024w, 1280wfor a 960px wide original. - With
cover, the original height also limits the width, since the crop keeps its aspect ratio: a 4:5 crop of a 960×720 original is at most 576px wide. - Density candidates stop at the original too, with the real density as descriptor:
1x, 1.6xinstead of1x, 2x, 3x. The first density is always kept.
Art direction with sizes
On <x-image::source>, candidates only cover viewports from its screen up, so a desktop crop gets no mobile-sized variants. The fallback <img> candidates stop at the smallest source screen, since the browser never renders it above that:
<x-image::picture src="images/cover.webp" width="800" height="1000" fit="cover" sizes="100vw" alt="…">
<x-image::source screen="lg" width="1600" height="900" fit="cover" sizes="100vw" />
</x-image::picture>
Mobile gets a 4:5 crop from the <img>, with candidates for viewports up to lg; from lg up the <source> serves a 16:9 crop, each with its own srcset.
Preload publishing
When :preload="true" is set on <x-image::image> or <x-image::picture>, ImagePreloader passes an ImagePreload (url, srcset, sizes, media) to ImagePreloadPublisher for each image to preload.
On a picture, sources are matched like the browser does: in document order, the first matching media wins. Each preload gets the range where its image is actually rendered:
<x-image::picture src="images/web/hero.webp" width="640" height="800" :preload="true">
<x-image::source screen="lg" width="1600" height="900" />
<x-image::source screen="md" width="1024" height="768" />
</x-image::picture>
<link rel="preload" as="image" href="…?w=1600&h=900" media="(min-width: 1024px)" />
<link rel="preload" as="image" href="…?w=1024&h=768" media="(min-width: 768px) and (max-width: 1023.98px)" />
<link rel="preload" as="image" href="…?w=640&h=800" media="(max-width: 767.98px)" />
A source that an earlier source always wins over is not preloaded.
The default implementation is PreloadPublishers\StackImagePreloadPublisher, which pushes a <link rel="preload" as="image"> onto a Blade stack.
Configure in config/image.php:
'preload' => [
'publisher' => \Eolica\LaravelImage\PreloadPublishers\StackImagePreloadPublisher::class,
'stack' => 'head',
],
Custom publisher
Implement ImagePreloadPublisher in your app and bind it (or set image.preload.publisher):
use Eolica\LaravelImage\ImagePreload;
use Eolica\LaravelImage\ImagePreloadPublisher;
final readonly class MyHeadPreloadPublisher implements ImagePreloadPublisher
{
public function __construct(private MyHeadManager $head) {}
public function preload(ImagePreload $preload): void
{
$this->head->addLink(array_filter([
'rel' => 'preload',
'as' => 'image',
'href' => $preload->url,
'imagesrcset' => $preload->srcset,
'imagesizes' => $preload->sizes,
'media' => $preload->media,
]));
}
}
// AppServiceProvider::register()
$this->app->bind(ImagePreloadPublisher::class, MyHeadPreloadPublisher::class);
The ev-mallorca host app uses App\Image\HeadImagePreloadPublisher with Seo\Head\Head so preloads render via {!! $head['headTags'] !!} instead of @stack('head').
Programmatic usage
Inject or resolve ImageManager. resolve() is the primary API — it returns a ResolvedImage DTO with the provider URL and display attributes (width, height, format from preset + explicit modifiers, excluding provider defaults):
use Eolica\LaravelImage\ImageManager;
use Eolica\LaravelImage\ImageModifiers;
$resolved = app(ImageManager::class)->resolve(
'images/web/hero.webp',
ImageModifiers::fromProps(width: 1200, height: 630, fit: 'cover', format: 'webp'),
);
$resolved->url; // provider transform URL (meta tags, preload, etc.)
$resolved->width; // display width
$resolved->height; // display height
$resolved->format; // display format
// Responsive candidates (null without sizes or densities)
$resolved = app(ImageManager::class)->resolve(
'images/web/hero.webp',
ImageModifiers::fromProps(width: 800, height: 600, fit: 'cover'),
sizes: '100vw lg:400px',
densities: '1x 2x', // optional with sizes; defaults to config('image.densities')
screen: 'lg', // optional: only viewports from this screen up (for <source>)
untilScreen: 'xl', // optional: only viewports below this screen (for a picture fallback)
originalWidth: 960, // optional: caps candidates at the original size
originalHeight: 720,
);
$resolved->srcset; // "… 400w, … 640w, …"
$resolved->sizes; // "(min-width: 1024px) 400px, 100vw"
// Named preset with optional overrides
$resolved = app(ImageManager::class)->resolve(
'images/web/hero.webp',
ImageModifiers::fromProps(width: 800),
preset: 'og',
);
$mediaQuery = app(ImageManager::class)->mediaQuery('md'); // "(min-width: 768px)"
url() remains a thin wrapper for one-liners:
$url = app(ImageManager::class)->url(
'images/web/hero.webp',
ImageModifiers::fromProps(width: 1200),
preset: 'og',
);
Pass a third argument to override the provider: ->resolve($src, $modifiers, 'bunny').
Custom providers
Implement Eolica\LaravelImage\ImageProvider and register a factory on the registry. Registration must happen after the package registers its built-in drivers, using afterResolving:
use Eolica\LaravelImage\ImageManager;
use Eolica\LaravelImage\ImageModifiers;
use Eolica\LaravelImage\ImageProvider;
use Eolica\LaravelImage\ImageProviderRegistry;
use Eolica\LaravelImage\ProviderSettings;
final readonly class AcmeImageProvider implements ImageProvider
{
public function __construct(private string $baseUrl) {}
public function url(string $src, ImageModifiers $modifiers): string
{
return rtrim($this->baseUrl, '/').'/'.ltrim($src, '/');
}
}
In AppServiceProvider::register():
$this->app->afterResolving(ImageProviderRegistry::class, function (ImageProviderRegistry $registry): void {
$registry->register(AcmeImageProvider::class, fn (ProviderSettings $settings): AcmeImageProvider => new AcmeImageProvider(
baseUrl: $settings->requireString('base_url'),
));
});
Add the provider to config/image.php:
'providers' => [
// …
'acme' => [
'driver' => AcmeImageProvider::class,
'base_url' => env('ACME_CDN_URL'),
'defaults' => [],
],
],
Use ProviderSettings helpers in the factory:
requireString($key)— required non-empty stringoptionalString($key)— string ornullstring($key, $default)— string with fallbackrequireInt($key)— required integer (accepts int or numeric string)optionalInt($key)— integer ornull
The driver and defaults keys in config are consumed by ImageManagerFactory and are not passed to your factory callback. Only provider-specific keys (for example base_url, key, salt) are forwarded via ProviderSettings.
Architecture
config/image.php
→ ImageManagerFactory
→ ImageProviderRegistry (resolve driver classes)
→ ImageProvider instances
→ ImageManager
→ Blade components (picture, image, source)
Built-in drivers are registered in ImageServiceProvider. The core classes (ImageManager, ImageModifiers, ImageScreens, ImagePresets, ResolvedImage, etc.) are framework-agnostic and intended for eventual extraction to a standalone eolica/image package.
Testing
composer test
composer analyse
Known limitations
- Published config stub — Provider entries are commented out and
IMAGE_PROVIDERhas no default. Publishconfig/image.php, uncomment the providers you need, and setIMAGE_PROVIDERin.env.
License
MIT © Eolica Digital