Images & Media
When a PHP component uses #[Silverback\Uploadable], use useCwaComponent with the withFile() plugin. It exposes the uploaded file's URL, load state, and Imagine filter variants under a files map keyed by the field's fileProp (default 'file'). Map entries are reactive, so in templates you use them without .value (files.file.contentUrl).
Display Component
<!-- app/app/cwa/components/Image/Image.vue -->
<template>
<div class="w-full relative my-5">
<div
v-if="files.file?.displayMedia"
class="relative flex overflow-hidden max-w-full w-fit"
>
<img
ref="file"
:src="files.file.contentUrl"
:srcset="files.file.srcset"
:sizes="sizes"
:width="files.file.displayMedia.width"
:height="files.file.displayMedia.height"
class="max-w-full h-auto"
:style="{ 'aspect-ratio': `${files.file.displayMedia.width} / ${files.file.displayMedia.height}` }"
@load="files.file.handleLoad"
>
<div
class="absolute inset-0 bg-gray-200 pointer-events-none transition-opacity"
:class="{ 'opacity-0': files.file.loaded }"
/>
</div>
<div
v-else
class="w-40 h-40 bg-gray-200 flex items-center justify-center text-gray-500 font-bold"
>
No Image
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaComponent, withFile } from '#imports'
const props = defineProps<IriProp>()
const imageRef = useTemplateRef<unknown>('file')
const { exposeMeta, files } = useCwaComponent(props, [
withFile({ imagineFilterName: 'general', imageRef }) ])
// Shown at the general filter's width, or the viewport's when narrower
const sizes = computed(() => { const width = files.file?.displayMedia?.width return width ? `(max-width: ${width}px) 100vw, ${width}px` : undefined})
defineExpose(exposeMeta)
</script>
Two stages are at work here. displayMedia tells you the media exists in the resource data, so the outer branch swaps the "No Image" box for the real <img>. loaded then tells you the browser has painted it, fading the grey placeholder out over the top. The wrapper is w-fit, so the placeholder covers the image and no more, and the aspect-ratio style reserves the image's space before it loads.
imageRef makes loaded reliable for an image the browser finished loading before the page hydrated, such as one in its cache: its load event fired before Vue attached @load, so without imageRef the placeholder would stay over it. Pass it whenever you rely on loaded, and omit it for non-image fields. The template's Image component passes it. Why imageRef exists explains it, and why the ref is typed <unknown>.
Responsive Images
files.file.srcset lists every sized variant of the field: the original and each Imagine filter, smallest first. The browser picks one from it using the sizes you give the <img>. The example above follows the template's Image component:
withFile({ imagineFilterName: 'general' })makes thegeneralfilter thesrc, the fallback for browsers withoutsrcsetsupport.sizessays the image is shown at that filter's width, or the viewport's width when the viewport is narrower. Write your own if your layout differs.- It's a plain
<img>, not<NuxtImg>, because<NuxtImg>readssizesas its own format and builds its ownsrcset.
srcset is undefined for an SVG, or when the field has fewer than two variants with a known width, and Vue then leaves the attribute out. The full rules are under useCwaFileField › srcset.
Add a Filter for High-Density Screens
The original upload is in the srcset too. On a high-density screen, the browser wants about twice the display width. If no filter is that wide, it downloads the original, which can be many times larger. The template adds a large filter at twice general's size for this:
# api/config/packages/liip_imagine.yaml
liip_imagine:
filter_sets:
general:
format: webp
quality: 80
filters:
thumbnail:
size: [ 1200, 1200 ]
mode: inset
large: format: webp quality: 80 filters: thumbnail: size: [ 2400, 2400 ] mode: insetThen list it on the field, so the API generates it for each upload:
#[Silverback\UploadableField(adapter: 'gcloud', urlGenerator: 'public', imagineFilters: ['thumbnail', 'general', 'large'])]
A filter never upscales, so an image smaller than 2400 px gets a large variant the same width as the original. srcset then keeps whichever of the two files is smaller. See Uploadable for imagineFilters. The template's filters all save WebP, whatever the upload's format: see WebP and Other Output Formats.
large variant rather than the original uploadThe files Map
Each entry — files[fileProp] — carries these values for that field:
| Value | Type | Description |
|---|---|---|
contentUrl | string | undefined | Public URL of the (optionally filtered) file |
displayMedia | MediaFile | undefined | The media object to display — has contentUrl, mimeType, width, height; undefined until available |
srcset | string | undefined | Every sized variant, smallest first, for the <img>'s srcset. See Responsive images |
loaded | boolean | Becomes true when handleLoad() is called |
handleLoad | () => void | Call on the <img> element's @load event |
Because entries are reactive, use them directly in templates (files.file.contentUrl) — no .value. Gate rendering on files.file?.displayMedia so the skeleton shows until the media is resolved and the browser has loaded the image.
Using a Specific Imagine Filter
Pass imagineFilterName to surface a specific image variant as displayMedia/contentUrl:
const { files } = useCwaComponent(props, [
withFile({ imagineFilterName: 'thumbnail' })
])
// files.file.contentUrl → the 'thumbnail' variant
Without imagineFilterName, the field resolves to the original uploaded file.
Multiple File Fields
A component can hold more than one uploadable field (e.g. a poster and a thumbnail). Add one withFile() per field, each with its own fileProp — useCwaComponent accumulates them into the same files map:
const { files } = useCwaComponent(props, [
withFile({ fileProp: 'poster' }),
withFile({ fileProp: 'thumbnail', imagineFilterName: 'small' }),
])
// files.poster.contentUrl · files.thumbnail.contentUrl
Each fileProp must match a distinct #[UploadableField] property on the PHP entity. For the standalone, non-plugin form, see useCwaFileField.
Admin Upload Tab
useCwaResourceUpload returns a bind object — destructure it, spread it onto CwaUiFormFile with v-bind, and only supply the label (and optional accept) per field:
<!-- app/app/cwa/components/Image/admin/Image.vue -->
<template>
<div class="p-4">
<CwaUiFormFile v-bind="bind" label="Upload Image" accept="image/*" />
</div>
</template>
<script setup lang="ts">
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaResourceManagerTab, useCwaResourceUpload } from '#imports'
defineProps<IriProp>()
const { exposeMeta, iri } = useCwaResourceManagerTab({ name: 'Image', order: 1 })
const { bind } = useCwaResourceUpload(iri, 'file') // 'file' = the PHP property name
defineExpose(exposeMeta)
</script>
useCwaResourceUpload(iri, propertyName) handles file selection, multipart upload to {iri}/upload, and deletion via PATCH. bind is a ComputedRef, and templates only auto-unwrap top-level refs, so v-bind="upload.bind" on a non-destructured result would not unwrap. For multiple file fields, call it once per property and rename as you destructure (const { bind: posterBind } = …). See useCwaResourceUpload for the full reference.
Large JPEG, PNG and WebP images are resized in the browser before they are sent: by default, anything over 2560 px on its longest edge or over 20 megapixels. The resized file has no EXIF metadata, so phone photos lose their GPS location. To keep originals for one field, pass { imageDownscale: { enabled: false } } as the fourth argument. See Large images are resized before upload.
Video, PDF, and Other Files
The same pattern works for any file type — withFile() is not image-specific. For non-image files there's no @load event to wait for, so render as soon as files.file?.contentUrl is available:
<template>
<a v-if="files.file?.contentUrl" :href="files.file.contentUrl" download>
Download PDF
</a>
</template>
<script setup lang="ts">
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaComponent, withFile } from '#imports'
const props = defineProps<IriProp>()
const { exposeMeta, files } = useCwaComponent(props, [withFile()])
defineExpose(exposeMeta)
</script>
For video:
<video v-if="files.file?.contentUrl" :src="files.file.contentUrl" controls class="w-full" />
CLI Generator
Scaffold a new CWA display component from the command line — generates the Vue file with the correct useCwaComponent boilerplate and prints the matching API command.
Collections & Pagination
Render paginated resource lists with useCwaComponent and the withCollection() plugin, with page navigation bound to the URL query string.