# TallStackUI: Upload Async
> TallStackUI is a TALL Stack (Tailwind CSS, Alpine.js, Laravel, Livewire)
> component library providing 80+ Blade components for building modern web interfaces.
A chunked, asynchronous file upload component. The browser slices each file and posts the pieces straight to a controller of your own, bypassing Livewire's upload pipeline entirely, so a file no longer has to fit inside PHP's request limits. Files around 1 GB are the target.
It shares nothing with `` beyond the namespace. Unlike that one, it works outside Livewire too, binding either to a `wire:model` or to a plain form through `name`.
## Basic Usage
```blade
```
```blade
```
```blade
```
```blade
```
The `route` accepts a named route or a plain URL. Either `wire:model` or `name` is required.
An image tile opens a fullscreen lightbox when clicked. `:preview="false"` turns that off: the lightbox is not rendered at all, the thumbnail stops reacting to the click and loses its pointer cursor. Non-image tiles were never clickable, so nothing changes for them.
```blade
```
## Attributes
| Attribute | Type | Default | Description |
|-------------|-----------------------------|-------------|----------------------------------------------------------------------|
| route | string **required** | — | Named route or URL the chunks are posted to |
| method | string | 'POST' | HTTP method used for every chunk |
| label | string\|ComponentSlot\|null | null | Label text displayed above the drop area |
| hint | string\|ComponentSlot\|null | null | Hint text displayed below the drop area |
| title | string\|null | translation | Placeholder title inside the drop area |
| description | string\|null | translation | Placeholder subtitle inside the drop area |
| tip | string\|ComponentSlot\|null | null | Extra line under the description, hidden once a file is picked |
| multiple | bool | false | Allows multiple file selection |
| preview | bool | true | Opens the image lightbox when a thumbnail is clicked |
| manual | bool | false | Stages the files and waits for the Send button |
| disabled | bool | false | Blocks drop, click and keyboard |
| limit | int\|null | null | Maximum number of files, only meaningful with `multiple` |
| max-size | int\|null | config | Maximum megabytes per file, also enforced by the handler |
| accept | string\|null | config | Mime/extension filter (e.g. `image/*,.pdf`) |
| files | array\|Collection\|null | null | Pre-existing files, same shape as the bound value |
| height | string\|null | 'min-h-48' | Tailwind min-height of the drop area |
| columns | int\|null | 6 | Maximum grid columns, clamped to 1..6 |
| chunk-size | int\|null | config | Bytes per chunk |
| concurrency | int\|null | config | Chunks uploaded in parallel, per component |
| retries | int\|null | config | Attempts per chunk on transient failures |
| headers | array\|null | null | Extra HTTP headers. The CSRF token is injected automatically |
| footer | ComponentSlot\|null | null | Replaces the built-in Send/Clear footer of manual mode |
| error | string\|bool\|null | translation | Component-level fallback error message |
| editor | bool\|string\|null | config | Opens the image editor before the upload: `true`, `crop` or `rotate` |
| aspect | string\|null | config | Locks the editor crop box to a `width:height` ratio |
## Slots
| Slot | Description |
|--------|------------------------------------------------------------------------------------------------|
| footer | Replaces the Send and Clear buttons rendered by manual mode. Ignored when `manual` is not set. |
## Bound Value
Both `wire:model` and `name` receive the same shape, an array of finished uploads. With `multiple` off, a single object or `null`.
```php
[
[
'id' => '9f8c2b1e-...',
'path' => 'posts/attachments/abc-def.jpg',
'real_name' => 'photo.jpg',
'size' => 1234567,
'mime' => 'image/jpeg',
'url' => '/storage/posts/attachments/abc-def.jpg',
],
]
```
`url` is `null` when the destination disk has no public URL. With `name`, the same data is rendered as hidden inputs so a plain form submit carries it.
Following `wire:model` semantics, the array is synced on the next round trip. Use `wire:model.live` to push it immediately.
## Image Editor
```blade
```
Images open a crop and rotate dialog before the first chunk is sent; the edited file is what gets chunked, validated and stored. In `manual` mode the edited file waits in the grid until Send. Everything the dialog does, accepts and exposes is on the [Upload Editor](editor.md) page.
## Backend
The endpoint is yours. The `Uploader` trait handles the chunk protocol:
```php
use Illuminate\Http\Request;
use TallStackUi\Http\AsyncUpload\Uploader;
class UploadController
{
use Uploader;
public function store(Request $request)
{
return $this->upload($request, [
'disk' => 'public',
'directory' => 'posts/attachments',
'rules' => ['file' => ['mimes:jpg,png,pdf']],
]);
}
}
```
The method is called once per chunk. Intermediate chunks answer `204`; the last one assembles the file, stores it and answers `200` with the payload above.
> Naming the `directory` is required. Without it, and without a `store` callback, the handler throws instead of guessing a destination.
### Options
| Option | Type | Default | Description |
|-----------|----------|--------------|--------------------------------------------------------------------------------------------------------------------|
| disk | string | config | Destination disk. Any driver, including S3 |
| directory | string | **required** | Destination directory on that disk |
| rules | array | null | Laravel rules applied to the assembled file, under the `file` key |
| store | callable | null | Receives `(SplFileInfo $file, AsyncUploadRequest $request)` and returns the final path, skipping the built-in move |
| authorize | callable | null | Receives `(AsyncUploadRequest $request)`; returning `false` aborts with `403` |
| max_size | int | config | Per-endpoint override of the megabyte ceiling |
| tmp_disk | string | config | Staging disk. Must use the local driver |
### Taking over persistence
The callback receives the assembled file and returns its final path. This is where a library like MediaLibrary plugs in:
```php
return $this->upload($request, [
'disk' => 'public',
'store' => fn (SplFileInfo $file, AsyncUploadRequest $request): string => $post
->addMedia($file)
->usingFileName($request->input('real_name'))
->toMediaCollection('attachments')
->getPathRelativeToRoot(),
]);
```
It runs once per file, not once per chunk, after the pieces are joined and validated. Whatever path it returns must exist on the disk named in `disk`, since the handler reads the size, mime and URL back from there.
### Guards
`max-size` on the component is feedback for the user; a request built by hand would ignore it. The handler re-checks the declared size on every chunk and compares the assembled bytes to it at the end, so neither can be lied about. `rules` run against the real bytes, not the mime the browser claimed.
Route middleware is yours. The `authorize` option sits on top of it, for rules middleware cannot express. It receives the validated `AsyncUploadRequest` and runs on every chunk, before anything is written:
```php
'authorize' => fn (AsyncUploadRequest $request): bool => $request->user()->can('upload', $post),
```
### Cleaning up
An upload that starts and never finishes leaves its pieces staged. The command discards whatever has been idle longer than the `keep` setting:
```php
// routes/console.php
Schedule::command('tallstackui:async-upload:clear')->daily();
```
Nothing else collects them, so without this the staging directory grows forever. Finalized files are never touched: telling an orphan from a saved file there would need your database.
## Events
### Alpine.js
Dispatched on the component root. All payloads arrive on `event.detail`.
| Event | When | Detail |
|----------|----------------------------------------------------------|---------------------------------------------|
| added | File passed the client-side checks and entered the queue | `{ file }` |
| rejected | File blocked by `accept`, `max-size` or `limit` | `{ file, reason: 'mime'\|'size'\|'limit' }` |
| start | Chunk loop began for a file | `{ file }` |
| progress | Per-file progress update | `{ file, progress }` (0..100) |
| success | Backend accepted the file | `{ file, response }` |
| error | Definitive failure, retries exhausted | `{ file, error, status }` |
| removed | File removed from the grid | `{ file }` |
| complete | Whole queue finished, whatever the outcome | `{ files }` |
```blade
```
### Laravel
Three events for side effects: queueing a thumbnail, scanning, auditing.
| Event | When | Payload |
|----------------------|-------------------------------------------------------|--------------------------------------------------------|
| AsyncUploadStarted | First chunk of a file landed | `uuid`, `realName`, `mime`, `totalSize`, `totalChunks` |
| AsyncUploadCompleted | File assembled, validated and stored | `response`, `disk`, `uuid` |
| AsyncUploadFailed | A guard, the rules or the integrity check rejected it | `reason`, `uuid`, `realName`, `errors` |
`$reason` is one of `unauthorized`, `size`, `integrity` or `rules`. There is deliberately no per-chunk event: a 500 MB file would fire hundreds.
> `AsyncUploadCompleted` is not the place to write a database row. A finished upload is not a submitted form, and the user may still close the tab. That write belongs where the form is handled, reading the array the component synced out.
## Configuration
Global defaults live under `components.upload.async` in the published config.
| Setting | Default | Description |
|---------------|-----------------|------------------------------------------------------------------------|
| chunk_size | 2 MB | Bytes per chunk. Must stay below the PHP `upload_max_filesize` |
| concurrency | 3 | Chunks uploaded in parallel, per component |
| retries | 3 | Attempts per chunk on 5xx, 408, 429 and network errors |
| retry_delay | 1000 | Milliseconds between retries, with exponential backoff |
| max_size | null | Maximum megabytes per file. null = unlimited |
| accept | null | Default mime/extension filter. null = any |
| tmp_disk | 'local' | Disk used to stage the chunks. Must use the local driver |
| tmp_directory | 'async-uploads' | Directory, inside `tmp_disk`, used to stage the chunks |
| disk | 'local' | Destination disk of the finalized files |
| keep | 6 hours | Seconds an unfinished upload is kept before the clear command drops it |
| editor | false | Opens the image editor by default: `false`, `true`, `crop` or `rotate` |
| aspect | null | Default crop ratio of the editor, in the `width:height` format |
| quality | 0.92 | Compression of the edited jpeg and webp images |
| format | null | Forces the output type of the edited image: `png`, `jpeg` or `webp` |
The `aria-label` of the remove button and of the lightbox close button are not
translated: they carry a fixed English string.
Raising `chunk_size` requires raising the PHP limits with it, otherwise every chunk is rejected before reaching Laravel. The staging disk must be local because joining the pieces needs real filesystem paths; the destination has no such restriction.
## Soft Customization
Soft customization allows you to override default Tailwind CSS classes used by this component at runtime, either through a service provider or scoped per-instance.
### Customization
```php
TallStackUi::customize()
->form('upload.async')
->block('dropzone.base', 'your-tailwind-classes');
```
### Available Blocks
| Block Name | Purpose |
|------------------------------|-----------------------------------------------------------------|
| wrapper | Outermost container of the component |
| dropzone.base | Drop area frame: border, radius, padding and scrollbar clipping |
| dropzone.dragging | Drop area while a file is being dragged over it |
| dropzone.disabled | Drop area while disabled |
| dropzone.input | Hidden file input overlay |
| dropzone.placeholder | Placeholder container, shared by both sizes |
| dropzone.placeholder-full | Placeholder layout while no file has been picked |
| dropzone.placeholder-compact | Placeholder layout once files are in the grid |
| dropzone.icon | Placeholder icon colour |
| dropzone.icon-full | Placeholder icon size while empty |
| dropzone.icon-compact | Placeholder icon size once filled |
| dropzone.icon-bouncing | Placeholder icon animation while dragging |
| dropzone.title | Placeholder title colour |
| dropzone.title-full | Placeholder title size while empty |
| dropzone.title-compact | Placeholder title size once filled |
| dropzone.description | Placeholder description colour |
| dropzone.description-full | Placeholder description size while empty |
| dropzone.description-compact | Placeholder description size once filled |
| dropzone.tip | Placeholder tip text |
| grid.wrapper | File grid scroll container |
| grid.multiple | Grid width in multiple mode |
| grid.single | Grid width in single mode |
| grid.cols.1 … grid.cols.6 | Responsive column counts, picked by the `columns` attribute |
| tile.wrapper | File tile container |
| tile.image | Image thumbnail inside the tile |
| tile.image-clickable | Added to the thumbnail only while `preview` is on |
| tile.document | Non-image tile container |
| tile.document-icon | Non-image tile icon |
| tile.extension | Uppercase extension pill of a non-image tile |
| tile.name-overlay | File name gradient at the top of the tile |
| tile.size-overlay | File size gradient at the bottom of the tile |
| tile.remove | Remove button of the tile |
| tile.remove-icon | Remove button icon |
| tile.progress | Per-file progress bar |
| tile.success-mark | Badge shown once the file is stored |
| tile.error-ring | Ring drawn around a rejected or failed tile |
| tile.error-badge | Badge shown on a rejected or failed tile |
| tile.error-msg | Error message rendered over the tile |
| lightbox.backdrop | Fullscreen image preview backdrop |
| lightbox.wrapper | Preview image container |
| lightbox.image | Preview image |
| lightbox.caption | File name overlaid on the preview |
| lightbox.close | Preview close button |
| lightbox.close-icon | Preview close button icon |
| footer.wrapper | Manual mode footer container |
| footer.summary | Manual mode file count and total size |
| footer.actions | Manual mode button group |
| error.wrapper | Error message container |
| error.message | Error message text |