Skip to main content
This recipe stores one row per original file in 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

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.
This recording runs the recipe’s actual page against synthetic SQLite records and a local public disk. Three original files become three related rows. It shows upload, save, a fresh reload, editing and another reload. It does not demonstrate S3, authentication setup, queued processing or every failure path.

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’s users table. user_id supplies an explicit owner for the authorization examples.
database/migrations/2026_10_17_000001_create_posts_and_galleries.php
Use private storage and authorized delivery instead when attachments must not be publicly accessible. The 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
There is no 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
Laravel discovers this conventional policy. If your application disables discovery, register 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
Use trusted application data for a different naming convention, such as 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 new relationship() 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. Explicit appendFiles() 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 optional post 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
Default Filament page discovery finds App\Filament\Pages\PostGallery. If your panel does not discover that directory, add it to the panel’s existing page list:
Generate links through Filament so panel prefixes and domains remain correct:
Save a post, follow its edit URL, reload, and check both galleries, original filenames, order, thumbnails and the viewer. The 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 creates thumb and large synchronously. To move encoding out of the save request, add these options to galleryField() after imageConversions():
Use an existing durable 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:
FLEX dispatches conversions only after the saved relation references the original and the transaction commits. While conversion work is pending, the original remains available as the preview fallback. Failed work retains the original. The original SQL path remains stable. See worker configuration and recovery.

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’s disk 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’s media 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:
In a normal Filament Resource form attached to that authorized Post:
Use native Resource create/edit relationship persistence for these fields. Do not reuse the custom 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:
For private Spatie files use its authorized temporary delivery configuration. See Spatie conversions, queue and storage configuration. For complete website and JSON examples with Spatie, see Use Spatie outside the form. No additional conversion index is needed.
Last modified on October 20, 2026