First-party media library for Lattice — an uploadable, searchable file library with a React grid, a detail slideout for renaming and alt text, bulk delete, and a form field that attaches media to any model through a polymorphic pivot.
composer require lattice-php/mediaDevelopment happens in the lattice-php/lattice monorepo; this repository is a read-only split. Please open issues and pull requests there.
On the PHP side there is nothing to wire: the classes, migrations, config (config/media.php), and
the default Media policy are picked up automatically. The React renderer ships as source and
Lattice's lattice() Vite plugin compiles it into your app's bundle via virtual:lattice/plugins.
No-build apps use the precompiled standalone module instead: run php artisan lattice:assets after
installation.
The package has three touchpoints.
The library component — a standalone page of media:
use Lattice\Media\Components\MediaLibrary;
MediaLibrary::make();The picker field — the library inside a form, submitting the selected ids:
use Lattice\Media\Forms\Components\MediaPicker;
MediaPicker::make('gallery')->multiple();The HasMedia trait — per-collection attachments on any model:
use Lattice\Media\Models\Concerns\HasMedia;
class Product extends Model
{
use HasMedia;
}
$product->syncMedia($ids, 'gallery'); // replaces the collection, keeps the given order
$product->media('gallery'); // MorphToMany<Media>, ordered by the pivot
$product->firstMediaUrl('gallery');Validate submitted ids with Lattice\Media\Rules\AttachableMedia.
The package registers a media-image rich-editor extension. Activate it per
field and optionally offer conversions as selectable sizes:
use Lattice\Lattice\Forms\Components\RichEditor;
use Lattice\Media\Forms\RichEditor\MediaImage;
RichEditor::make('body')->withExtensions(MediaImage::make()->conversions('hero'));The stored document keeps only {id, alt, conversion} per image — URLs are
resolved on every render and prefill, so temporary/signed disk URLs work.
Render stored documents as usual with RichContent::make($post->body)->toHtml().
To track usage (and benefit from per-collection conversions), sync the referenced media as attachments when you persist the document:
$post->update(['body' => $validated['body']]);
$post->syncMedia(MediaImage::idsIn($validated['body']), 'content');Conversion names passed to ->conversions() should be generated for that
collection — declare them in the model's mediaConversions('content') so the
sync dispatches their generation.
Every convertible image (jpeg, png, bmp, gif, webp) gets its derivatives generated by a queued job
after upload and after attach — as does anything stored under a generic mime type, since a signed
upload can record one for a real image; the job probes the bytes and skips what is not an image.
$media->previewConversion() (thumb by default) is what the grid, the picker and the detail
slideout preview; $media->url('thumb') reads any of them and falls back to the original when it
was never generated.
Defaults live on the model. Subclass it, point media.model at your class, and return callbacks over
Laravel's immutable Illuminate\Image\Image — each one must return the transformed image. Override
previewConversion() alongside defaultConversions() if the subclass drops thumb, or the library
grid silently falls back to full-size originals:
class Media extends \Lattice\Media\Models\Media
{
public function defaultConversions(): array
{
return [
'thumb' => fn (Image $image): Image => $image->cover(400, 400)->optimize('webp', 70),
'hero' => fn (Image $image): Image => $image->scaleDown(1600)->optimize('webp', 80),
];
}
}A collection can ask for more on top of the defaults. A bare string reuses a conversion that is already defined globally:
class Product extends Model
{
use HasMedia;
public function mediaConversions(string $collection): array
{
return match ($collection) {
'gallery' => ['card' => fn (Image $image): Image => $image->cover(1200, 800)],
'downloads' => ['hero'],
default => [],
};
}
}Conversion names are one global namespace: one name is one spec everywhere. Two collections that need different sizes must use different names — the generated map is per media, not per collection, so the first spec to run wins for that name. For the same reason a name that is not defined anywhere throws, and because the job resolves all the names before it generates anything, one bad name fails every conversion for that collection — including the ones that were fine — and burns all three attempts. Verify a new name in a queue worker's log before shipping it.
Nothing fingerprints a callback, so an edited one is not picked up on its own:
php artisan media:conversions # queue every convertible media
php artisan media:conversions --missing # only what is incomplete (or has no dimensions yet)
php artisan media:conversions --force # drop the derivatives first, so new specs are adopted
php artisan media:conversions --force --only=thumb,card
php artisan media:conversions --id=12 --id=13--only narrows what --force drops and what --missing counts as incomplete; the job itself always
fills in whatever else the media is missing. Dropped derivatives are deleted from the disk, so a spec
that now writes a different file extension leaves nothing behind.
The command regenerates the model's default conversions; a collection's extras come from the consuming model, so they are rebuilt the next time that collection is synced. Derivatives are deleted with the media — detaching a media from a record deletes nothing, because another record may rely on the same names.
Beyond the accepted types and the size cap, a library can validate every uploaded file:
MediaLibrary::make()->uploadRules(['dimensions:max_width=4000,max_height=4000']);The rules are sealed into the upload endpoint's context as JSON, so they travel as strings: pass
string rules or rule objects that stringify (Rule::dimensions()->maxWidth(4000)), never closures.
With signedUpload() the bytes go straight to S3 and the server only ever sees the object key, so
rules that need the file — dimensions above all — apply to multipart uploads only.
config/media.php covers the disk (media.disk), the upload size cap (media.max_size), the
accepted mime patterns (media.accepted_types, image/* wildcards included, empty accepts
everything), whether uploads go through signed URLs (media.signed_uploads), the media model
(media.model) and the queue the conversion job runs on (media.queue). The previewed conversion is
the model's previewConversion(), not config — see Conversions.
A single library overrides the config defaults per instance:
MediaLibrary::make()->signedUpload()->disk('s3')->accept('image/*');The components' strings ship with inline English defaults. With
bambamboole/laravel-i18next enabled, the plugin's
media namespace is loaded automatically and serves the bundled en/de translations (override
them like any Laravel package translation).