lattice-php / media
First-party media library for Lattice.
Requires
- php: ^8.4
- intervention/image: ^4.0
- lattice-php/lattice: >=0.36.0 <1.0.0
Requires (Dev)
- bambamboole/laravel-i18next: ^0.6.0
- larastan/larastan: ^3.0
- laravel/boost: ^2.4
- laravel/pint: ^1.16
- league/flysystem-aws-s3-v3: ^3.34
- mrpunyapal/peststan: ^0.2.12
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-browser: ^4.3
- pestphp/pest-plugin-laravel: ^4.0
This package is auto-updated.
Last update: 2026-08-03 10:31:12 UTC
README
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.
Installation
composer require lattice-php/media
Requires lattice-php/lattice >=0.29.0 <1.0.0.
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.
Usage
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.
Rich editor images
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.
Conversions
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.
Upload validation
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.
Configuration
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/*');
Translations
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).
Development
composer install && npm install composer check # pint --test, phpstan, rector --dry-run, pest (Unit + Feature) npm run typecheck && npm test composer test:browser # rebuilds the workbench bundle, then runs the Playwright suite composer serve # workbench demo app: /media (library) and /media-picker (field in a form)