Skip to content

[Upload] Add the UX Upload package - #3769

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

[Upload] Add the UX Upload package#3769
smnandre wants to merge 1 commit into
symfony:3.xfrom
smnandre:sa/ux-upload

Conversation

@smnandre

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

TL;DR;

From a small attachment to a large interrupted transfer, UX Upload keeps one Symfony Form contract: files upload before submit and survive validation errors without being selected or sent again.
Small files use one request, large files can resume, and Symfony receives a signed, lazy CompletedUpload while the application owns permanent storage.

UX Upload

Add one field and render it like any other Symfony Form field:

$builder->add('attachment', FileUploadType::class);
{{ form_row(form.attachment) }}

The simple path stays simple. The same field automatically adds progress, validation persistence and resumable transfers when they are needed, without application JavaScript or a custom upload controller.

Objectives

UX Upload provides a modern upload foundation that feels native to Symfony and Symfony UX:

  • integrate with Forms, validation, Twig, translations and Stimulus instead of creating a parallel application workflow;
  • make the difficult cases reliable: large files, interrupted transfers, repeated submissions, concurrency and cleanup;
  • ship accessible native interaction with bundle-owned, application-overridable Twig markup;
  • own temporary transfer concerns without imposing permanent storage, media models or business transactions on applications.

Features

Transport follows the effective uploader policy automatically:

flowchart LR
    A["Select a file"] ==> B{"size <= chunk_size?"}
    B ==> C["Yes: one multipart POST"]
    B ==> D["No: resumable chunks"]
    C ==> E["Temporary file + signed form value"]
    D ==> E
    E ==> F["Invalid form: re-render without re-upload"]
    E ==> G["Valid form: CompletedUpload"]
    G ==> H["Application storeOnce()"]
    E ==> I["Scheduled TTL cleanup"]
Loading
  • Symfony-native field: single or multiple CompletedUpload values, named uploader policies and values preserved across invalid form submissions;
  • adaptive transport: one XHR request with upload progress for small files, resumable parallel chunks for larger files, with pause, retry and cancellation;
  • complete browser UX: drag-and-drop, keyboard and clipboard selection, progress, speed, estimated time and optional image previews;
  • defence in depth: signed policies and tokens, context binding, server-side MIME detection, bounded compression, per-part SHA-256 checks and optional whole-file checksums;
  • operational storage: local or Flysystem temporary storage, locking, lifecycle events and scheduled TTL cleanup;
  • Symfony UX integration: standard Form rendering, overridable Twig blocks, optional compact and dropzone CSS, a standalone JavaScript API and an optional LiveComponent bridge.

Out of Scope

  • permanent storage, media entities and application database transactions;
  • public delivery, download authorization and CDN integration;
  • antivirus scanning, transcoding and asynchronous media processing;
  • provider-native browser-to-cloud multipart protocols;
  • multi-gigabyte ingestion, which should use provider-native direct or multipart upload APIs;
  • a no-JavaScript fallback or batching several files into one request.
  • the Symfony Flex recipe: this PR ships the Bundle itself, and a second companion PR will add automatic bundle, route and asset registration.

Requirements

Required Dependencies

  • PHP 8.4 or later;
  • Symfony 7.4 or 8.x;
  • Symfony UX 3.x and symfony/stimulus-bundle 3.0 or later.

Symfony Lock and MIME are required. Lock coordinates concurrent writes, retries and cleanup; MIME supports server-side content validation.

Optional Dependencies

The uploader, controller, Form type and local temporary storage work without any of the optional packages below. These dependencies are only needed for their corresponding integration:

Dependency Enables
symfony/twig-bundle ^7.4|^8.0 Built-in Twig form themes and template rendering
symfony/console ^7.4|^8.0 The ux:upload:cleanup command
symfony/security-csrf ^7.4|^8.0 CSRF protection on upload mutation endpoints
symfony/security-bundle ^7.4|^8.0 Automatic binding to the authenticated user
symfony/rate-limiter ^7.4|^8.0 Upload initialization throttling
league/flysystem ^3.0 Flysystem-backed temporary storage
symfony/ux-live-component ^2.20|^3.0 ComponentWithUploadTrait and the LiveComponent bridge

Usage

Named uploaders keep transport policy on the server:

# config/packages/ux_upload.yaml
ux_upload:
  uploaders:
    documents:
      max_size: 50M
      allowed_types: [application/pdf]
$builder->add('document', FileUploadType::class, [
    'uploader' => 'documents',
]);

On a valid submission, the field contains a lazy CompletedUpload. Its metadata is available without reading storage; openStream() is the explicit I/O boundary:

if ($form->isSubmitted() && $form->isValid()) {
    /** @var CompletedUpload|null $upload */
    $upload = $form->get('document')->getData();

    if (null !== $upload) {
        $stream = $upload->openStream();

        try {
            $documentStorage->storeOnce(
                uploadId: $upload->getId(),
                stream: $stream,
                originalName: $upload->getOriginalName(),
                mimeType: $upload->getMimeType(),
            );
        } finally {
            fclose($stream);
        }
    }
}

$documentStorage and storeOnce() are application code. The upload ID is an idempotency key: receiving the same valid request twice must not create two permanent files.

Configuration

Global defaults apply to every uploader; named uploaders may narrow or override them:

ux_upload:
  storage: local
  chunk_size: 5M
  parallel_chunks: 3
  max_size: 100M
  compression: false
  integrity_algorithm: sha256
  completed_ttl: 86400

  local_storage:
    directory: "%kernel.project_dir%/var/uploads"

  uploaders:
    images:
      max_size: 20M
      allowed_types: [image/jpeg, image/png, image/webp]

chunk_size is both the direct-upload threshold and the part size for resumable uploads. Distributed deployments use Flysystem and must configure a shared lock store.

integrity_algorithm selects the optional whole-file checksum attempted by the browser for files up to 64 MiB. The bundled transport independently sends a SHA-256 digest for every direct body or chunk.

Integration

Symfony Forms and Twig

FileUploadType uses standard Symfony form rendering, validation errors and re-rendering. Its layout option selects compact or dropzone, while show_preview independently enables image previews in either layout. Collection entry indices are normalized in the signed field path, so removing or reordering an entry does not invalidate its upload or weaken isolation from other fields.

The form theme owns the complete markup and exposes one composable contract made of ten blocks for the standard Form row, widget, picker, item visual, progress, actions, summary and client errors. Applications import it once with Twig use, override one or several related blocks in the same application form theme, and can register that theme globally through twig.form_themes. Standard Symfony options render the label, help, row, errors and native file input attributes; widget_attr customizes the UX Upload container. Stimulus clones Twig <template> elements and updates state, native hidden and disabled properties, and accessibility attributes without constructing presentation markup. File-specific failures stay in their item; Symfony errors and general client feedback share the field-level presentation.

Two standalone Baseline 2026 stylesheets provide optional compact and dropzone treatments. Both AssetMapper autoimport entries are disabled by default, so the bundle remains compatible with an application's existing form theme and CSS.

This PR deliberately introduces a Form type, not a Twig Component: the form is the integration, validation and submission boundary.

Live Components

ComponentWithUploadTrait can assign a completed upload to an explicitly authorized nullable #[LiveProp]. #[UploadTarget] is the allow-list, while getUpload() lazily resolves its signed token.

#[AsLiveComponent]
final class ProfilePhoto
{
    use ComponentWithUploadTrait;

    #[LiveProp]
    #[UploadTarget(uploader: 'avatar')]
    public ?string $photo = null;

    public function getPhotoUpload(): ?CompletedUpload
    {
        return $this->getUpload('photo');
    }
}

The property stores the signed token rather than bytes or a path. The bridge uses LiveComponent's public JavaScript API and remains inert when the optional package is absent.

Implementation

Direct and chunked transfers converge on the same uploader pipeline, storage adapters, validation, events and final token format. The boundary is intentional: UX Upload solves browser transfer and temporary-file reliability while applications retain their own storage and transaction model.

The default direct transport uses XHR because browsers expose upload progress there, unlike fetch(). Explicitly injected fetch transports remain supported for custom frontends, but direct progress then depends on an injected XHR factory. A direct network failure is never replayed automatically because the server may already have completed it; only an explicit 413 falls back to chunks.

Metadata access does not contact storage. Only openStream() may fetch bytes, which matters for remote backends and repeated form requests. Temporary files remain cleanup candidates, so applications copy the bytes they need and make that operation idempotent. Cleanup stays outside the form request because an immediate delete would make a repeated request fail.

The whole-file checksum is optional and may be absent from CompletedUpload. Applications that require a checksum for every file calculate it while copying into permanent storage or use a checksum supplied by their storage provider.

The built-in Flysystem adapter accepts any configured FilesystemOperator, including S3, Azure or GCS adapters. MultipartUploadStorageInterface lets a custom server-side storage complete provider-native parts, but UX Upload deliberately does not implement presigned browser-to-cloud ingestion.

The cleanup command intentionally exposes an age threshold rather than a generic batch limit. Very large remote inventories need provider pagination, lifecycle policies or an application-owned cleanup worker. Applications that increase file count, chunk size or parallelism also own the resulting browser, proxy and storage capacity.

Security

Upload endpoints accept untrusted browser input and write potentially large objects. The Bundle therefore applies security at every transition instead of relying on the form submission alone.

Requests and Authorization

  • signed upload policies prevent the browser from widening uploader or field limits;
  • signed, expiring chunk and resume URLs constrain follow-up mutations, while direct uploads require the signed field policy and, when available, CSRF protection;
  • completed tokens are bound to their owner, tenant and canonical form-field path to prevent cross-context replay, while dynamic collection indices are normalized to survive reordering;
  • CSRF protection is applied when the component is installed, while an optional rate limiter can throttle initialization.

File Validation

  • declared metadata, transmitted bytes and assembled size are checked independently;
  • MIME type is detected from assembled content, never trusted from the browser;
  • the bundled browser transport sends a SHA-256 digest for every part, while files up to 64 MiB may also carry an optional SHA-256, SHA-384 or SHA-512 whole-file checksum;
  • compressed parts are accepted only when enabled and decompressed within the configured size bound.

Storage and Concurrency

  • generated paths remain inside the configured temporary prefixes;
  • Symfony Lock serializes writes, completion, quota checks and cleanup;
  • distributed mode requires Flysystem and an explicitly acknowledged shared lock;
  • local storage is rejected for distributed deployments.

Tests

  • PHP: 456 tests, 4,077 assertions and 100% class, method and line coverage;
  • JavaScript: 157 Vitest tests across 10 files;
  • Browser: 15 Chromium scenarios with 97 assertions run in a dedicated CI job, covering real Forms/Stimulus integration, labels, help, validation errors, Bootstrap and Tailwind row delegation, direct and multiple uploads, local and global form themes, partial templates, attribute escaping, unstyled action states, inherited color schemes, accessibility and duplicate-submission prevention;
  • Storage and concurrency: shared LocalStorage, InMemoryStorage and Flysystem contracts plus multi-process chunk tests;
  • Quality gates: PHP CS Fixer, TypeScript, Oxlint, formatting, reproducible asset build with committed dist verification and Composer security audit.

Documentation

The documentation follows the user journey from the first field to production operation:

The detailed guide is authored as 19 publishable reStructuredText pages. It covers the first field, transport and storage concepts, customization, security, integrations and production operation.

Section Focus Pages
Start Product overview and installation index.rst, installation.rst
Build a form Fields, values and named policies form.rst, named-uploaders.rst
Understand the model Ownership, lifecycle and temporary storage architecture.rst, upload-lifecycle.rst, storage.rst, persisting-uploaded-files.rst
Customize JavaScript, Twig, CSS and server events javascript.rst, customizing-upload-field.rst, events.rst
Build robust flows Validation, security, retry and resume validation.rst, security.rst, retry-and-resume.rst
Integrate LiveComponent bridge live-component.rst
Operate Deployment, testing and debugging production.rst, testing.rst, debugging.rst
Reference Complete configuration configuration.rst

Coming Next

Companion pull requests will address:

  • a second PR with the Symfony Flex recipe for automatic bundle, route and asset registration;
  • the ux.symfony.com package pages and focused interactive demos;
  • cross-bundle integration examples, starting with Autocomplete-backed metadata.

@carsonbot carsonbot added Documentation Improvements or additions to documentation Feature New Feature Status: Needs Review Needs to be reviewed labels Aug 13, 2026
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@github-actions

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
Upload
compact.css Added 5.08 kB / 1.41 kB
defineProperty-B6pPL0VL.js Added 985 B / 484 B
dropzone.css Added 5.97 kB / 1.55 kB
index.d.ts Added 1.36 kB / 626 B
index.js Added 363 B / 207 B
live_upload_controller.d.ts Added 553 B / 308 B
live_upload_controller.js Added 1.03 kB / 508 B
upload_controller-CIQaENxt.js Added 33.93 kB / 8.1 kB
upload_controller.d.ts Added 7.14 kB / 1.47 kB
upload_controller.js Added 93 B / 123 B
uploader-CW2SxOPm.js Added 22.55 kB / 5.55 kB
uploader-EqhK8s3O.d.ts Added 5.62 kB / 1.79 kB
uploader.d.ts Added 406 B / 213 B
uploader.js Added 175 B / 130 B

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.

3 participants