Skip to main content
Use HasManyMediaCollection when a post, product or another model owns an existing hasMany gallery: one SQL row per original file. No upload JSON attribute is added to the parent, and no package-owned media model or migration is installed. The adapter handles scoped reading, synchronization, order, original names, duplicate-reference checks and stale edits. Your application supplies its model, disk, directory and authorization. It does not upload files, convert images, accept staged uploads or delete physical files. For the complete first-time table, model and field setup, start with the Post gallery recipe. This page replaces its application-owned row synchronization with the package adapter. The original recipe remains available for applications that deliberately own their mapper.

1. Keep a unique reference to each original

Use the recipe’s posts and galleries tables and models. Add a unique index on the original’s disk and path:
The adapter checks for competing references; the database index closes the race between writers attaching the same object to different parents. Resolve existing duplicates before applying this migration. If your application intentionally shares one original between several gallery rows, keep a reference-aware custom mapper instead of using this exclusive-ownership adapter. Do not make sort_order unique: reordering updates rows inside one transaction. The parent and gallery must use the same database connection. The initial adapter supports ordinary hasMany relations without soft deletes, joins, grouping, limits or pagination. Mapping column names must be distinct simple SQL identifiers and cannot replace primary keys, foreign keys or timestamps.

2. Define your collection once

This factory uses the recipe’s existing Post policy. Both reading and saving the editor require update permission. Adapt the callback if your application has different view/edit rules.
app/Support/PostMediaCollections.php
admittedNewPaths must come from the mounted upload field’s validated, stored getState() result, with its existing-path protection intact. Do not pass raw request paths or merely validate that a string starts with a directory. For a server-only workflow, admit paths from receipts your authorized service actually created, after checking their owner and destination. Directory membership and physical file existence are additional checks; neither proves who uploaded a new file. The adapter calls your admission callback even when a submitted path already matches the saved state. The callback receives the existing related row only when it already belongs to the same parent, collection and disk. The adapter also rejects a disk/path claimed outside that scoped set, including references hidden by related-model scopes. It never transfers that other row or removes its file.

Use different existing column names

The adapter sets the parent foreign key through the relationship. The browser never supplies that key, a row ID, disk, collection or conversion path. A path is limited to 512 bytes; original names accept bounded UTF-8 text and are shortened to 255 characters. The configured limit is between 1 and 1000 originals.

3. Read paths, names and a revision

Keep the revision captured when the form opens. On a Livewire page, protect the revision map from client changes:
Hydrate main and interior separately. The revision identifies the scoped rows, their order and original names. It is a conflict detector, not authorization or a file receipt. Reads always query the authorized parent and related rows; they do not depend on a previously loaded $post->galleries collection. For the recipe’s existing-file/reference callbacks, you can use:
Keep preventFilePathTampering() and mediaReferenceUsing() on the upload field. They still protect field reads and commit-aware upload acceptance. Keep the recipe’s retention settings when detached files should remain on disk.

4. Save references inside the parent’s transaction

The paths below are the stored field state, after form validation and upload storage. They are not TemporaryUploadedFile objects.
Use the parent’s actual connection if it is not the application’s default: $post->getConnection()->transaction(...). Never catch a conflict inside the parent transaction and then commit a partly updated parent. Let it abort that save and ask the user to reload the gallery. For a newly created parent, authorize creation and persist the parent inside its transaction first. Obtain its empty collection revision with read($post), then call sync() and attach the new model with $this->form->model($post) before completing its mounted upload fields. There is no parent ID to trust from the browser. An edit must use the revision captured when the form opened; reading a new revision immediately before an edit save would bypass stale-edit detection. The adapter locks and reauthorizes the current parent before writing. On MySQL, it also uses current locking reads for the gallery rows, so an earlier REPEATABLE READ snapshot cannot hide a competing committed edit. All other gallery writers must follow the same parent-lock convention. Keep the unique disk/path index even when they do.

Native Filament Resource pages

Use the same read() in mutateFormDataBeforeFill() and the same sync() inside handleRecordUpdate() or handleRecordCreation(), with $hasDatabaseTransactions = true. Preserve existing Resource authorization. Do not add the manual completion call above: native RecordSaved handling already accepts referenced uploads after commit. If a Resource updates its revision map before its outer save transaction ends, schedule that map update through $post->getConnection()->afterCommit(...). Do not leave uncommitted revisions in component state after a rolled-back save. See the complete Resource example for native page hooks and field configuration.

Conflicts, retries and removal

Model observers and custom callbacks are application code; keep destructive file operations out of pre-commit model events. SQL and storage are separate systems. A SQL cascade does not delete files, and a database rollback does not roll back earlier upload or conversion work.

Conversions and Spatie

Named native conversions remain in their existing server-owned receipts and conversion journals. This adapter stores one row per original, rather than guessing variant filenames or accepting derivative paths from the browser. For an optional SQL conversion index and website/API examples, see Gallery images in your website and API. For Spatie Media Library, keep its own media model, collections, conversions and FLEX Spatie field. Do not mirror the same attachment into this adapter’s rows. Spatie’s polymorphic media relationship is outside this initial hasMany contract. See Spatie media.

Map file metadata explicitly

Add a nullable JSON metadata column to your existing galleries table and an Eloquent 'metadata' => 'array' cast to Gallery. This stores a file’s captions or alt text on its gallery row; the form’s file list still uses separate relational records.
Pass application-validated metadata keyed by the admitted original path as the last argument to sync():
Omitting a path’s metadata preserves its current value. An explicit [$path => []] clears it. Metadata participates in conflict detection, so an old form cannot silently overwrite a newer caption. Accept only the keys and locales your application permits; the adapter’s JSON bounds are not a substitute for validation. Each entry accepts JSON values only, at most 16 KiB, 512 nested entries and eight levels of nesting. Never put access tokens or file authority in metadata.

Project ready conversions into SQL

Use HasManyMediaConversions when your website or API needs to query named versions in a separate table. It indexes existing server-owned receipts; it neither converts an image nor deletes stored bytes. With Spatie, use Spatie’s own conversion APIs instead of this projection. The original row needs its existing disk and path columns and a simple conversions(): HasMany relationship on the same database connection. Create the application’s conversion table explicitly:
On Gallery, return $this->hasMany(GalleryConversion::class) from conversions(). GalleryConversion is your normal Eloquent model. Do not use soft deletes or model observers that delete disk objects for this index. For a synchronous ImageBundle returned by the image pipeline, configure the projector using that exact server-owned plan:
The authority callback must match every receipt to the current trusted plan and supply the appropriate MediaWrite policy. A directory prefix, readable path or receipt from browser state is insufficient. FLEX verifies actual stored bytes through Media OS before changing any conversion rows. The original receipt must match the freshly loaded gallery row. Use MediaConversionColumns for explicit alternative column names. sourceDiskColumn and sourcePathColumn map the original row, and maxVariants limits the complete projection (default 32, maximum 100). For queued work, first obtain an authorized QueuedImageConversions::snapshot(). Only project when its status is ready; pending or failed work must retain the previous SQL index. Build authority from that trusted journal’s exact original and named receipts, using the matching application policy for each output disk. Do not reuse the original’s MIME allow-list for WebP/JPEG derivatives. See the queued conversion example. sync() replaces the complete ready index. Passing [] explicitly removes its rows. A repeated identical request preserves row IDs; a stale different request raises relation_conflict. Reload and review the current index before retrying a conflict. Database failures roll back the index, including a model event veto. The parent save and projection require an outer transaction. External side effects in your own observers remain your application’s responsibility.
Last modified on October 20, 2026