# Image

> An uploaded image: the file goes on disk under dirBase, the filename goes in the column, and any number of derived versions — thumbnails, web-optimised copie…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/property-types/image. Last updated 2026-07-27.

---
An uploaded image: the file goes on disk under `dirBase`, the **filename** goes in the column, and
any number of derived versions — thumbnails, web-optimised copies, square crops — are generated
alongside it at upload time.

**Storage kind:** `column` · **Column:** `varchar(255)`

Use it for anything displayed as a picture that benefits from resizing: product images, answer
images, question images, the shop logo. For a downloadable asset that is never resized — a PDF
datasheet, a CAD file — use [`file`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/file), which is the same machinery without the image
processing.

## Settings

| Setting | Meaning |
|---|---|
| `dirBase` | **Required.** Filesystem directory the file is written to. Created (recursively, 0777) if missing |
| `urlBase` | URL base the file is served from. Without it, reads expose no `_href` |
| `filename` | Fixed base filename, instead of the uploaded one |
| `appendSerial` | Append a random 4-digit serial to the base name — a cache-buster, see below |
| `allowedExtensions` | Array of permitted extensions, lower-case, without the dot |
| `allowedMimeTypes` | Array of permitted MIME types — checked as well as the extension |
| `maxFileSizeKb` | Upload ceiling in KB. Was `size`; a shim still routes the old key |
| `minimumDimensions` | `array('width' => …, 'height' => …)`. Either key may be omitted |
| `mutations` | Derived versions to generate — see [Mutations](#mutations) |
| `options` | `FILENAME_TO_RECORD_ID`, `NODELETEFILE`, `NO_SAVE_FILENAME` — see below |

A representative definition, from `adminproducts`:

```php
$propDefs['image'] = array(
    'name'              => 'image',
    'label'             => KText::_('Product Image'),
    'type'              => 'image',
    'appendSerial'      => 1,
    'allowedExtensions' => array('jpg', 'jpeg', 'gif', 'tif', 'bmp', 'png'),
    'allowedMimeTypes'  => array('image/jpeg', 'image/gif', 'image/png', /* … */),
    'maxFileSizeKb'     => '2000',
    'dirBase'           => KenedoPlatform::p()->getDirDataStore().'/public/product_images',
    'urlBase'           => KenedoPlatform::p()->getUrlDataStore().'/public/product_images',
    'options'           => 'FILENAME_TO_RECORD_ID',
    'positionForm'      => 90000,
);
```

## The filename is persisted by default

`store()` calls `updateOriginalFileName()` unless the definition opts out with `NO_SAVE_FILENAME`.

This used to be opt-in, behind a `SAVE_FILENAME` tag, and getting it wrong failed silently: the file
was still uploaded, validated and mutated, but the column was never updated — and since
`prepareForStorage()` has already written the *previous* filename into the data object, the base row
kept pointing at the old file, or at nothing. No error, nothing in a log, just a form that comes back
empty. Every shipped image definition set the tag, which is the clearest evidence it was not a real
choice.

`NO_SAVE_FILENAME` remains for the genuine case the old flag was invented for: a pure upload widget
whose file location is derived from the record id, where storing the name is redundant. Note that
even the shipped `FILENAME_TO_RECORD_ID` definitions persisted the name anyway.

## How the filename is built

In `getNewOriginalFilename()`, the base name is the first of these that applies:

1. `FILENAME_TO_RECORD_ID` in `options` → the record id.
2. `filename` in the definition → that literal string.
3. Otherwise → the uploaded name, lower-cased, with its extension stripped.

Then, if `appendSerial` is set, `-` plus `str_pad(rand(1,1000), 4, 0)` is appended. Finally the
uploaded file's extension is put back. The extension is **always** the uploaded one — no setting
changes it. (`PRESERVE_EXT`, which nine shipped definitions used to carry, was read by nothing and
has been removed.)

`appendSerial` exists to defeat browser and CDN caches: when the operator replaces a product image
the new file has a different name, so nothing serves the stale one. The old file is unlinked once
the new one is in place. Note the serial is **random, not sequential**, and `str_pad` pads on the
right — `rand()` returning `7` gives `7000`, not `0007`. It is a cache-buster, not an ordering and
not a collision-proof unique suffix.

## Validation

`check()` collects **every** problem with one file and throws them together, rather than reporting
the first and making the operator upload again to learn about the next:

| Checked | Condition |
|---|---|
| Presence | Only when `required` and `appliesWhen` says the field applies, and only if there is no current file either |
| `maxFileSizeKb` | `filesize()` of the temp file against the ceiling |
| `allowedMimeTypes` | `KenedoFileHelper::getMimeType()`. **Skipped silently if the system cannot determine a MIME type** |
| `allowedExtensions` | The uploaded name's extension |
| `minimumDimensions` | Only if everything above passed — no point measuring a rejected non-image |

Declare **both** `allowedExtensions` and `allowedMimeTypes`. An extension is a claim by the client;
the MIME check looks at what was actually sent. Neither is sufficient alone, and the MIME check is
the one that can silently not run.

A file whose dimensions `getimagesize()` cannot read is **refused**, not measured against nothing.
That is deliberate: the code previously set a message and then compared against `null` anyway, so an
unreadable file passed the size checks it should have failed.

## Mutations

A mutation is a derived image generated from the upload — a thumbnail, a web-optimised copy, a
square crop. They are declared as a map of name → recipe:

```php
'mutations' => array(
    'thumb' => array(
        'mode'   => 'coverAndCrop',
        'params' => array('width' => 200, 'height' => 200),
    ),
    'web' => array(
        'mode'   => 'contain',
        'params' => array('width' => 1200, 'outputFormat' => 'webp', 'outputQuality' => 80),
    ),
),
```

The key (`thumb`, `web`) names the mutation and appears both in the generated filename and in the
record keys — pick something stable and URL-safe.

No shipped property definition declares mutations; the feature is there for products and
customizations that need sized variants.

### Modes

| `mode` | What it does | Aspect ratio | Params used |
|---|---|---|---|
| `coverAndCrop` | Fills the target box exactly, cropping the overflow, anchored **center** | preserved, excess cut | `width`, `height` (both required) |
| `contain` | Scales to fit inside the target box; one dimension ends up shorter | preserved, nothing cut | `width` and/or `height` |
| `cover` | Scales to fill the target box; one dimension ends up longer, nothing is cropped | preserved, nothing cut | `width` and/or `height` |

`forceRatioAndContain` is a deprecated alias for `coverAndCrop` and is still accepted. Any other
`mode` **throws** at upload time, naming the model and property — a typo here fails the save rather
than silently skipping the mutation.

Use `coverAndCrop` for anything that must tile in a fixed grid (product-list thumbnails); `contain`
when the whole image must stay visible and the container can absorb the leftover space.

> **Known defect:** `cover` currently does not scale at all. `getImageCoveredWithoutCrop()` reads a
> variable that is not in scope and so overwrites both target dimensions with `null`, returning the
> image untouched. Use `contain` or `coverAndCrop` until that is fixed.

### Output format and quality

| Param | Meaning |
|---|---|
| `outputFormat` | `webp` \| `jpg` \| `png` \| `gif`. Omitted means **keep the original's extension** |
| `outputQuality` | 1–100, default **70**. Passed to `toWebp()` and `toJpeg()` only — the `png` and `gif` conversions are called without it |

Every mutation is converted to the sRGB colourspace and has its colour profile removed before
saving, so a CMYK or wide-gamut upload does not produce browser-dependent colours in its derivatives.

Processing runs on **Intervention Image**, using the **Imagick** driver when that extension is
loaded and falling back to **GD** otherwise. Output can differ slightly between the two — a property
of the host, not of your definition.

### Mutation filenames

```
<base>-<mutationName>[-<serial>].<ext>
```

`<base>` and `<serial>` come from the original, split apart on the last `-` when `appendSerial` is
on; `<ext>` is `outputFormat` or the original's extension. So `chair-0420.jpg` with a `thumb`
mutation becomes `chair-thumb-0420.jpg`, and with `'outputFormat' => 'webp'`, `chair-thumb-0420.webp`.

When reading, the property first looks for the expected extension and, failing that, probes `jpg`,
`gif`, `png`, `webp` in turn. That is what lets you change a mutation's `outputFormat` without
orphaning the files already generated under the old one.

### Lifecycle

| Event | What happens to the mutations |
|---|---|
| Upload | Generated immediately, in `store()`, one per entry in `mutations` |
| Replace | The new upload's mutations are generated; the previous original **and** its mutations are unlinked |
| Delete checkbox | Original and mutations unlinked; the column is cleared |
| Record delete | Original and mutations unlinked before the row goes |
| Record copy | Original and every **existing** mutation are copied under the new name |
| Adding a mutation to the definition | **Nothing.** Existing records keep the mutations they were uploaded with |

That last row is the one that bites. Mutations are generated at upload, never on demand, so adding
one to a live definition produces it for new uploads only. There is a `recreateMutations()` method
that walks the model's records and regenerates, but the source marks it *experimental — not to be
used yet*; do not treat it as the supported backfill path. Re-saving the affected records with a
fresh upload is what actually works today.

## What reads produce

For a property named `image` with a `thumb` mutation, `appendDataForGetRecord()` adds:

| Key | Holds | Present when |
|---|---|---|
| `image` | the filename | always |
| `image_href` | `urlBase` + `/` + filename | `urlBase` is set and the filename is non-empty |
| `image_path` | `dirBase` + `/` + filename | `dirBase` is set and the filename is non-empty |
| `image_thumb_href` | the mutation's URL | the mutation file **exists on disk** |
| `image_thumb_path` | the mutation's filesystem path | the mutation file **exists on disk** |

The mutation keys are driven by what is on disk, not by what the definition declares — so a record
uploaded before a mutation was added simply has no key for it. Consumers must treat them as
optional; there is no placeholder.

That underscore is deliberate and always was: `image` and `file` were already following the
convention that `translatable` was not.

## The delete checkbox

The template renders a "delete file at save" checkbox only when **all** of these hold: a file is
stored, `NODELETEFILE` is not in `options`, the property is not `required`, and the record id is not
`0`. It posts `<propertyName>-delete=1`, which `store()` reads straight from the request.

If that flag arrives when the property is not deletable, `store()` **throws** rather than ignoring
it — a delete arriving for a required field means something is wrong with the form, not with the
operator's intent.

## Notes

- The upload is `copy()`d from the temp file, not `rename()`d the way [`file`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/file) does it.
  Permissions on the destination are set to `0775`.
- `storeExternally` moves the **filename column** to another table; the property implements the
  branch itself in `getCurrentOriginalFileName()` and `updateOriginalFileName()`. The files
  themselves always live under `dirBase`. See the overview's `storeExternally` section.
- Copying a record whose source file is **missing on disk throws**, aborting the copy. `file` only
  logs a warning and carries on. If you copy records whose images may have been removed outside the
  admin, that difference matters.
- Export/import (`getPropertyFilePaths()`) carries the **original only**. Mutations are regenerated
  on import rather than transferred.

@see file.md
@see ../com_configbox_property_types.md §3 for `storeExternally`
@see ../../admin-guide/products/add-a-product.md — the product image from the operator's side
