Skip to main content
Version: 4.0 preview

Image

Version
4.0 preview
Updated
View markdown

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, which is the same machinery without the image processing.

Settings

SettingMeaning
dirBaseRequired. Filesystem directory the file is written to. Created (recursively, 0777) if missing
urlBaseURL base the file is served from. Without it, reads expose no _href
filenameFixed base filename, instead of the uploaded one
appendSerialAppend a random 4-digit serial to the base name — a cache-buster, see below
allowedExtensionsArray of permitted extensions, lower-case, without the dot
allowedMimeTypesArray of permitted MIME types — checked as well as the extension
maxFileSizeKbUpload ceiling in KB. Was size; a shim still routes the old key
minimumDimensionsarray('width' => …, 'height' => …). Either key may be omitted
mutationsDerived versions to generate — see Mutations
optionsFILENAME_TO_RECORD_ID, NODELETEFILE, NO_SAVE_FILENAME — see below

A representative definition, from adminproducts:

$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:

CheckedCondition
PresenceOnly when required and appliesWhen says the field applies, and only if there is no current file either
maxFileSizeKbfilesize() of the temp file against the ceiling
allowedMimeTypesKenedoFileHelper::getMimeType(). Skipped silently if the system cannot determine a MIME type
allowedExtensionsThe uploaded name's extension
minimumDimensionsOnly 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:

'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

modeWhat it doesAspect ratioParams used
coverAndCropFills the target box exactly, cropping the overflow, anchored centerpreserved, excess cutwidth, height (both required)
containScales to fit inside the target box; one dimension ends up shorterpreserved, nothing cutwidth and/or height
coverScales to fill the target box; one dimension ends up longer, nothing is croppedpreserved, nothing cutwidth 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

ParamMeaning
outputFormatwebp | jpg | png | gif. Omitted means keep the original's extension
outputQuality1–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

EventWhat happens to the mutations
UploadGenerated immediately, in store(), one per entry in mutations
ReplaceThe new upload's mutations are generated; the previous original and its mutations are unlinked
Delete checkboxOriginal and mutations unlinked; the column is cleared
Record deleteOriginal and mutations unlinked before the row goes
Record copyOriginal and every existing mutation are copied under the new name
Adding a mutation to the definitionNothing. 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:

KeyHoldsPresent when
imagethe filenamealways
image_hrefurlBase + / + filenameurlBase is set and the filename is non-empty
image_pathdirBase + / + filenamedirBase is set and the filename is non-empty
image_thumb_hrefthe mutation's URLthe mutation file exists on disk
image_thumb_paththe mutation's filesystem paththe 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 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