---
url: /frontend/image-rendering.md
---
# Image Rendering

Render and manipulate images on the fly

## Basic usage

You can generate image URLs by using the `image` helper function. The function accepts a `Media` model instance or a static asset filename as the first argument.

```blade
<img src="{{ image($block->image) }}">

// or

<img src="{{ image('logo.png') }}">
```

You can also pass a tuple of width and height as the second argument to define the image size.

```blade
<img src="{{ image($media, [300, 300]) }}">
```

Pass a string or an array of filters as the third argument to apply filters to the image.

```blade
<img src="{{ image($media, [300, 300], ['crop', 'bw']) }}">
```

### URL format

The image helper function generates URLs in the following format:

#### Media model

* **Format**: /img/`imageId`/`options`/`name`.`format`

```ada
/img/dGVpG23oQz/300x0_fit/my-image.jpg
```

#### Static assets

You can also render static assets located inside your `public/images` directory by only passing the filename.

* **Format**: /img/`assetFile`/`options`/`name`.`format`

```ada
/img/logo.png/300x0_fit/logo.png
```

## Filters/Manipulations

You can manipulate an image on the fly by defining size and filter options in the URL section after the image identifier:

**Format:**

* /img/dGVpG23oQz/`width`**x**`height`/my-image.jpg
* /img/dGVpG23oQz/`width`**x**`height`**\_**`filters`/my-image.jpg

### Resize to fit (default)

Resizes the image to fit into 300 x 300 px and **keep the current aspect-ratio**.

```csharp
/300x300/
// or
/300x300_fit/
```

### Resize and fill

Resizes the image to fit into 300 x 300 px by **keeping the current aspect-ratio** and **filling** the rest of the image with a given color (Default is `white`).

```csharp
/300x300_fill/
```

#### Fill color

You can pass any RGB value with the fill filter to define a custom color (*red* in this example):

```csharp
/300x300_fill[255,0,0]/
```

### Crop

Resizes the image to 300 x 300 px and **crops from top/bottom or sides**.

```csharp
/300x300_crop/
```

#### Focus point

You can also set where the image is cropped by adding a crop position (Accepts `x,y` values (0-1) or keywords like `top-left`, `top`, `top-right`, `left`, `center`, `right`, `bottom-left`, `bottom` or `bottom-right`).

```csharp
/300x300_crop[top-left]/

// or

/300x300_crop[0.25,0]/
```

### Flip

Flip/mirror an image horizontally (default) or vertically (Accepts `h` or `v`).

```csharp
/300x300_flip/

// or

/300x300_flip[v]/
/300x300_flip[h]/
```

### Contrast

Change contrast with the contrast filter. You can pass a value to define the amount (between 0 - 2, where 1 would be no change in contrast):

```csharp
// more contrast (120%)
/300x300_contrast[1.2]/

// less contrast (50%)
/300x300_contrast[0.5]/
```

### Brightness

Change brightness with the brightness filter. You can pass a value to define the amount (between `0 - 2`, where 1 would be no change in brightness, values `> 1` would lighten the image, values `< 0` would darken the image):

```csharp
// lighten (120% brightness)
/300x300_brightness[1.2]/

// darken (75% brightness)
/300x300_brightness[0.75]/
```

### Blur

Blur an image. Accepts blur amount between `0 - 100` (default: `25`).

```csharp
// default blur (25)
/300x300_blur/

// specific blur amount
/300x300_blur[50]/
```

### Grayscale (Black/White)

Make an image black and white.

```csharp
/300x300_bw/
```

### Invert colors

Inverts the colors of an image.

```csharp
/300x300_invert/
```

### Pixelate

Pixelate an image. (Accepts size as a pixel value)

```csharp
/300x300_pixelate[25]/
```

### Copyright text

Places a copyright text in the botom left corner of an image. Accepts custom font size in pixel value (default: `22`).

```csharp
/300x300_copy/

// Customize font size
/300x300_copy[42]/
```

::: info
The copyright filter is best used as a **global filter** or **tag filter** to prevent someone to just remove the `copyright` part from the URL.
:::

## Combining Filters

You can combine **any** manipulation filters by separating them with `_`.

```csharp
// - Crop to 300x300 with position top-left
// - Make black and white
// - Adjust contrast to 1.2 (120%)
// - Blur image with amount 50
// - Invert the colors

/300x300_crop[top-left]_bw_contrast[1.2]_blur[50]_invert/
```

::: warning Important
Filters are applied in the order you define them in the URL.
:::

## Custom Filters

You can create custom image manipulators/filters by making a new class which implements the `Intervention\Image\Filters\FilterInterface` interface from [Intervention Image](http://image.intervention.io/use/filters).

```php
class MyCustomFilter implements FilterInterface
{
    public function __construct(
        protected ?int $width = null,
        protected ?int $height = null,
        protected ?array $options = null,
        protected ?Media $image = null,
    ) {
    }

    public function applyFilter(Image $image)
    {
        // do something with the $image object

        return $image;
    }
}
```

Your class will receive `width`, `height`, the `options` array and the `image` Media model as constructor arguments.

#### Registering Your Custom Filters

After creating your own custom image filter, you will need to register it in the `config/image.php` config file:

```php [config/image.php]
'filters' => [
    ...
    'my-custom-filter' => \App\ImageFilters\MyCustomFilter::class,
],
```

You can now use your custom filter with the key defined in the config array.

## Global Filters

Global filters will be applied on every image without specifiying it in the URL.

You can define some filters as global by adding their name to the `global-filters` array in the `config/image.php` config file.

**Example:** All images will receive the `fit` and `bw` (Grayscale) filters.

```php [config/image.php]
'global-filters' => [
    'fit', 'bw'
],
```

## Tag Filters

Tag filters will be applied on every image with a given tag value (without specifiying it in the URL).

You can define them as tag filters by adding an array of tag names to the `tag-filters` in the `config/image.php` config file.

**Example:**&#x20;
Images with the tag value of `fancy` will receive the filters defined in the array.

```php [config/image.php]
'tag-filters' => [
    'fancy' => [
        'bw', 'contrast[1.2]', 'brightness[0.8]'
    ],
]
```

## How a variant is served

Every URL the `image()` helper builds names one variant: an image, a size, filters and a format. The first time a variant is rendered it is written to `public/img/...` under exactly that URL path, so every later request is answered by the web server from disk and never reaches PHP. `public/img` is excluded from the deploy's rsync and carried from release to release by the hard-linked copy, so variants survive deploys. `php artisan evolve:clear-images` (or the image cache action in the panel) deletes them.

URLs with the front controller in the path, such as `/index.php/img/dGVpG23oQz/300x0_fit/my-image.jpg`, answer with a 301 to the same URL without `/index.php` . Before 2.12 they rendered without being cached and raised warnings.

## Queued conversions&#x20;

Rendering a variant means decoding the original with GD or Imagick, resizing, encoding and running `jpegoptim` or `pngcrush`. The starter's `<x-image>` component requests 17 variants per picture (eight srcset widths as WebP, the same eight in the image's own format, and the blurred placeholder). Before 2.12 all of them rendered inside visitors' requests the first time a page with a new image was opened, which made that page slow and put a burst of heavy PHP requests on the server.

Since 2.12 the work moves into the queue:

* **On upload**, and when an image is replaced, renamed, or gets a new focal point, the `GenerateImageConversions` job renders every variant listed in `image.conversions`.
* **A variant that does not exist yet** is answered with the original file (its real content type, `Cache-Control: public, max-age=60`), and a `GenerateImageVariant` job is queued for exactly that URL. The browser picks up the rendered variant on a later visit.
* **After a deploy**, `evolve:images:warm --missing` queues whatever is still missing.

### Lifecycle of a variant

1. An editor uploads `team.jpg`. The file is stored, then `GenerateImageConversions` is queued for the media item. The job is unique per image until it starts, so repeated saves queue it once.
2. A visitor opens the page before the worker ran. For `/img/dGVpG23oQz/1024x0/team.webp` the controller finds no file under `public/img`, serves `team.jpg` itself and queues `GenerateImageVariant` for that path. A cache lock (10 minutes) makes sure a page full of cold images queues each variant once, not once per hit.
3. The worker renders the variant and writes `public/img/dGVpG23oQz/1024x0/team.webp`.
4. From then on Apache serves the file directly.

Requests that are cheap or that the renderer cannot process are still handled in the request: size `0x0` (the original, copied) and file types the renderer does not support. A variant that fails to render, in the job or in the request, is reported to the error log (and Sentry) and the original is served. A visitor never gets a 500 for an image.

### The list of variants

`config/image.php` holds the list the component requests and the job pre-renders. The component reads the same keys, so both stay in line as long as the project uses the package's component.

```php [config/image.php]
'conversions' => [
    // srcset widths, rendered in `format` and in the image's own extension
    'srcset-widths' => [3840, 2560, 1920, 1440, 1280, 1024, 756, 512],

    // the <source> format, the component's `format` prop default
    'format' => 'webp',

    // the <img src> width for browsers without srcset support
    'fallback-width' => 1280,

    // the blurred placeholder behind a lazy image, [width, height]
    'placeholder-size' => [3, 3],

    // values of the component's `size` prop to pre-render; [0, 0] keeps the aspect ratio
    'sizes' => [[0, 0]],
],
```

| Key | Default | Effect |
|---|---|---|
| `image.conversions.srcset-widths` | `[3840, 2560, 1920, 1440, 1280, 1024, 756, 512]` | Widths of the srcset, rendered twice (in `format` and in the original's extension). |
| `image.conversions.format` | `webp` | Format of the `<source>` element. |
| `image.conversions.fallback-width` | `1280` | Width of the `<img src>`. |
| `image.conversions.placeholder-size` | `[3, 3]` | Size of the blurred placeholder. |
| `image.conversions.sizes` | `[[0, 0]]` | `size` prop values to pre-render. Each entry multiplies the srcset. |
| `QUEUE_CONNECTION` (Laravel) | `sync` in older projects | `sync` keeps rendering in the request. Anything else enables the queued path. |

The media name is part of the URL, and it is translatable. Paths are therefore built for every locale that has a name, and variants that differ only in the name are rendered once and copied.

Sizes not on the list still work. They are rendered by the per-variant job on their first request. To pre-render a size the theme uses a lot, add it:

```php [config/image.php]
'sizes' => [[0, 0], [800, 400]],   // plus the cropped card images
```

::: warning Projects with their own image component
A project that published `components/image.blade.php` and changed widths, format or `size` props keeps working, but the job pre-renders what `image.conversions` lists, not what the copy requests. Mirror the changes in `config/image.php`. Whatever is not listed renders on its first request through the per-variant job.
:::

### Queue driver

| `QUEUE_CONNECTION` | Upload | Cold variant | `evolve:images:warm` |
|---|---|---|---|
| `sync` | nothing is queued | rendered in the request, as before 2.12 | renders inline, can take long |
| `database`, `redis`, ... | variants queued | original served, variant queued | only enqueues work |

With the sync driver nothing changes compared to earlier releases, so switching is a per-site decision. On Onlime there is no daemon; the worker runs from cron, see [Deployment](/getting-started/deployment#cron-jobs):

```bash
* * * * * cd ~/webhome/public_html/www/current && php84 artisan queue:work --stop-when-empty --max-time=55 >> /dev/null 2>&1
```

The `jobs` table must exist for the database driver, and the cache store must support locks (file, database and redis do).

### Warming

```bash
php artisan evolve:images:warm             # every image, all variants re-rendered (forced)
php artisan evolve:images:warm --missing   # only images with missing variants, only those variants
```

The command prints a summary (`Queued 12 images with 57 missing variants.`). Add the `--missing` form to the deploy's after hooks; since `public/img` survives deploys it is normally a no-op. Run it once without `--missing` after changing `image.conversions`, or after `evolve:clear-images`.

```php [config/evolve.php]
'hooks' => [
    'after' => [
        'php84 artisan evolve:images:warm --missing',
    ],
],
```

### Monitoring

No separate check was added. The queue health check is enabled automatically as soon as the queue driver is not `sync` (or explicitly with `HEALTH_CHECK_QUEUE`, see [Settings Store](/panel/settings-store#the-settings-panel-in-2-12)). The scheduler dispatches its heartbeat job every minute, and that job waits behind the image jobs. A stopped worker cron or a growing backlog therefore fails the Queue check on `/evolve/health` and in Oh Dear.

### Pitfalls

* **Switching the queue without a worker.** Images keep working (originals are served), but pages get heavier and nothing is ever rendered. Other queued jobs stop too: Shopify imports, server side tracking events, image metadata. Add the cron line in the same step.
* **Originals are large.** Until the worker ran, visitors download the original upload. On a site with a backlog this costs bandwidth; `evolve:images:warm --missing` right after the deploy keeps the window short.
* **Failing images retry every 10 minutes at most.** An image that cannot be rendered (corrupt file, too large for the memory limit) keeps being served as the original. The error is in the log and in Sentry.
* **`ImageRepository` is gone.** `Racerfish\Evolve\Repositories\ImageRepository` and `Media::updateEdited()` were removed in 2.12. Use `Racerfish\Evolve\Services\ImageRenderer`.

### Verifying on a server

```bash
# 1. Is the queue in use, and is the worker running?
grep QUEUE_CONNECTION current/.env
crontab -l | grep queue:work

# 2. Upload an image in the panel, wait a minute, then look for its variants
ls current/public/img/<hash>/

# 3. Nothing left over after a deploy
php84 artisan evolve:images:warm --missing    # "Queued 0 images with 0 missing variants."
```

A cold variant answers with `Cache-Control: public, max-age=60` and the original's content type; the same URL a minute later is a static file served by Apache.
