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
image-variants-development
fruitcake/laravel-image-variants
claude-code
image-variants
image-processing
presets
srcset
hmac
caching

Generate resized, cropped and converted image variants on demand with fruitcake/laravel-image-variants — building variant URLs and srcsets, the <x-variant> Blade component, defining presets, and the operation grammar.

Install
php artisan boost:add-skill fruitcake/laravel-image-variants

Save this content to: .claude/skills/image-variants-development/SKILL.md

# Image Variants Development

## When to use this skill

Use this skill when the application has `fruitcake/laravel-image-variants`
installed and the task involves rendering images at a particular size or format:
thumbnails, responsive `srcset`s, WebP conversion, cropping, or anything that
would otherwise reach for a pre-generated image pipeline.

## How it works

You ask for a variant and get a URL back. Nothing is generated at that point. The
**first** request for that URL reaches PHP, which writes the image to exactly the
path the browser asked for — below the `storage:link` symlink. **Every request
after that** is served by the web server straight off disk, with PHP never
involved again.

The URL looks like this:

```
/storage/variants/{preset}/{hash}/{name}?src=…&…operations…
```

The `hash` is an HMAC keyed with `APP_KEY` over the preset, the merged
operations, the source and the name. It is what makes the endpoint safe to
expose — without the key nobody can produce a URL the package will act on.

The hash covers more than the query says. The preset and the configured defaults
are merged back in before the signature is checked, so the query carries only
what the server cannot look up: the source, plus whatever a caller added or
overrode. A preset URL is therefore just `?src=…`. A shorter query says less; it
never permits more.

There is no unsigned path to a variant, for any preset. `fromRequest()` verifies
the signature itself and throws rather than returning an unverified object, so
there is nothing to bypass and nothing for a caller to forget.

## Rules

- **Never build a variant URL by hand, in PHP or in a template.** The HMAC will
  not match and the request will 404. Always go through the facade or component.
  This applies to preset URLs too — the preset name in the path is signed, not a
  shortcut around signing.
- **Never write to `config('image-variants.cache')` yourself.** It is generated
  output; the package owns it.
- **`$src` is relative to the configured source disk**, not `public_path()`.
  Check `config('image-variants.source.disk')` (default: `public`, i.e.
  `storage/app/public`) before assuming a path resolves.
- **Only one geometry operation at a time.** `cover`, `contain`, `resize` and
  `scale` are mutually exclusive and combining them throws. This bites most often
  when calling `srcset()` (which adds `scale`) with a preset built on `cover`.
- **Prefer a named preset in `config/image-variants.php`** over repeating the
  same inline operations across templates. The preset name becomes a directory in
  the URL, so keep it readable.
- Editing a preset changes the hash of every URL using it, so old variants are
  never served stale. There is no cache to bust.

## Blade

Prefer the component. It assembles the URL, the `srcset`, and the `width` and
`height` that stop the page shifting — all from one description:

```blade
<x-variant src="uploads/team.jpg" preset="thumb" alt="The team" />
```

```blade
<x-variant
    src="uploads/office.jpg"
    :widths="[640, 1024, 1600]"
    sizes="(max-width: 900px) 100vw, 900px"
    format="webp"
    alt="Our office"
/>
```

Props: `src`, `preset` (name or `:preset="['cover' => [60, 40]]"`), `format`,
`name`, `:operations`, `:widths`, `sizes`. Anything else (`class`, `loading`,
`decoding`, `fetchpriority`, …) passes through to the tag.

Always pass a real `alt` for images that carry meaning. The component emits
`alt=""` when you do not, which tells a screen reader the image is decorative.

For a URL somewhere that is not an `<img>` — an `og:image`, a CSS background, a
`<source>` — use the facade.

## The facade

```php
use Fruitcake\ImageVariants\Facades\Variants;

Variants::url(
    string $src,                       // relative to the source disk
    string|array $preset = 'custom',   // a preset name, or operations
    ?string $format = null,            // output extension; defaults to the source's
    ?string $name = null,              // filename to serve as; defaults to the source's
    array $operations = [],            // merged over the preset
);
```

```php
Variants::url('uploads/bg.jpg', 'hero');
Variants::url('uploads/bg.jpg', ['cover' => [600, 500], 'quality' => 80], 'webp');
Variants::url('uploads/bg.jpg', 'thumb', 'webp', 'team-photo', ['grayscale' => true]);

Variants::srcset('uploads/bg.jpg', [640, 1024, 1600], 'webp');
Variants::dimensions('uploads/bg.jpg', 'thumb');   // ['width' => 100, 'height' => 100] or null
Variants::make('uploads/bg.jpg', 'thumb');         // the Variant object
```

`dimensions()` returns `null` when the answer depends on decoding the image —
an `orient` (EXIF) or a `rotate` off the square. Handle that case; do not guess.

## Twig

If the application renders Twig, register
`Fruitcake\ImageVariants\Twig\VariantExtension` with the environment. It provides
`variant()`, `srcset()` and `variant_size()` (named that way because Twig
functions share one global namespace).

## Operations

Named after the methods on `Illuminate\Image\Image`. Written as arrays in PHP, as
query parameters in the URL; both normalise to the same thing. Applied in this
fixed order regardless of how they were written:

| Operation | Arguments | Example |
| --- | --- | --- |
| `orient` | — | `['orient' => true]` |
| `rotate` | angle `0–359`, optional background | `['rotate' => [90, 'ffffff']]` |
| `flip` | `v` or `h` | `['flip' => 'v']` |
| `crop` | width, height, optional x, y | `['crop' => [300, 200, 50, 25]]` |
| `cover` | width, height (both required) | `['cover' => [600, 500]]` |
| `contain` | width, height, optional background | `['contain' => [600, 500, 'fff']]` |
| `resize` | width and/or height | `['resize' => [800, 600]]` |
| `scale` | width and/or height | `['scale' => [800, null]]` |
| `grayscale` | — | `['grayscale' => true]` |
| `blur` | `0–100`, default `5` | `['blur' => 10]` |
| `sharpen` | `0–100`, default `10` | `['sharpen' => 15]` |
| `quality` | `1–100`, defaults to `image-variants.quality` | `['quality' => 80]` |

- `scale` preserves aspect ratio and never enlarges past the source. `resize`
  does not preserve it. `cover` and `contain` need both dimensions.
- Backgrounds are bare hex without `#` (`ffffff` or `fff`), or `dominant`.
- `quality` falls back to the configured `image-variants.quality` (`80`) when
  neither the preset nor the URL sets one. It is merged underneath both, and
  signed like anything else, so changing it regenerates the affected variants.
- Passing `false` or `null` removes an operation, but only in a **preset** —
  `'original' => ['scale' => [2000, null], 'quality' => false]`. A drop leaves no
  trace in the query, so doing it in `$operations` on top of a preset or the
  configured default throws rather than returning a URL that would 404.
- Anything outside the grammar throws `InvalidArgumentException` when building
  the URL, and 404s when it arrives over HTTP.

## Presets

```php
// config/image-variants.php
'presets' => [
    'thumb' => ['cover' => [100, 100], 'quality' => 80],
    'photo' => ['scale' => [800, null], 'quality' => 80],
    'hero'  => ['scale' => [1600, null], 'quality' => 80],
],
```

## Configuration worth knowing

| Key | Default | Notes |
| --- | --- | --- |
| `source.disk` | `public` | Disk sources are read from. Any disk, including `s3`. |
| `source.prefix` | `null` | Confines sources to a directory within that disk. |
| `cache` | `storage_path('app/public/variants')` | Always a local path, so the web server can serve it. |
| `max_source_megapixels` | `24` | Bounds what decoding costs. `0` disables. |
| `quality` | `80` | Used when neither the preset nor the URL sets one. `null` leaves it to the encoder. |
| `blade.component` | `variant` | Rename if the app already has an `<x-variant>`. |
| `lock.*` | enabled | Serialises generation of the same variant across workers. |
| `presets` | `[]` | |

## Exceptions

- `VariantException` (extends `InvalidArgumentException`) — a bad URL: source
  missing, escapes its root, wrong format, over the megapixel limit. Answered
  with **404**.
- `VariantConfigurationException` (extends `RuntimeException`) — the application
  is misconfigured: no source disk, a disk missing from `filesystems.disks`, an
  unset `APP_KEY`, a cache store that cannot lock. Deliberately **not** caught by
  the controller, so a broken deployment surfaces as a 500 rather than as images
  that quietly never appear.

## Setup and maintenance

```bash
php artisan storage:link                                   # required
php artisan vendor:publish --tag=image-variants-config      # optional

php artisan image-variants:cleanup --days=90 --dry-run      # sweep old variants
```

The web server must serve existing files before handing to PHP (the stock Laravel
nginx `try_files` and shipped `.htaccess` both do). If every request is routed to
`index.php` unconditionally, every request regenerates and the package's whole
point is lost.

Image Variants Development

When to use this skill

Use this skill when the application has fruitcake/laravel-image-variants installed and the task involves rendering images at a particular size or format: thumbnails, responsive srcsets, WebP conversion, cropping, or anything that would otherwise reach for a pre-generated image pipeline.

How it works

You ask for a variant and get a URL back. Nothing is generated at that point. The first request for that URL reaches PHP, which writes the image to exactly the path the browser asked for — below the storage:link symlink. Every request after that is served by the web server straight off disk, with PHP never involved again.

The URL looks like this:

/storage/variants/{preset}/{hash}/{name}?src=…&…operations…

The hash is an HMAC keyed with APP_KEY over the preset, the merged operations, the source and the name. It is what makes the endpoint safe to expose — without the key nobody can produce a URL the package will act on.

The hash covers more than the query says. The preset and the configured defaults are merged back in before the signature is checked, so the query carries only what the server cannot look up: the source, plus whatever a caller added or overrode. A preset URL is therefore just ?src=…. A shorter query says less; it never permits more.

There is no unsigned path to a variant, for any preset. fromRequest() verifies the signature itself and throws rather than returning an unverified object, so there is nothing to bypass and nothing for a caller to forget.

Rules

  • Never build a variant URL by hand, in PHP or in a template. The HMAC will not match and the request will 404. Always go through the facade or component. This applies to preset URLs too — the preset name in the path is signed, not a shortcut around signing.
  • Never write to config('image-variants.cache') yourself. It is generated output; the package owns it.
  • $src is relative to the configured source disk, not public_path(). Check config('image-variants.source.disk') (default: public, i.e. storage/app/public) before assuming a path resolves.
  • Only one geometry operation at a time. cover, contain, resize and scale are mutually exclusive and combining them throws. This bites most often when calling srcset() (which adds scale) with a preset built on cover.
  • Prefer a named preset in config/image-variants.php over repeating the same inline operations across templates. The preset name becomes a directory in the URL, so keep it readable.
  • Editing a preset changes the hash of every URL using it, so old variants are never served stale. There is no cache to bust.

Blade

Prefer the component. It assembles the URL, the srcset, and the width and height that stop the page shifting — all from one description:

<x-variant src="uploads/team.jpg" preset="thumb" alt="The team" />
<x-variant
    src="uploads/office.jpg"
    :widths="[640, 1024, 1600]"
    sizes="(max-width: 900px) 100vw, 900px"
    format="webp"
    alt="Our office"
/>

Props: src, preset (name or :preset="['cover' => [60, 40]]"), format, name, :operations, :widths, sizes. Anything else (class, loading, decoding, fetchpriority, …) passes through to the tag.

Always pass a real alt for images that carry meaning. The component emits alt="" when you do not, which tells a screen reader the image is decorative.

For a URL somewhere that is not an <img> — an og:image, a CSS background, a <source> — use the facade.

The facade

use Fruitcake\ImageVariants\Facades\Variants;

Variants::url(
    string $src,                       // relative to the source disk
    string|array $preset = 'custom',   // a preset name, or operations
    ?string $format = null,            // output extension; defaults to the source's
    ?string $name = null,              // filename to serve as; defaults to the source's
    array $operations = [],            // merged over the preset
);
Variants::url('uploads/bg.jpg', 'hero');
Variants::url('uploads/bg.jpg', ['cover' => [600, 500], 'quality' => 80], 'webp');
Variants::url('uploads/bg.jpg', 'thumb', 'webp', 'team-photo', ['grayscale' => true]);

Variants::srcset('uploads/bg.jpg', [640, 1024, 1600], 'webp');
Variants::dimensions('uploads/bg.jpg', 'thumb');   // ['width' => 100, 'height' => 100] or null
Variants::make('uploads/bg.jpg', 'thumb');         // the Variant object

dimensions() returns null when the answer depends on decoding the image — an orient (EXIF) or a rotate off the square. Handle that case; do not guess.

Twig

If the application renders Twig, register Fruitcake\ImageVariants\Twig\VariantExtension with the environment. It provides variant(), srcset() and variant_size() (named that way because Twig functions share one global namespace).

Operations

Named after the methods on Illuminate\Image\Image. Written as arrays in PHP, as query parameters in the URL; both normalise to the same thing. Applied in this fixed order regardless of how they were written:

Operation Arguments Example
orient — ['orient' => true]
rotate angle 0–359, optional background ['rotate' => [90, 'ffffff']]
flip v or h ['flip' => 'v']
crop width, height, optional x, y ['crop' => [300, 200, 50, 25]]
cover width, height (both required) ['cover' => [600, 500]]
contain width, height, optional background ['contain' => [600, 500, 'fff']]
resize width and/or height ['resize' => [800, 600]]
scale width and/or height ['scale' => [800, null]]
grayscale — ['grayscale' => true]
blur 0–100, default 5 ['blur' => 10]
sharpen 0–100, default 10 ['sharpen' => 15]
quality 1–100, defaults to image-variants.quality ['quality' => 80]
  • scale preserves aspect ratio and never enlarges past the source. resize does not preserve it. cover and contain need both dimensions.
  • Backgrounds are bare hex without # (ffffff or fff), or dominant.
  • quality falls back to the configured image-variants.quality (80) when neither the preset nor the URL sets one. It is merged underneath both, and signed like anything else, so changing it regenerates the affected variants.
  • Passing false or null removes an operation, but only in a preset — 'original' => ['scale' => [2000, null], 'quality' => false]. A drop leaves no trace in the query, so doing it in $operations on top of a preset or the configured default throws rather than returning a URL that would 404.
  • Anything outside the grammar throws InvalidArgumentException when building the URL, and 404s when it arrives over HTTP.

Presets

// config/image-variants.php
'presets' => [
    'thumb' => ['cover' => [100, 100], 'quality' => 80],
    'photo' => ['scale' => [800, null], 'quality' => 80],
    'hero'  => ['scale' => [1600, null], 'quality' => 80],
],

Configuration worth knowing

Key Default Notes
source.disk public Disk sources are read from. Any disk, including s3.
source.prefix null Confines sources to a directory within that disk.
cache storage_path('app/public/variants') Always a local path, so the web server can serve it.
max_source_megapixels 24 Bounds what decoding costs. 0 disables.
quality 80 Used when neither the preset nor the URL sets one. null leaves it to the encoder.
blade.component variant Rename if the app already has an <x-variant>.
lock.* enabled Serialises generation of the same variant across workers.
presets []

Exceptions

  • VariantException (extends InvalidArgumentException) — a bad URL: source missing, escapes its root, wrong format, over the megapixel limit. Answered with 404.
  • VariantConfigurationException (extends RuntimeException) — the application is misconfigured: no source disk, a disk missing from filesystems.disks, an unset APP_KEY, a cache store that cannot lock. Deliberately not caught by the controller, so a broken deployment surfaces as a 500 rather than as images that quietly never appear.

Setup and maintenance

php artisan storage:link                                   # required
php artisan vendor:publish --tag=image-variants-config      # optional

php artisan image-variants:cleanup --days=90 --dry-run      # sweep old variants

The web server must serve existing files before handing to PHP (the stock Laravel nginx try_files and shipped .htaccess both do). If every request is routed to index.php unconditionally, every request regenerates and the package's whole point is lost.

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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource