Skip to content

[Image] Add the UX Image package - #3768

Open
smnandre wants to merge 1 commit into
symfony:3.xfrom
smnandre:sa/ux-image
Open

[Image] Add the UX Image package#3768
smnandre wants to merge 1 commit into
symfony:3.xfrom
smnandre:sa/ux-image

Conversation

@smnandre

@smnandre smnandre commented Aug 13, 2026

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? yes
Deprecations? no
Documentation? yes
Issues Fix #...
License MIT

So I had the intention to open the Upload PR first ... but #3765 kinda changed my plan :)

I don't think it's a question of "one or the other one", and let's see what feature, DX, etc both PR bring to the table, and what we can build from there.

I'll write a longer description tomorrow, but I didn't want to let too much time pass before opening this.

/!\ Review warning: this is a big one...


TL;DR;

UX Image turns one uploaded raster image into durable responsive-image metadata, then renders native <picture> and <img> markup without storage I/O or JavaScript.
Applications keep ownership of uploads and entities while the bundle provides one explicit processing, storage and rendering contract.

UX Image

Process the image once:

$asset = $processor->process(
    $uploadedFile,
    profile: 'product',
    storage: 'default_public',
);

$product->setImage($asset);

Render the persisted value anywhere:

{{ ux_picture(product.image, {
    alt: product.name,
    lazy: false,
    fetchpriority: 'high',
}) }}

The browser receives native responsive markup and selects the appropriate format, width and density. Rendering uses the persisted ImageAsset metadata only: it does not reopen the source image or contact storage.

Objectives

UX Image provides a Symfony-native image pipeline with a deliberately small application boundary:

  • process uploaded images through named, reviewable profiles instead of scattering transformation options through application code;
  • persist one immutable, versioned ImageAsset value that remains independent from entities and storage implementations;
  • render correct responsive HTML from metadata alone, with no runtime image processing and no frontend dependency;
  • make file validation, processing budgets, storage publication and regeneration explicit enough for production use.

Pipeline

flowchart LR
    A["UploadedFile"] --> B["Inspect real bytes"]
    B --> C["Named profile"]
    C --> D["Generate variants"]
    D --> E["Local or Flysystem storage"]
    E --> F["Immutable ImageAsset"]
    F --> G["Application persistence"]
    G --> H["ux_picture() or ux_image()"]
    H --> I["Native responsive HTML"]
Loading

Processing and rendering are separate operations. The expensive work happens when the application accepts the image; ordinary page rendering reads only persisted metadata.

Features

  • Named profiles: formats, dimensions, resize mode, quality, focal point, art direction, processing mode and revision are configured centrally;
  • Bounded processing: binary inspection, EXIF orientation, codec capability checks, input limits, output budgets and no silent format fallback;
  • Portable storage: local and Flysystem implementations, immutable generation paths, rollback of partial writes and explicit public URL adapters;
  • Persistable assets: immutable schema-versioned ImageAsset, JSON serialization contract and optional Doctrine DBAL type;
  • Responsive rendering: ux_picture(), ux_image() and an optional Twig Component produce srcset, sizes, intrinsic dimensions, media conditions and loading hints;
  • Operations: configuration validation, deterministic test fixtures and bounded regeneration through application-owned providers and persisters.

Out of Scope

  • file pickers, browser upload transport and form ownership;
  • application entities, authorization and database lifecycle;
  • a media library, image editor or content-management workflow;
  • provider-native transformation APIs or browser-to-cloud ingestion;
  • automatic cleanup of image generations that are still referenced by application data.

These boundaries are intentional. UX Upload, Symfony Forms or application code can provide the input; UX Image starts when the application has an authorized UploadedFile to process.

Requirements

Required Dependencies

  • PHP 8.4 or later;
  • Symfony 7.4 or 8.x;
  • TwigBundle for the rendering integration;
  • one processing backend: the PHP GD extension for the default driver, or a configured custom processor.

The package also uses Symfony Config, Console, DependencyInjection, Filesystem, HttpFoundation and HttpKernel, plus PSR-6 cache contracts.

Optional Dependencies

Dependency Enables
league/flysystem ^3.0 Remote or application-defined storage backends
doctrine/dbal ^4.0 The image_asset JSON persistence type
intervention/image ^3.0 Imagick, VIPS or a custom Intervention driver
intervention/image-driver-vips VIPS processing with its required system extensions
symfony/ux-twig-component ^3.0 The optional <twig:ux:image> component

Optional integrations are registered only when their concrete dependency is available. The core value objects, renderer contracts and local storage do not require them.

Usage

Define the outputs required by the layout:

# config/packages/ux_image.yaml
ux_image:
    profiles:
        product:
            directory: products
            formats: [webp, jpeg]
            sizes: '(min-width: 64rem) 50vw, 100vw'
            variants:
                small:  { width: 480, mode: fit }
                medium: { width: 960, mode: fit }
                large:  { width: 1440, mode: fit, quality: 88 }

Process the real uploaded file after application authorization:

use Symfony\UX\Image\Processor\ImageProcessorInterface;

final class ProductImageUpdater
{
    public function __construct(
        private ImageProcessorInterface $processor,
    ) {
    }

    public function update(Product $product, UploadedFile $file): void
    {
        $product->setImage($this->processor->process(
            $file,
            profile: 'product',
            storage: 'default_public',
        ));
    }
}

The application persists the returned ImageAsset with its owning model. It can use its own mapping or the optional Doctrine DBAL type.

Configuration

The default configuration works with local storage and GD. Applications add named profiles and storages as their layouts and deployment require:

ux_image:
    driver: gd
    storage_root: '%kernel.project_dir%/var/ux-image'
    preferred_formats: [avif, webp, jpeg]

    limits:
        max_input_bytes: 20M
        max_width: 12000
        max_height: 12000
        max_megapixels: 40
        max_variants: 20
        max_output_megapixels: 100

    storages:
        default_public:
            public_url_prefix: /uploads/images

Profiles select transformation behavior. Storage is selected at processing time and recorded in the resulting asset, so the same profile can publish to different storage backends.

Processing modes are explicit:

  • immediate writes the original and variants before returning;
  • deferred stores an asset that can be completed later;
  • async delegates dispatch to an application implementation of ImageProcessingDispatcherInterface.

The bundle does not invent an application message, owner identifier or persistence transaction for asynchronous work.

Rendering

ux_picture() performs format negotiation through <source> elements and keeps a JPEG, PNG or original fallback in <img>. ux_image() renders a single native <img> when format negotiation is unnecessary.

Both functions support:

  • intrinsic dimensions and aspect-ratio stability;
  • srcset, profile-level sizes and density descriptors;
  • art-directed sources with media conditions;
  • lazy loading, decoding and fetch priority;
  • safe application attributes without allowing overrides of renderer-owned attributes.

The optional <twig:ux:image> component delegates to the same renderer. There is one rendering contract, not a second component-specific implementation.

Storage and Persistence

ImageAsset stores paths, dimensions, MIME information, variants, profile name and profile revision. It stores no resolved public URL. This keeps persisted data portable when a CDN hostname or URL strategy changes.

Storage publication uses immutable generation keys. A failed processing run removes only the new objects it created; an existing published generation is never overwritten. The application makes the database update durable before deleting an older generation.

Rendering never checks storage existence. The persisted asset and its stored objects therefore form an application consistency boundary.

Security

Images are untrusted binary input. Security is applied before processing, while writing outputs and when generating public markup.

Input Files

  • the binary signature is inspected instead of trusting the filename or browser MIME type;
  • non-images are rejected before a storage directory or object is created;
  • SVG is rejected by default and can only enter through an explicit application sanitizer or rasterizer policy;
  • EXIF orientation is normalized before transformation;
  • input bytes, dimensions and decoded megapixels are bounded.

Processing and Storage

  • profile variant count and total output pixels are validated before work begins;
  • requested codecs must be supported by the effective driver and never silently fall back to another format;
  • storage paths reject absolute paths, backslashes, NUL bytes and traversal segments;
  • partial writes are rolled back in reverse order and streams are closed on failure;
  • random immutable generation names prevent application filenames from becoming storage paths.

Authorization and Delivery

The application authorizes the owner and tenant before calling process(). It must never accept an ImageAsset JSON document or storage path directly from a client.

Public URL prefixes and CDN builders provide addressing, not access control. Private originals require private storage and an application-owned signed URL adapter or controller.

Regeneration

Regeneration starts from application persistence, never from a storage scan. Applications implement:

  • ImageAssetProviderInterface to expose bounded, stably ordered batches;
  • ImageAssetPersisterInterface to publish each replacement with application-level compare-and-swap semantics.
php bin/console ux:image:regenerate product \
    --storage=default_public \
    --batch-size=100

The command supports dry runs, current-revision skipping, forced regeneration and resumable opaque cursors. The provider and persister keep entity knowledge and transactions where they belong: in the application.

Tests

  • PHP: 504 tests and 1,425 assertions;
  • Bundle integration: container configuration, optional dependency isolation, Twig registration and initialization failures;
  • Processing: GD and Intervention drivers, binary inspection, geometry, focal points, capabilities, limits and rollback behavior;
  • Rendering: responsive sources, art direction, dimensions, safe attributes and deterministic HTML contracts;
  • Storage: shared local and Flysystem behavior, path confinement, stream ownership, publication and cleanup;
  • Operations: validation and regeneration commands, provider contracts, persistence conflicts and failure recovery.

Documentation

The documentation follows the image lifecycle from profile design to production operation:

Page Focus
overview.md Mental model, first decisions and package boundaries
installation.md Installation, bundle registration and first configuration
processing.md Profiles, formats, geometry, focal points and processing modes
image-asset.md Persisted metadata, schema and Doctrine integration
rendering.md Twig functions, responsive HTML and performance options
storage.md Local storage, Flysystem, URL adapters and publication
regeneration.md Bounded providers, persisters, cursors and replacement
integrations.md Symfony Forms, UX Upload, Twig Component and application services
security.md Untrusted inputs, budgets, authorization and delivery
configuration.md Complete configuration reference
testing.md Fixtures and application-level testing strategies
debugging.md Diagnostics and common deployment failures
architecture.md Internal boundaries and extension contracts

The Markdown pages will be converted to reStructuredText before the documentation is submitted for publication.

Coming Next

Separate pull requests can provide:

  • the official Symfony Flex recipe;
  • the Symfony UX website page and live examples;
  • focused examples combining UX Upload and UX Image;
  • additional provider-specific URL or storage adapters when their contracts are broadly reusable.

@carsonbot carsonbot added Documentation Improvements or additions to documentation Feature New Feature Status: Needs Review Needs to be reviewed labels Aug 13, 2026

@Kocal Kocal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by checking the branch out locally: ran the full suite (504 tests / 1425 assertions pass on PHP 8.4 with gd + exif + imagick), ran php-cs-fixer and twig-cs-fixer, and probed the processing pipeline with throwaway scripts to confirm the behavioural findings rather than guess at them.

The design holds up well. The metadata-only rendering boundary, the immutable generation keys, StoragePath confinement and the bounded-provider regeneration contract are all genuinely well thought out, and the scope section says no to the right things. Most of what follows is about the GD encoding path and render-time attribute handling.

Detailed notes are inline on the diff. The three I would consider blocking:

  • Transparent PNG produces JPEG variants with a black background. Verified: the corner pixel of a generated variant is #000000. Nothing in the suite covers alpha, so it stays invisible until production.
  • class, alt and sizes are not in the reserved-attribute list, so passing them through attributes emits duplicate attributes and the caller's value is silently dropped.
  • htmlspecialchars($v, \ENT_QUOTES) drops the default ENT_SUBSTITUTE, so any invalid UTF-8 blanks the whole attribute. Verified: alt: "Caf\xE9 crème" renders alt="".

The lossy encoding intermediate, the Twig runtime wiring, the dead DBAL 3 methods, the unreachable non-stream storage path and the dropped TwigComponent attributes bag are API and behaviour decisions that get expensive to change once the package ships, so they are worth settling now rather than after. Everything else is polish.

Really nice work overall. The architecture survives a close read, the security posture is substantive rather than decorative, and the test suite is serious. Its one blind spot is alpha and transparency, which is exactly where the top issue lives.

@carsonbot carsonbot added Status: Needs Work Additional work is needed and removed Status: Needs Review Needs to be reviewed labels Aug 19, 2026

@Kocal Kocal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line-by-line notes, following up on the summary review above.

'webp' => imagewebp($image, $outputPath, $quality),
'avif' => \function_exists('imageavif') ? imageavif($image, $outputPath, $quality) : throw ImageProcessingException::unsupportedFormat('avif'),
'png' => imagepng($image, $outputPath, (int) (9 - ($quality * 9 / 100))),
'jpeg', 'jpg' => imagejpeg($image, $outputPath, $quality),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: transparent PNG produces JPEG variants with a black background.

resize() fills the canvas with imagecolorallocatealpha($dst, 0, 0, 0, 127) (L312-317), then this line hands that image to imagejpeg(). JPEG has no alpha channel, so GD flattens the transparency onto black rather than white.

I ran a transparent PNG through a jpeg-only profile and read back the generated variant: the corner pixel is #000000.

A transparent logo comes out with a black background in every JPEG variant, and fill mode letterboxes in black too. Nothing in the suite covers alpha, so this stays invisible until someone looks at a rendered page. Most libraries flatten onto white; whichever colour you pick it should be a deliberate one, and ideally configurable.

match ($type) {
\IMAGETYPE_PNG => imagepng($image, $path),
\IMAGETYPE_WEBP => imagewebp($image, $path),
default => imagejpeg($image, $path, 90),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saveImage() picks the encoder from the source image type, which makes the shared resized file a lossy intermediate whenever the upload is a JPEG: every variant goes through an extra JPEG q90 generation before the real encode runs.

Measured on a synthetic high-frequency source: mean absolute deviation of 2.64/255 per channel versus encoding straight from the resampled pixels. A WebP or AVIF variant of a JPEG upload can never be better than a recompressed q90 JPEG.

A lossless intermediate (PNG) fixes the quality side. Better still, see the note on L195: the disk round trip is avoidable entirely.


try {
foreach ($plan->variants as $plannedVariant) {
$resizedPath = $workspace->path(\sprintf('resized-%d.%s', ++$index, pathinfo($originalPath, \PATHINFO_EXTENSION) ?: 'jpeg'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intermediate inherits the source extension, and resize() then encodes it with the source's codec (see L382-389).

The round trip through disk also buys nothing here: $encodingImage on L208 immediately decodes the file back into memory to loop over $plan->formats. Having resize() expose a variant that returns the GdImage would remove one encode and one decode per variant, and would make the lossy-intermediate problem disappear along the way.

throw new \Symfony\UX\Image\Exception\InvalidArgumentException(\sprintf('Unsafe image attribute name "%s".', $name));
}
if (\in_array(strtolower($name), ['src', 'srcset', 'width', 'height', 'loading', 'fetchpriority', 'decoding'], true)) {
throw new \Symfony\UX\Image\Exception\InvalidArgumentException(\sprintf('Image attribute "%s" is managed by ImageRenderOptions.', $name));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: class, alt and sizes are renderer-owned but not reserved here.

The list covers src, srcset, width, height, loading, fetchpriority and decoding, but the renderer also emits class, alt and sizes itself. Passing any of those through attributes produces duplicate attributes:

<img src="/up/a_sm.jpg" alt="x" loading="lazy" ... class="rounded" class="shadow" id="hero" />
<img src="/up/a_sm.jpg" alt="x" ... sizes="100vw" ... alt="other" sizes="50vw" />

That is invalid HTML, and the browser keeps the first occurrence, so the caller's value is silently dropped. The PR description promises "safe application attributes without allowing overrides of renderer-owned attributes", which currently holds for 7 attributes out of 10.

public function toHtml(): string
{
$sizes = null !== $this->options->sizes && '' !== $this->options->sizes
? \sprintf(' sizes="%s"', htmlspecialchars($this->options->sizes, \ENT_QUOTES))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: passing \ENT_QUOTES explicitly drops ENT_SUBSTITUTE.

Since PHP 8.1 the default flags are ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401. Passing ENT_QUOTES alone removes the substitution, and htmlspecialchars() then returns an empty string on invalid UTF-8.

Verified: rendering with alt: "Caf\xE9 crème" emits alt="". Not an injection, but the accessibility text disappears with no error and no log, and mis-encoded strings are common when the value comes from a legacy import or a latin1 column.

\ENT_QUOTES | \ENT_SUBSTITUTE restores the intended behaviour. This applies to every htmlspecialchars() call in the file.

sizes: $this->sizes,
alt: $this->alt ?? '',
lazy: $this->lazy,
fetchPriority: $this->fetchpriority ?? ($this->lazy ? 'auto' : 'high'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: two entry points, two defaults. The component sets fetchpriority: high when lazy: false, while ux_image() / ux_picture() stay on auto in the same situation (ImageRuntime::renderConfigured()).

The implicit behaviour is defensible, it just should not depend on which API the user picked.


$errors = [];
foreach (array_unique(['default_public', ...array_keys($this->storages)]) as $storageName) {
$path = new StoragePath('.ux-image-validation/'.bin2hex(random_bytes(12)).'.probe');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: worth documenting that ux:image:validate writes real objects into every configured storage, including a production bucket, then deletes them.

The .ux-image-validation/ directory is also left behind after the probe file is removed.

Comment thread src/Image/doc/index.rst
The documentation is organized as a navigable, task-oriented corpus instead of
one monolithic page:

* `Installation and quick start <installation.md>`_

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These use RST external-link syntax pointing at .md files, and .symfony.bundle.yaml declares doc_dir: doc, so symfony.com would build an index of dead links.

You already flagged the Markdown to RST conversion as pending, just worth gating the docs publication on it.

return null;
}

foreach (['avif', 'webp', 'jpeg', 'jpg', 'png'] as $preferred) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: this hardcodes [avif, webp, jpeg, jpg, png] and ignores the configurable ux_image.preferred_formats (and the per-profile override). An application that changes the preference order does not affect this method, which DefaultImageRenderer::resolveDimensions() relies on.

...$attributes,
\sprintf('alt="%s"', htmlspecialchars($this->options->alt, \ENT_QUOTES)),
\sprintf('loading="%s"', $this->loadingAttribute()),
\sprintf('fetchpriority="%s"', $this->fetchPriorityAttribute()),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: fetchpriority="auto" and decoding="async" are emitted on every image even at their default values. Both are the browser default, so it is bytes on every page for no behavioural change.

if (null !== $position && '' === trim($position)) {
throw new Exception\InvalidArgumentException('Image variant position must not be empty.');
}
if (null !== $density && 1 !== preg_match('/^(?:[1-9]\d*(?:\.\d+)?x)$/', $density)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The density regex [1-9]\d*... rejects values below 1 like 0.5x, which are valid per the HTML spec (§ 4.8.4.2.1: "a valid floating-point number giving a number greater than zero").

Comment on lines +48 to +56
return $this->render($asset, new ImageRenderOptions(
sizes: \is_string($sizes) ? $sizes : null,
alt: \is_string($alt) ? $alt : '',
lazy: \is_bool($lazy) ? $lazy : true,
fetchPriority: \is_string($fetchPriority) ? $fetchPriority : 'auto',
class: \is_string($class) ? $class : '',
decoding: \is_string($decoding) ? $decoding : 'async',
variant: \is_string($variant) ? $variant : null,
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderConfigured() extracts 7 of the 9 ImageRenderOptions parameters but doesn't forward srcset or attributes. The Twig functions ux_image() / ux_picture() silently ignore those options with no error, while the Twig component <twig:ux:image> does support srcset. Either forward them or document the limitation explicitly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation Feature New Feature Status: Needs Work Additional work is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants