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
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 Review Needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants