Forms and files
Two small modules, both built around the same idea: keep the plugin or field doing what it already does, and add the minimum around it. Staff views are forms and file library.
app-modules/forms/ adds multi-site behaviour to
packstub/filament-form-builder. The package
brings the form builder resource, the form_builder_* tables, validation, spam checks, captcha,
emails, webhooks, its events and the <x-form-builder::form> renderer. It is configured in root
config/packstub-form-builder.php and registered in AdminPanelProvider with this module’s
resource:
FormBuilderPlugin::make() ->resource(FormResource::class) ->navigationGroup('Content Management'),What the module adds
Section titled “What the module adds”| Class | Role |
|---|---|
Models\FormSubmission |
The package’s submission plus site_id. Registered as models.submission in the config |
Filament\Resources\FormResource |
The package’s resource plus a Site contact section on the Notifications tab, and a Site column and filter on submissions |
Listeners\EmailSiteContact |
On SubmissionReceived, emails the primary contact of the submission’s site, when the form’s Email the site’s contact toggle is on |
Filament\SitePages\ManageSiteFormSubmissions |
The site’s Form Submissions page, added with SiteResource::registerRecordPage() |
ContentBlocks\FormBlock |
The only way a form reaches a page |
Policies\FormPolicy |
The forms.manage capability, for the package’s form model and our submission |
site_id comes from the host
Section titled “site_id comes from the host”Forms are global, shared across sites. A submission records where it came in:
static::creating(function (self $submission): void { $submission->site_id ??= app(SiteResolver::class)->forCurrentRequest()?->getKey();});The form posts back to the host that shows it, so the site is known server side, and a visitor cannot choose it. It is null for submissions made in code or on the console, and set to null if the site is deleted. Do not add a hidden site field to a form.
Dependencies point one way. Content-management knows nothing of forms: the module declares
Site::formSubmissions() itself with resolveRelationUsing, and adds its page through the public
registerRecordPage() hook.
Routes
Section titled “Routes”Config sets routes.prefix to _forms, and only the submit, validate and definition endpoints are
used. The underscore keeps them clear of CMS
slugs. The hosted page
(routes.page), the embed script and share links are off, as forms appear on pages only through the
Form block.
Pages must render with a session, since the submit route keeps the web middleware and CSRF.
Full-page caching would need the package’s session-less setup.
Spam protection
Section titled “Spam protection”| Layer | Setting |
|---|---|
| Captcha | FORM_BUILDER_CAPTCHA, with TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY |
| Honeypot | spam.honeypot (on) |
| Time trap | spam.min_seconds (2) |
| Single-use token | spam.token_ttl |
Cloudflare Turnstile is the provider in use, selected through the environment as the default for all forms. Verification is server side. The Turnstile widget must list every site’s hostname in Cloudflare, so adding a site means adding its hostname there, or the captcha will not work on that site. Cloudflare’s always-pass test keys work on a dev environment.
The default applies only to a form left on the default: each form’s spam settings have a captcha
select that can also pick none or another configured provider. And with FORM_BUILDER_CAPTCHA
empty, the default is no captcha at all, so check this in each environment’s configuration.
The block
Section titled “The block”FormBlock lets an editor pick an active form, an optional title and intro, background colour and
style (default, or panel, which boxes it). It renders forms::content-blocks.tailwind.form,
which a theme can override under views/vendor/forms/. The default theme maps the package’s
--fb-* variables to its own --cb-* ones in content-blocks.css, and overrides the paragraph
field at views/vendor/packstub-form-builder/fields/paragraph.blade.php. See adding a
block for the general pattern.
File library
Section titled “File library”app-modules/file-library/ is a shared library of editorial files that staff upload once and pick
into any upload field. Product photos are deliberately not in it.
Copy, not reference
Section titled “Copy, not reference”The defining decision: picking a file copies it into the field. The browser fetches the library file and adds it to the field’s FilePond uploader as if staff had dropped it there. From then on, the field does what it does for any upload: its own collection, block id, conversions and per-site handling.
- Nothing points back at the library. Replacing or deleting a library file never changes a page that used it.
- It works on create pages, before the record exists.
- No block or content type had to change to support it.
- The same image picked onto ten pages is stored ten times. That is accepted in exchange for the decoupling.
The browser does the copying because a server-side temporary upload put into a field’s state does
not show: Filament’s file-upload.js ignores server state containing livewire-file:. Filament’s
own image editor uses the same addFile route.
Models
Section titled “Models”| Model | Key points |
|---|---|
LibraryFolder |
A tree with one root, inserted by a migration (root()). Saving with no parent files under the root. The root can be renamed but not moved or deleted. Deleting another folder moves its contents up, so no file is lost |
LibraryFile |
library_folder_id, name, alt, last_used_at. The file is Spatie media in the single-file file collection |
LibraryFile::ACCEPTED_TYPES is raster images and PDFs, checked by the upload fields and again by
the collection against the file’s content. SVG and HTML are refused because uploads are served from
the panel’s own domain, where a script inside one would run as the admin who opened it. The stored
file name takes the extension of the detected type, so a PNG named .html is stored as .png.
How it reaches every field
Section titled “How it reaches every field”Nothing that declares an upload field knows about the library. FileLibraryServiceProvider::boot()
uses Filament’s global hooks, which apply to subclasses too:
FileUpload::configureUsing( fn (FileUpload $field) => $field->hintAction(ChooseFromLibraryAction::make()),);- “Choose from library” (
Filament\Support\ChooseFromLibraryAction) is a hint action on everyFileUpload, includingSpatieMediaLibraryFileUploadand Statik’s image fields. It hides itself on disabled fields, on fields accepting nothing the library holds, on the library’s own page, and on fields listed underexcludeinconfig/file-library.php. That list lives here so content-management and catalog never depend on this module. A click dispatcheslibrary-file-chosen, andresources/views/choose-from-library-script.blade.phpdoes the FilePond work. If the file has alt text and the field has an empty alt field beside it (alt_fieldsin the config), that is filled too. - “Insert from library” (
Filament\Support\LibraryRichContentPlugin) is a toolbar tool on rich editors that take attachments. It copies the file to the editor’s attachments disk and inserts an image, or a link for anything else. Most block text editors turn attachments off, so they do not get it.
Limits and permissions
Section titled “Limits and permissions”The picker loads every matching file when it opens and filters in the browser, which suits hundreds to a few thousand files. Beyond that it would need server-side paging.
files.manage gates the library itself (Content Management > Media Library,
LibraryFileResource). Picking needs nothing beyond access to the field. See
authorization.
Related media behaviour
Section titled “Related media behaviour”These are not part of the module but affect every upload:
Filament\Support\MediaUploadDisk(content-management) gives everySpatieMediaLibraryFileUploada default disk: its collection’s own, elsemedia-library.disk_name. A field setting its own disk keeps it.Support\Listeners\SanitizeUploadedSvgcleans every SVG added as media on any model, when stored, because SVGs are served as uploaded from the site’s own domain.HasScopedMedialets a registered type keep per-site versions of a media item, marked incustom_properties->site_id. Reads prefer the site’s and fall back to the global one.