galleries, related to a Post. No upload array or JSON column is added to posts. The files live on a Laravel disk; SQL stores their disk-relative paths, original names, collection and order.
All files below are example application code. The complete maintained files also ship in resources/recipes/post-gallery inside the installed package. Their code blocks are generated from those same files, and the gallery mapping is exercised by the package tests. They are not migrations or models installed by FLEX. Use the examples in an existing authenticated Filament 5.6+ panel with FLEX registered. Adapt names instead of recreating tables your application already has.
Watch the save and reopen flow
Recording steps
Recording steps
This silent recording shows two independent galleries on a Post: upload main and
interior images, save the record, reload it, change its title and reload again.
The first save is around 14 seconds, the fresh reload around 18 seconds, and the
second edited reload around 26 seconds. The source files and related SQL rows
persist across those reloads.
Which parts do I need?
The basic flow is upload → save the post and gallery rows → reopen the saved record. A finished upload alone does not save a database relationship.
The main example uses public JPEG, PNG and WebP images, synchronous named conversions and two independent galleries. It enables the image editor only for main photos and uses
appendFiles() so ascending SQL order matches the visible order. It keeps removed physical files for a separate retention process. You can stop after step 6; the later sections are alternatives and optional additions.
For a reusable package-owned SQL mapper with stale-edit detection, see
the related gallery adapter. The complete application
mapper below remains an alternative; choose one synchronization implementation.
1. Create the tables
The example assumes the application already has Laravel’susers table. user_id supplies an explicit owner for the authorization examples.
database/migrations/2026_10_17_000001_create_posts_and_galleries.php
public disk here is a deliberate example choice, not an access-control mechanism.
2. Define both models
app/Models/Post.php
app/Models/Gallery.php
photos cast: neither model stores the field’s temporary path list as JSON. The relationship supplies post_id when a gallery row is created.
3. Authorize the post
app/Policies/PostPolicy.php
Gate::policy(Post::class, PostPolicy::class) in its provider. Add your own tenant and business authorization to this policy and the relation queries; do not trust a browser-supplied owner or tenant ID.
4. Generate the actual stored filename
This generator preserves a readable, sanitized stem and adds a UUID. The extension comes from the inspected MIME type, not the original extension. For example,Summer Deck.JPG becomes photo-summer-deck-<uuid>.jpg on the disk. The separate original_name column retains the upload’s display name.
app/Support/GalleryFileName.php
invoice-<uuid>.pdf. Return a basename only; configure folders with directory(). Do not return an absolute path, unsanitized user text or a repeatedly reused fixed name. Media OS refuses to overwrite an existing destination. This callback names the stored source; generated conversions have their own names and must be identified by conversion name, not by guessing a filename suffix.
5. Map the relationship explicitly
This small application-owned mapper has one job: restore and synchronize two known collections. It is not a newrelationship() API or an automatic package
migration. The page calls it with validated, stored field state inside the post’s
transaction. The page still authorizes the actor; the upload field still proves
existing paths. Other writers must use the same parent-lock convention.
It rejects duplicate paths, traversal, another collection’s files and a path
already referenced by another post. Removed rows are detached; bytes are retained
for an application-owned retention process. A unique path is not a substitute for
authorization, and a SQL transaction does not roll back disk/S3 writes.
app/Support/GalleryRows.php
Reuse the field configuration
Both the page below and a Resource can reuse this application helper. The main collection enables the image editor; the interior collection does not. ExplicitappendFiles() makes the displayed sequence match ascending SQL sort_order.
The source path stays unchanged when choosing a thumbnail or viewer conversion.
app/Support/GalleryFields.php
Build the create/edit page
This is a Filament panel page. Its optionalpost route parameter opens an existing record; omitting it creates a post. The ID is locked, and authorization runs again on every save and schema access. gallery_main and gallery_interior are virtual form fields, not posts columns.
app/Filament/Pages/PostGallery.php
getState() validates and stores temporary uploads. The SQL transaction saves only explicitly selected attributes and scoped gallery rows. completeMediaUploadsAfterSave() runs its reference checks and acceptance work after commit, using the mounted field instance. Keep FLEX’s own relationship hooks intact; do not replace them with saveRelationshipsUsing() to save these rows.
allowFilePathUsing authorizes existing paths during reads and validation. mediaReferenceUsing checks that a saved gallery row actually references the file. Neither callback creates that row. Browser state must never supply post_id, the storage disk, conversion paths or an arbitrary gallery model to update.
Removing a tile and saving removes its relation row. This example deliberately retains the underlying bytes, including conversions. Canceling or failing a form save must not destroy the previously saved gallery. Configure a separate, reference-aware retention workflow for physical deletion; SQL cascades alone do not delete disk objects. See removal and replacement.
6. Render and register the page
resources/views/filament/pages/post-gallery.blade.php
App\Filament\Pages\PostGallery. If your panel does not discover that directory, add it to the panel’s existing page list:
posts row contains title and user_id; each original file has its own galleries row. Selecting a preview conversion does not replace the original path stored in SQL.
7. Optional: generate conversions in a worker
The main recipe createsthumb and large synchronously. To move encoding out of the save request, add these options to galleryField() after imageConversions():
database queue with its Laravel job tables and retry_after greater than 60 seconds. Configure shared storage for the originals, staging, manifests and conversion journals if web and worker processes run on different machines. Then run:
8. Use images outside the form
Saved images can be displayed on your website or returned by your API without mounting File Upload. To display originals, read each gallery row’sdisk and path.
A separate SQL row per conversion is optional. Add it when you want your own database to answer queries for named versions such as thumb and large. Creating an API does not, by itself, require that extra table.
Follow Gallery images in your website and API for the optional conversion index, a complete Blade gallery, an authorized JSON endpoint and the simpler Spatie alternative. These are application examples; no changes to FLEX’s runtime are required.
9. Alternative: use Spatie collections
Use this alternative when Spatie should own media persistence. It writes to Spatie’smedia table and model relationship, rather than mirroring the same files into this recipe’s galleries and gallery_conversions tables.
Install the optional dependencies and publish their migrations using the Spatie setup guide. On the existing Post model, implement HasMedia, add InteractsWithMedia, and define collections/conversions:
Post:
galleries save loop from step 5. Do not add JSON columns for main_photos/interior_photos, and do not enable native disk imageConversions()/queueImageConversions() on the Spatie fields. Spatie owns its queued conversions, path generator and file naming configuration; keep those existing model/package settings. It stores each original as a media row and tracks its generated conversions as metadata on that row. It does not require an extra gallery_conversions row per version. Use its native methods to retrieve conversion URLs.
Outside the form, use the same authorized model and native Spatie APIs: