Livewire UI
Livewire components and themes
Laracrate ships an optional Livewire UI layer. The core (models, storage, pipeline, HTTP endpoints) works without Livewire, so you can ignore this section entirely and build your own front end against the methods in the "Working with files from your models" section. If you do use Livewire, these components wire the model trait, collection config, and upload flow together for you.
The components register only when Livewire is installed (registerLivewireComponents() no-ops otherwise). There is one view per family, and the look is CSS: import resources/css/laracrate.css once (see "Themes and customising").
Minimal example: a single-file uploader for a user avatar.
<livewire:laracrate-uploader :model="$user" collection="avatar" />
The three components
All three are registered in LaracrateServiceProvider. They split into two families: the card uploader (single file, in-place preview) and the dropzones (presigned direct upload to R2/S3, see the "Upload modes" and "HTTP endpoints" sections).
| Tag | Class | Files | Use case |
|---|---|---|---|
laracrate-uploader |
LaracrateUploader |
single, replaces the current one | Avatar, logo, cover image |
laracrate-dropzone |
LaracrateDropzone |
multiple, with per-file and batch events, file slots and a slot picker | Galleries, document libraries, forms where files are tied to slots |
laracrate-dropzone-single |
LaracrateDropzoneSingle |
single, replaces the current one | One large file (a video, a PDF) too big for the card uploader |
Each one uploads as soon as a file is picked, or with :deferred="true" stages it for review and only persists when the user confirms. The dropzones upload straight to your disk with a presigned PUT and the binary never touches PHP. laracrate-uploader sends the file through PHP (no CORS needed on the bucket, and the only way an encrypt collection can be uploaded). Both go through the shared upload queue, so they keep uploading after a wire:navigate: see "Upload modes, grants and the upload tray". The deferred uploader stages the file with Livewire's temporary upload instead and saves it on submit(); leaving before submitting saves nothing, by design. Pick the dropzones for large files.
2.0 removes the 1.x tags laracrate-uploader-deferred, laracrate-dropzone-deferred and laracrate-dropzone-single-deferred: use the component without the suffix and :deferred="true".
Props
The card uploader accepts:
| Prop | Type | Default | Purpose |
|---|---|---|---|
model |
Eloquent model | required | The model the file belongs to (must use the HasFiles trait) |
collection |
string | required | Collection name as declared on the model |
variant |
string | null |
Variant to show in the preview (falls back to the master, then the collection placeholder) |
layout |
string | row |
row or portrait |
rounded |
string | null |
Rounds the preview (an avatar in a circle with full): none, sm, md, lg, xl, 2xl, 3xl, full |
deferred |
bool | false |
Preview the file, then submit() to persist or cancel() to discard |
Every component also takes the appearance props of "Themes and customising" (theme, rounded, bordered, shadow, size, class, style) and upload-mode (presign or grant, see "Upload modes, grants and the upload tray").
The dropzones share model, collection and deferred, plus their own props. Common ones: contextKey (an opaque identifier echoed back in the laracrate-file-uploaded event so you can route a file when several widgets share a page), folderId (target folder, see the "Folders" section), persistQueue, maxFiles, and hideActions (a deferred one without its own buttons, started by a laracrate-start-batch event). laracrate-dropzone adds slot support (slots, slotOptions, slotLabel, slotPlaceholder, slotOptional, selectedSlotId), a creator override (creator, or creatorType plus creatorId), and a layout of grid or list. laracrate-dropzone-single adds hideExisting. See the "File slots" section for slot semantics.
Immediate vs deferred
Instant uploader, persists on selection:
<livewire:laracrate-uploader :model="$user" collection="avatar" theme="ios" variant="medium" />
Deferred uploader, shows a preview and waits for the user to confirm:
<livewire:laracrate-uploader :model="$user" collection="avatar" :deferred="true" theme="studio" layout="portrait" />
A deferred multi-file dropzone, queue then batch upload:
<livewire:laracrate-dropzone :model="$organization" collection="gallery" :deferred="true" theme="studio" />
All components dispatch browser events you can listen for: laracrate-file-uploaded (per file, with collection, fileId, and contextKey), laracrate-file-deleted or laracrate-file-removed, and on the dropzones laracrate-batch-completed (with ok and error counts) and laracrate-file-rejected (with collection, reason and fileName) when a file is refused by the collection or a slot quota. The upload ones are sent only while the page that started the upload is still open: the page reached after a wire:navigate may listen for the same collection with another meaning. See "Upload modes, grants and the upload tray" for the events that always fire, and the "Events" section for the server-side events fired during processing.
Upload modes, grants and the upload tray
The upload components work in one of two modes, presign by default (laracrate.uploads.mode, optionally LARACRATE_UPLOAD_MODE), and each one can pick its own:
<livewire:laracrate-dropzone :model="$case" collection="documents" /> {{-- presign --}} <livewire:laracrate-dropzone :model="$case" collection="documents" upload-mode="grant" />
- presign: when the user picks files, the component signs them right there, with the collection and slot rules, and the server keeps where each signed key goes. The queue puts the bytes and registers each object with its key alone (
register); the browser never gets a grant nor names a target. The whole pick is signed at once, solaracrate.uploads.presign_minutes(120) is how long a long batch has. - grant: the component hands the browser an upload grant (below) and the queue signs each file with it when its turn comes.
Either way the uploads live in the page's queue and keep going after a wire:navigate. Presign only puts bytes straight in the bucket, so a component that uploads through PHP (an encrypted collection, the single file laracrate-uploader) always uses its grant. A key is registered once, only by the user it was signed for and in the same browser session, and with the size the bucket holds.
In grant mode, an upload component builds an upload grant when it mounts: its target (model, collection, slots, creator, owner, folder, context key, whether it replaces the current file) and how the bytes travel, bound to the signed in user (their class and id, so a client #5 is not a user #5) and to the browser session it was issued in, and valid for 24 hours. A token copied out of the page does not work in another browser, even with the same account. The browser only gets an encrypted token ($wire.uploadGrant) it can neither read nor alter, and the upload endpoints (sign, confirm, receive) take nothing else: the disk, the model and the object key never come from the request. A direct upload gets a receipt for the temp/ key it was signed, and only that grant can confirm it; the size checked is the one the bucket holds, not the one the browser declared.
The uploads themselves live in one queue for the whole page, window.Laracrate.uploads (an Alpine store when Alpine is there), not in the component. That is what lets them survive a wire:navigate: the queue keeps going, and a component mounted again for the same target ($wire.uploadScope) shows its uploads where they are. Put the tray in your layout to show them all, inside @persist so it is not re-created on each navigation:
@auth @persist('laracrate-uploads') @include('laracrate::upload-tray') @endpersist @endauth
Pass ['position' => 'bottom: 3rem; right: 1rem;'] to move it. It uses its own CSS (your Tailwind build does not scan the package views); the colours are the --wire-laracrate-tray-* variables on .laracrate-tray. A reload, closing the tab or a link without wire:navigate still ends the uploads; while any is running the browser asks first.
Your own front end can use the same queue with a grant built on the server:
use EduLazaro\Laracrate\Uploads\UploadGrant; $grant = UploadGrant::for($organization, 'drive', folderId: $folder?->id); // To the view: $grant->token() and $grant->scope()
Laracrate.uploads.add(input.files, { grant, scope }) window.addEventListener('laracrate:uploaded', (e) => { /* e.detail: scope, fileId, slug, collection, contextKey, name */ })
A grant can cap how many files it signs with maxFiles, counted on the server by sign, receive and the multipart init. A front end that knows how many files were dropped issues the grant for exactly that batch, so it is no good for another one later:
$grant = UploadGrant::for($organization, 'drive', folderId: $folder?->id, maxFiles: $count);
The queue sends an id with each file (upload), so retrying one does not count as another; a request without it always counts as a new file. Without maxFiles there is no limit. The components do not set it: their max-files is how many files the target holds, not how many uploads a grant signs.
Grants carry a version, and updating to one that changes it invalidates the grants of pages already open: their next upload asks to reload the page.
A page that shows its uploads itself (a chat composer with its attachments) passes tray: false to add(), and the tray leaves them out.
laracrate:upload fires on every change of every upload ({ type, item }), and laracrate:uploaded once per saved file, whatever page the user is on by then. Logic that must run after an upload no matter what (attach the file somewhere, start a job) belongs on the server, in an observer or a listener of the File, not in a browser listener.
A direct upload that is cancelled or never confirmed leaves its object under temp/. Add a lifecycle rule on that prefix in your bucket (R2 and S3 both have one) to delete what is older than a day.
The viewer
laracrate::viewer shows one image, audio or video at a time in a card at the bottom of the page. Images and videos open to full size from there (the same element grows, so a video keeps playing). Put it in your layout inside @persist, so what is playing keeps playing across wire:navigate:
@auth @persist('laracrate-viewer') @include('laracrate::viewer') @endpersist @endauth
Open a file with Laracrate.viewer.open(slug), or give any element data-laracrate-view="{{ $file->slug }}": a plain click on it opens the viewer, while Ctrl, Cmd, Shift or the middle button leave a link to do what it does. Opening the file that is already open leaves it as it is (a paused one plays on).
Elements that share a data-laracrate-gallery="name" form a gallery: the viewer shows "2 of 5" and moves through them in page order with its arrows and the arrow keys (not while the person types in a field). Hidden elements count too, so a grid that draws four images with "+N" can still reach the rest. From JavaScript: Laracrate.viewer.open(slug, { gallery: [slug, ...] }).
The page never holds a link to the file. The viewer asks GET /laracrate/files/{slug}/viewer (laracrate.files.viewer) when it opens, which answers with the name, the type and a link made at that moment (url()), after the file's own view policy. A signed link may run out while a long recording plays; the viewer then asks again, once, and carries on at the same second. Images (whatever their mode) show the variant in laracrate.viewer.image_variant (display) and videos the one in laracrate.viewer.video_variant (none by default), each falling back to the file itself. Other types answer 422, unless a preview is registered for their MIME type.
'viewer' => [ 'image_variant' => 'display', 'video_variant' => null, // e.g. 'web', a copy declared in the video variants ],
Previews for other types
A package or the app can show other files in the viewer as HTML: register a resolver for their MIME type on ViewerRegistry, usually from a service provider's boot():
use EduLazaro\Laracrate\Models\File; use EduLazaro\Laracrate\Support\ViewerRegistry; app(ViewerRegistry::class)->html('application/x-note', fn (File $file) => [ 'html' => Note::forFile($file)->html, // what the card shows 'edit' => route('notes.edit', $file->slug), // optional: an "Edit" button ]);
The resolver runs after the view policy and may return null to fall back to the 422. The HTML goes into a sandboxed iframe (no scripts, no access to the page), small and scrolling in the card and readable at full size. edit adds a pencil that follows the link with wire:navigate; download, if given, a download button. laracrate-docs registers its documents this way.
Pass ['position' => '...'] to move it; the colours are the --wire-laracrate-viewer-* variables. Next to the upload tray, stack both in one container of your layout.
Handoff: editing a file in another site
A file can be opened in an editor of another site and come back as a copy next to the original, with the handoff protocol (windows and postMessage, version 1; Auditius publishes it in src/lib/handoff/SPEC.md). Declare the editors in config:
'handoff' => [ 'editors' => [ 'auditius' => [ 'name' => 'Auditius', 'url' => 'https://auditius.com/?editor&handoff=1', // its handoff URL; the editor answers from this origin 'types' => ['audio'], // file types it opens 'formats' => ['audio/mpeg'], // what the copy may come back as ], ], ],
Include laracrate::handoff once in the layout, with the name the editor shows on its "Save to ..." command, and call Laracrate.handoff.open() from a click:
@include('laracrate::handoff', ['appName' => 'Inis Legal']) <button @click="Laracrate.handoff.open('auditius', '{{ $file->slug }}').catch((e) => e.code !== 'closed' && alert(e.message))"> Edit in Auditius </button>
open() opens the editor window inside the click, reads the file from your site (GET /laracrate/files/{slug}/handoff/{editor}, counted as a download) and hands it over once the editor says it is ready. Whoever may see the file may open it; only whoever may edit it gets somewhere to save. The editor saves into GET /laracrate/files/{slug}/handoff/{editor}/save, a page of its own that takes the file only from that editor's origin, lets the person name the copy and uploads it with the upload queue under a grant for the original's model, collection and folder, so the collection's rules apply as to any upload. Other tabs of your app get a laracrate:uploaded event when the copy is saved.
An editor only saves to https (or localhost while developing). On any other address, open() still works but offers nowhere to save.
Themes and customising
The look of every component is one stylesheet. Import it once, next to the other wire* packages:
/* resources/css/app.css */ @import '../../vendor/edulazaro/laracrate/resources/css/laracrate.css';
Themes. default, studio, minimal, material, ios, glassmorphism, neon, neumorphism, brutalist, claude and chatgpt. A component without theme= takes the theme of its page, the data-wire-theme attribute the wire* packages put on an ancestor (usually <html>); without one, it looks like default. There is no theme setting in the config, as in the rest of the wire* family.
<html data-wire-theme="studio"> {{-- the whole page --}} <livewire:laracrate-dropzone ... theme="minimal" /> {{-- this one --}}
A theme of your own is CSS, no views to publish. A theme only sets variables; everything you leave out keeps the default. The general ones are the wire* family's, so one theme styles Laracrate, the tables and the modals together; the --wire-laracrate-* ones are for Laracrate only:
[data-wire-theme="acme"] { --wire-font: 'Instrument Sans', sans-serif; --wire-accent: #ff4f38; --wire-radius: 16px; --wire-laracrate-drop-border-color: #1a1719; }
One rendering. Parameters on the tag change only that rendering:
| Prop | Values | Effect |
|---|---|---|
theme |
a theme name | this component's theme |
rounded |
none, sm, md, lg, xl, 2xl, 3xl, full |
the radius (in the uploaders, the preview's) |
bordered |
false |
no borders |
shadow |
true, false |
a soft shadow, or none whatever the theme says |
size |
sm, md, lg |
scales text, spacing and icons together |
class |
classes | on the root, to target this one from your CSS |
style |
inline style | on the root, e.g. --wire-laracrate-drop-padding: 1rem |
<livewire:laracrate-dropzone :model="$case" collection="documents" theme="studio" rounded="none" :bordered="false" size="sm" style="--wire-laracrate-drop-padding: 1rem" />
The variables. Each one falls back to its wire* family counterpart where there is one (--wire-font, --wire-text, --wire-text-muted, --wire-accent, --wire-radius), then to the default theme.
| Group | Variables (--wire-laracrate-…) |
|---|---|
| General | font, mono, text, text-muted, text-faint, accent, accent-hover, accent-text, danger, bg, border-color, border-width, radius, shadow |
| Drop area | drop-bg, drop-hover-bg, drop-border-width, drop-border-style, drop-border-color, drop-hover-border-color, drop-radius, drop-padding, drop-align, drop-items, drop-shadow, drop-hover-shadow, drop-hover-transform, drop-backdrop, dragover-bg, dragover-border-color, dragover-shadow, dragover-transform |
| Icon | icon-display, icon-box, icon-size, icon-padding, icon-bg, icon-color, icon-border, icon-radius, icon-shadow, icon-filter, icon-gap |
| Text | title-size, title-weight, title-color, title-font, title-transform, title-spacing, the same for subtitle-*, hint-size, hint-color, hint-font, hint-transform, hint-spacing, the label-* ones (status and eyebrows, plus label-style), name-weight |
| Button and link | button-bg, button-hover-bg, button-text, button-border-width, button-border-color, button-radius, button-shadow, button-hover-shadow, button-hover-transform, button-size, button-weight, button-transform, button-spacing, button-backdrop, link-color, link-hover-color, link-size, link-font, link-transform, link-spacing, link-decoration |
| Progress | progress-bg, progress-fill, progress-height, progress-radius, progress-border, progress-glow |
| Files | tile-radius, tile-border, tile-bg, tile-placeholder-bg, tile-placeholder-color, tile-name-bg, tile-name-font, overlay-uploading, overlay-done, overlay-error, card-bg, card-border, card-radius, card-shadow, media-bg, preview-radius, icon-button-border, icon-button-color, icon-button-radius, icon-button-bg |
Every part has a stable class (.laracrate-drop, .laracrate-title, .laracrate-button, .laracrate-tile, .laracrate-card…), and states are data-state (pending, uploading, done, error) and is-* classes (is-dragover), for whatever variables do not reach.
Localization
The UI strings used by the Livewire components are translatable through the laracrate translation namespace, loaded by the service provider via loadTranslationsFrom(__DIR__ . '/../lang', 'laracrate'). The package ships English (lang/en/uploader.php) and Spanish (lang/es/uploader.php) out of the box, and resolves strings against your app's active locale.
Reference a string with the namespaced key:
{{ __('laracrate::uploader.upload') }}
The strings live in the uploader group (the viewer's in viewer, the handoff pieces' in handoff). A sample of the uploader keys (see the source files for the full list):
| Key | English | Spanish |
|---|---|---|
upload |
Upload file | Subir archivo |
select |
Select file | Seleccionar archivo |
replace |
Replace | Reemplazar |
delete |
Delete | Eliminar |
delete_confirm |
Delete this file? | ¿Borrar este archivo? |
submit |
Upload | Subir |
cancel |
Cancel | Cancelar |
uploading |
Uploading... | Subiendo... |
processing |
Processing | Procesando |
failed |
Error | Error |
slot_placeholder |
Unclassified | Sin clasificar |
max_size |
max :size MB | máx :size MB |
Keys like max_size and max_size_capital take a :size placeholder.
Overriding strings
Publish the translation files into your app, then edit them:
php artisan vendor:publish --tag=laracrate-translations
The files land in lang/vendor/laracrate/{locale}/uploader.php. Any key you define there overrides the package default for that locale; keys you leave out fall back to the package.
Adding a locale
Create lang/vendor/laracrate/{locale}/uploader.php for the new locale (copy en/uploader.php and translate the values, keeping the keys), then set your app locale. For example, to add French, add lang/vendor/laracrate/fr/uploader.php and the components will pick it up when app()->getLocale() returns fr.