Skip to content

Settings Schema Registry

The Settings Schema Registry is a runtime registry of settings form-builder. The admin Settings page renders first-party Capell settings groups (core, admin, and frontend) as tabs when they are registered. Marketplace and third-party package settings should be exposed through package-owned Filament pages that reuse the same registry.

  1. SettingsSchemaRegistry (Capell\Core\Support\Settings\SettingsSchemaRegistry)

    • Central registry maintaining all settings schemas
    • Organizes schemas by group (e.g., ‘core’, ‘admin’, ‘frontend’)
    • Supports multiple schemas per group (composable)
    • Stores optional metadata for package-owned settings pages
  2. SettingsSchemaBootstrapper (Capell\Core\Support\Settings\SettingsSchemaBootstrapper)

    • Manages extension callbacks
    • Executes after package registration
    • Allows dynamic schema modifications
  3. HasSchema Contract (Capell\Admin\Filament\Contracts\HasSchema)

    • Interface all settings schemas must implement
    • Defines make(Schema $schema): array method

In your package’s service provider:

use Capell\YourPackage\Filament\Settings\YourSettingsSchema;
use Capell\YourPackage\Settings\YourSettings;
use Capell\Core\Support\Settings\SettingsGroupMetadata;
use Capell\Core\Support\Settings\SettingsSchemaRegistry;
use Filament\Support\Icons\Heroicon;
private function registerSettingsSchemas(): self
{
$registry = resolve(SettingsSchemaRegistry::class);
// Register the settings class (for form hydration/saving)
$registry->registerSettingsClass('yourgroup', YourSettings::class);
$registry->registerMetadata(new SettingsGroupMetadata(
group: 'yourgroup',
label: 'your-package::settings.title',
icon: Heroicon::OutlinedCog6Tooth,
navigationGroup: 'capell-admin::navigation.group_system',
packageName: 'capell-app/your-package',
));
// Register the schema class (for form building)
$registry->register('yourgroup', YourSettingsSchema::class);
return $this;
}

Package settings pages should extend AbstractPackageSettingsPage:

<?php
declare(strict_types=1);
namespace Capell\YourPackage\Filament\Pages;
use Capell\Admin\Filament\Pages\AbstractPackageSettingsPage;
class YourPackageSettingsPage extends AbstractPackageSettingsPage
{
protected static string $settingsGroup = 'yourgroup';
protected static ?string $slug = 'your-package/settings';
}

Register the page from your provider:

CapellAdmin::registerExtensionPage('capell-app/your-package', YourPackageSettingsPage::class);

All settings schemas must implement the HasSchema contract:

<?php
declare(strict_types=1);
namespace Capell\YourPackage\Filament\Settings;
use Capell\Admin\Filament\Contracts\HasSchema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Checkbox;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class YourSettingsSchema implements HasSchema
{
public static function make(Schema $schema): array
{
return [
Section::make(__('your-package::settings.general'))
->columnSpanFull()
->schema([
TextInput::make('api_key')
->label(__('your-package::settings.api_key'))
->required(),
Checkbox::make('enabled')
->label(__('your-package::settings.enabled'))
->default(true),
])
->columns(2),
];
}
}

Settings schemas should return top-level Section components, not bare fields or bare Grid components. A section gives labels, helper text, toggles, and inputs the Filament card background they need in both light and dark mode. Never leave labels and inputs floating directly on the page background, and do not call contained(false) on a section that contains normal form fields unless the section is immediately wrapped by another contained panel.

Use Section::make()->columns() for simple responsive layout inside the panel. Use a nested Grid only when it makes the schema clearer, and keep that grid inside a contained section.

Good:

Section::make(__('your-package::settings.display'))
->columnSpanFull()
->schema([
TextInput::make('items_per_page')
->label(__('your-package::settings.items_per_page'))
->numeric(),
])
->columns(2);

Avoid:

Grid::make(2)
->schema([
TextInput::make('items_per_page'),
]);
Section::make(__('your-package::settings.display'))
->contained(false)
->schema([
TextInput::make('items_per_page'),
]);

Your settings class should extend Spatie\LaravelSettings\Settings:

<?php
declare(strict_types=1);
namespace Capell\YourPackage\Settings;
use Capell\Core\Contracts\SettingsContract;
use Capell\YourPackage\Filament\Settings\YourSettingsSchema;
use Spatie\LaravelSettings\Settings;
class YourSettings extends Settings implements SettingsContract
{
public string $api_key;
public bool $enabled;
public static function group(): string
{
return 'yourgroup';
}
public static function schema(): string
{
return YourSettingsSchema::class;
}
}

You can register multiple schemas for the same group. They will be merged together:

$registry = resolve(SettingsSchemaRegistry::class);
// Core schema
$registry->register('admin', AdminCoreSchema::class, 'core');
// Additional schema from a plugin
$registry->register('admin', AdminExtendedSchema::class, 'extended');

Both schemas will appear on the package-owned settings page for that group.

To override a schema registered by another package:

$registry = resolve(SettingsSchemaRegistry::class);
// Replace the core admin schema with a custom one
$registry->replace('admin', CustomAdminSchema::class, 'AdminSettingsSchema');

To remove a schema entirely:

$registry = resolve(SettingsSchemaRegistry::class);
// Remove a specific schema
$registry->remove('admin', 'AdminSettingsSchema');
// Remove all schemas from a group
$registry->removeGroup('admin');

Use the bootstrapper to register schemas after all packages have loaded:

use Capell\Core\Support\Settings\SettingsSchemaBootstrapper;
use Capell\Core\Support\Settings\SettingsSchemaRegistry;
// In your service provider's boot method
resolve(SettingsSchemaBootstrapper::class)->extend(function (SettingsSchemaRegistry $registry): void {
// This runs after all packages are registered
$registry->register('admin', MyDynamicSchema::class);
});

register(string $group, string $schemaClass, ?string $key = null): void

Section titled “register(string $group, string $schemaClass, ?string $key = null): void”

Register a new schema for a group.

Parameters:

  • $group - Settings group identifier (e.g., ‘core’, ‘admin’, ‘frontend’)
  • $schemaClass - Fully qualified class name implementing HasSchema
  • $key - Optional unique identifier (defaults to class basename)

Example:

$registry->register('admin', AdminSettingsSchema::class);
$registry->register('admin', CustomSchema::class, 'my_custom');

registerSettingsClass(string $group, string $settingsClass): void

Section titled “registerSettingsClass(string $group, string $settingsClass): void”

Register the primary settings class for a group.

Parameters:

  • $group - Settings group identifier
  • $settingsClass - Fully qualified settings class name

Example:

$registry->registerSettingsClass('admin', AdminSettings::class);

replace(string $group, string $schemaClass, string $key): void

Section titled “replace(string $group, string $schemaClass, string $key): void”

Replace an existing schema in a group.

Parameters:

  • $group - Settings group identifier
  • $schemaClass - New schema class
  • $key - Key of the schema to replace (must exist)

Throws: InvalidArgumentException if key doesn’t exist

Example:

$registry->replace('admin', NewAdminSchema::class, 'AdminSettingsSchema');

Remove a specific schema from a group.

Parameters:

  • $group - Settings group identifier
  • $key - Schema key to remove

Example:

$registry->remove('admin', 'AdminSettingsSchema');

Remove all schemas from a group.

Parameters:

  • $group - Settings group identifier

Example:

$registry->removeGroup('admin');

Get all schema classes for a group.

Parameters:

  • $group - Settings group identifier

Returns: array<string, class-string<HasSchema>>

Example:

$schemas = $registry->getSchemas('admin');
foreach ($schemas as $key => $schemaClass) {
// Process each schema
}

getSchema(string $group, string $key): ?string

Section titled “getSchema(string $group, string $key): ?string”

Get a specific schema by group and key.

Parameters:

  • $group - Settings group identifier
  • $key - Schema key

Returns: class-string<HasSchema>|null

Example:

$schema = $registry->getSchema('admin', 'AdminSettingsSchema');

Get the primary settings class for a group.

Parameters:

  • $group - Settings group identifier

Returns: class-string|null

Example:

$settingsClass = $registry->getSettingsClass('admin');

Get all registered group names.

Returns: array<string>

Example:

$groups = $registry->getGroups();
// ['core', 'admin', 'frontend', 'ai-orchestrator']

Check if a group has any schemas registered.

Parameters:

  • $group - Settings group identifier

Returns: bool

Example:

if ($registry->hasGroup('admin')) {
// Admin group exists
}

Get all registered schemas across all groups.

Returns: array<string, array<string, class-string<HasSchema>>>

Example:

$allSchemas = $registry->all();
// [
// 'core' => ['CoreSettingsSchema' => CoreSettingsSchema::class],
// 'admin' => ['AdminSettingsSchema' => AdminSettingsSchema::class],
// ]

The Core package registers the registry and bootstrapper:

// In CapellServiceProvider::packageRegistered()
private function registerSettingsSchemaRegistry(): self
{
$this->app->singleton(
SettingsSchemaRegistry::class,
fn (): SettingsSchemaRegistry => new SettingsSchemaRegistry(),
);
$this->app->singleton(
SettingsSchemaBootstrapper::class,
fn (): SettingsSchemaBootstrapper => new SettingsSchemaBootstrapper(
resolve(SettingsSchemaRegistry::class),
),
);
return $this;
}

The Admin package registers core and admin schemas:

// In AdminServiceProvider::bootInstalledPackage()
private function registerSettingsSchemas(): self
{
$registry = resolve(SettingsSchemaRegistry::class);
$registry->registerSettingsClass('core', CoreSettings::class);
$registry->register('core', CoreSettingsSchema::class);
$registry->registerSettingsClass('admin', AdminSettings::class);
$registry->register('admin', AdminSettingsSchema::class);
return $this;
}

The Frontend package registers its own schemas:

// In FrontendServiceProvider::bootInstalledPackage()
private function registerSettingsSchemas(): self
{
$registry = resolve(SettingsSchemaRegistry::class);
$registry->registerSettingsClass('frontend', FrontendSettings::class);
$registry->register('frontend', FrontendSettingsSchema::class);
return $this;
}

Your plugin can add its own settings or extend existing ones:

// In YourPluginServiceProvider::bootInstalledPackage()
private function registerSettingsSchemas(): self
{
$registry = resolve(SettingsSchemaRegistry::class);
// Add your own settings group
$registry->registerSettingsClass('myplugin', MyPluginSettings::class);
$registry->register('myplugin', MyPluginSettingsSchema::class);
// Or extend an existing group
$registry->register('admin', MyPluginAdminExtensionSchema::class, 'myplugin_admin');
return $this;
}

First-party settings groups use the registry through the admin SettingsPage tabs. Package settings pages use the same registry through AbstractPackageSettingsPage:

  1. Page Ownership - The package registers a concrete page with CapellAdmin::registerExtensionPage()
  2. Icon & Label - Derived from package metadata when available
  3. Schema Composition - All schemas for the page’s group are merged together
  4. Form Hydration - The registered settings class populates form values
  5. Saving - Data is saved to the registered settings class for that group

Configurators are package-owned classes that build Filament schemas for configurable admin surfaces such as page types, widget types, layout containers, sites, languages, and themes. They implement Capell\Admin\Contracts\ConfiguratorInterface, expose a stable getKey(), provide a getSort() order, and return a configured Schema from configure(Schema $schema, ?ConfiguratorContextData $context = null).

Use a configurator when the extension point is an existing admin editing surface. Use a settings schema when the package owns saved package configuration. Keep both focused on assembling Filament components; move business operations into Actions and keep user-facing text in translation files.

Configurator schemas follow the same field presentation rules as settings schemas: fields belong in contained Section components. contained(false) is only appropriate for chrome-free display inside another panel, not for standalone labels, helper text, or inputs.

use Capell\Core\Support\Settings\SettingsSchemaRegistry;
it('registers a schema for a group')
->tap(function (): void {
$registry = new SettingsSchemaRegistry();
$registry->register('admin', MockAdminSchema::class);
expect($registry->hasGroup('admin'))->toBeTrue();
});
use Capell\Core\Support\Settings\SettingsSchemaRegistry;
use Capell\YourPackage\Filament\Pages\YourPackageSettingsPage;
it('displays settings schema in package settings page')
->actingAs($this->createAdmin())
->get(YourPackageSettingsPage::getUrl())
->assertSuccessful();
it('registers schema in registry')
->tap(function (): void {
$registry = resolve(SettingsSchemaRegistry::class);
expect($registry->hasGroup('yourgroup'))->toBeTrue();
expect($registry->getSettingsClass('yourgroup'))
->toBe(YourSettings::class);
});

Choose clear, unique group names:

  • 'yourpackage', 'ai-orchestrator', 'ecommerce'
  • 'settings', 'config', 'options'

Always register schemas in bootInstalledPackage() or similar boot methods, not in register().

Each group should have exactly one settings class (for form hydration/saving), but can have multiple schema classes (for form building).

When registering schemas that others might want to replace/remove, use explicit keys:

$registry->register('admin', ImportantSchema::class, 'important_feature');

If your package allows schema extensions, document which groups and keys are available for replacement.

The registry validates that all schemas implement HasSchema. Ensure your schemas are properly typed:

class MySchema implements HasSchema
{
public static function make(Schema $schema): array
{
return [
// Form components
];
}
}

Problem: Your schema is registered but doesn’t appear in the package settings page.

Solutions:

  1. Ensure the group name matches exactly
  2. Check that registerSettingsClass() was called
  3. Verify the schema class implements HasSchema
  4. Clear cache: php artisan cache:clear

Problem: Exception thrown when registering or replacing schemas.

Solutions:

  1. Check that the schema class exists and is autoloaded
  2. Verify the schema implements HasSchema
  3. For replace(), ensure the key exists before replacing

Problem: Changes in settings page don’t persist.

Solutions:

  1. Ensure registerSettingsClass() was called for the group
  2. Check that the settings class extends Spatie\LaravelSettings\Settings
  3. Verify database migrations have run for the settings table
  4. Check group() method returns the correct group name

The registry is instantiated fresh on each request - no caching is needed for package settings page use cases.

Schemas are loaded and merged when building the form. For many schemas (>20), consider:

  1. Lazy loading schema components
  2. Conditionally hiding schemas based on package installation status

Extension callbacks via the bootstrapper run on every request. Keep them lightweight:

  • ✅ Simple schema registration
  • ❌ Heavy computation or database queries