Every app accumulates values that don't belong in config/ or .env: the from-address on transactional mail, a support email, the company name on invoices, a maintenance toggle. Hard-code them and every change is a deploy; bolt on a loose key/value table and you lose types, defaults, and validation. Filament v4's SettingsPage, backed by spatie/laravel-settings, gives you a typed settings class, a database store, and a form that fills and saves itself.
This walkthrough builds a Filament v4 settings page end to end: install the plugin, define a settings class and its migration, scaffold the page, wire the form, and gate access. If you don't have a panel running yet, the Filament v4 zero-to-production dashboard guide covers that groundwork first.
Install the settings plugin and package#
Install spatie/laravel-settings first — that's the store that persists strongly typed settings to the database — then publish and run its migrations so the settings table exists. Once the store is in place, add the Filament plugin that renders a settings class as an admin page. The -W flag lets Composer update shared dependencies as needed.
# The underlying store: strongly typed settings persisted to the database
composer require spatie/laravel-settings
# Publish and run the package's own migrations (creates the `settings` table)
php artisan vendor:publish --provider="Spatie\LaravelSettings\LaravelSettingsServiceProvider" --tag="migrations"
php artisan migrate
# The Filament v4 plugin that turns a settings class into an admin page
composer require filament/spatie-laravel-settings-plugin:"^4.0" -W
There's no panel plugin to register. The package registers the make:filament-settings-page command, and any page you generate lands in app/Filament/Pages, which the panel already auto-discovers. If you've seen older guides adding a ->plugin() call to the panel provider for this, skip it — it isn't needed in v4.
Create a typed settings class and its migration#
A settings class is a plain PHP class extending Spatie\LaravelSettings\Settings with public, typed properties and a static group() that names the bucket those properties live in. Create it under app/Settings. The types are the point: bool stays a bool coming back out of the database, not the string "1".
<?php
namespace App\Settings;
use Spatie\LaravelSettings\Settings;
class GeneralSettings extends Settings
{
public string $site_name;
public string $support_email;
public bool $site_active;
// The group ties these properties together in the settings table
public static function group(): string
{
return 'general';
}
}
Every property needs a default, and defaults live in a settings migration — not a normal schema migration. Generate one and it lands in database/settings:
php artisan make:settings-migration CreateGeneralSettings
<?php
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
// Every property on the class needs a default here or it won't hydrate
$this->migrator->add('general.site_name', 'RichDynamix');
$this->migrator->add('general.support_email', 'hello@richdynamix.com');
$this->migrator->add('general.site_active', true);
}
}; // <-- keep this semicolon; the Spatie docs snippet drops it
Run php artisan migrate to seed the defaults. Two things trip people up here. First, classes in app/Settings are auto-discovered, so you don't need to touch config/settings.php — but if you store settings elsewhere or turn auto-discovery off, add the class to the settings array yourself. Second, the migration is not optional: a public property with no migrated value throws a "missing settings" error the moment the class loads.
Generate the Filament v4 settings page#
With the class and its defaults in place, scaffold the page. The first argument is the page class name; the second is the settings class it manages. Filament writes the page to app/Filament/Pages, wires up the $settings property for you, and the panel picks it up automatically on the next request.
php artisan make:filament-settings-page ManageGeneralSettings GeneralSettings
The generated class is deliberately thin — a navigation icon, the $settings binding, and an empty form():
<?php
namespace App\Filament\Pages;
use App\Settings\GeneralSettings;
use BackedEnum;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
class ManageGeneralSettings extends SettingsPage
{
protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedCog6Tooth;
protected static string $settings = GeneralSettings::class;
public function form(Schema $schema): Schema
{
return $schema
->components([
//
]);
}
}
Two v4-isms to notice. $navigationIcon is typed string | BackedEnum | null and defaults to the Heroicon enum rather than a string like 'heroicon-o-cog-6-tooth'. And form() receives a Filament\Schemas\Schema, not the v3 Filament\Forms\Form. If you paste a v3 method with a Form type-hint here, Filament silently skips it and you get a blank page — no error, just an empty form.
Build the settings page form to match your properties#
Now fill the components() array. This is the whole trick, and it's smaller than it looks: each field's make() name must be identical to a public property on the settings class. Filament reads the current values out of the database when the page mounts and writes them back on save — you don't write a mount() method, a save handler, or any binding glue.
use App\Settings\GeneralSettings;
use BackedEnum;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
class ManageGeneralSettings extends SettingsPage
{
protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedCog6Tooth;
protected static string $settings = GeneralSettings::class;
public function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('site_name') // matches $site_name
->label('Site name')
->required(),
TextInput::make('support_email') // matches $support_email
->label('Support email')
->email()
->required(),
Toggle::make('site_active') // matches $site_active
->label('Site is live'),
]);
}
}
Real settings aren't always flat scalars. If a property is an array — say array $footer_links — reach for a Repeater and the field still binds by name, the same pattern covered in nested form data with the Filament repeater. And when one control should reveal another — show a maintenance message field only when the site is switched off — make the field reactive, exactly how dependent, reactive fields behave elsewhere in a panel.
Don't cram everything into one class either. Keep GeneralSettings lean and give mail, branding, and feature flags their own classes, migrations, and pages — a ManageBrandingSettings page pairs naturally with the white-label theming setup. Separate groups mean smaller forms, tighter validation, and access rules you can set per page.
Restrict access with canAccess()#
Out of the box, every panel user can open a settings page. Global settings usually shouldn't be world-writable, so gate the page. canAccess() decides whether the page appears in navigation and whether the route resolves at all; canEdit() leaves the page visible but makes the form read-only.
// Static: Filament resolves this before building navigation.
// Blocks the page from the menu and returns 403 on direct access.
public static function canAccess(): bool
{
return auth()->user()?->can('manage-settings') ?? false;
}
// Instance method (NOT static) — the base class declares it non-static.
// Lets a user view current values but not change them.
public function canEdit(): bool
{
return auth()->user()?->can('update-settings') ?? false;
}
Copy those signatures exactly: canAccess() is static, canEdit() is not, and marking canEdit() static throws a fatal error. The bigger trap is treating canEdit() as security. It only disables the form and hides the save button — the page still loads and every field's current value is right there to read. For anything sensitive, like API keys or internal feature flags, gate viewing with canAccess() (and route middleware if you want belt and braces), never canEdit() alone. In a multi-tenant panel you'll usually scope access to the current tenant too; the team panel multi-tenancy guide covers that boundary.
Wrapping Up#
You've now got a typed, database-backed settings page that fills and saves itself, split by group and gated by policy — editable config without a deploy. The obvious next move is richer inputs: a FileUpload for a logo, a color picker for brand colors, or a reusable field you drop into several pages, which the custom form field component guide walks through. And if the panel itself is still coming together, start with the zero-to-production dashboard guide and layer settings on top.
FAQ#
How do I create a settings page in Filament v4?
Install spatie/laravel-settings and filament/spatie-laravel-settings-plugin, create a typed settings class in app/Settings with a migration that sets each property's default, then run php artisan make:filament-settings-page ManageGeneralSettings GeneralSettings. Filament generates a page extending SettingsPage in app/Filament/Pages, and the panel discovers it automatically. You only fill in the form() schema and run your migrations.
What package does Filament use for settings storage?
Filament's settings pages are built on Spatie's spatie/laravel-settings, which persists strongly typed settings to a database table. The official filament/spatie-laravel-settings-plugin is a thin bridge that turns one of those settings classes into an admin page with an auto-bound form. Filament doesn't ship its own settings store — it leans on Spatie's, so anything you can do with the package works underneath the page.
How do I bind form fields to a settings class?
You don't bind them manually — you name them to match. Each form component's make() argument must be identical to a public property on the settings class, so TextInput::make('support_email') maps to public string $support_email. Filament hydrates the form from the database when the page loads and writes the values back on save, with no mount() method or save handler of your own.
How do I restrict who can access a Filament settings page?
Add a static canAccess() method to the page that returns true only for authorised users, for example return auth()->user()?->can('manage-settings') ?? false;. That hides the page from navigation and returns a 403 on direct access. If you want some users to view but not change settings, add a non-static canEdit() method — but remember it only disables saving, so it is not a substitute for canAccess() when the values are sensitive.
How do I add a new setting to an existing settings page?
Add the public typed property to the settings class, generate a new settings migration with php artisan make:settings-migration, and register the default with $this->migrator->add('general.new_property', $default). Run php artisan migrate, then add a matching form field to the page. The migration is mandatory: a property with no migrated value throws a missing-settings error the next time the class loads.