Add Settings Screens to the Admin

Every screen under Settings is described in PHP and rendered by one generic view. You add a screen (or a tab on an existing one) by describing sections, pages and fields — no controller, route or view needed.

The model: section → page → field

SettingsManager::add([...])                       -> one SECTION  = one URL  /admin/settings/{id}
   'pages' => [ [ 'id','name','title','fields'=>[...] ] ]   -> PAGES = tabs of that screen
        'fields' => [ ['type'=>'text', 'name'=>..., ...] ]  -> FIELDS = inputs (storage decided by 'group')
Saved by Admin\SettingsController::update():  validate all rules of the section, then write each field
to the backend chosen by its group, then flush the response cache.

Registering a section

Modules/Testimonials/app/Helpers/TestimonialsSettings.php (excerpt)

SettingsManager::add([
    'id' => 'testimonials',                                  // URL: /admin/settings/testimonials
    'title' => __('testimonials::testimonials.settings.title'),
    'link' => [                                              // sidebar entry
        'id' => 'settings',                                  // parent group (the "Settings" entry)
        'sub_menu' => [
            'id' => 'testimonials-settings',
            'label' => __('testimonials::testimonials.settings.title'),
            'type' => 'testimonials',                        // = the section id
            'priority' => 40,
        ],
    ],
    'pages' => fn (): array => [self::page()],              // a closure = built lazily
]);
KeyMeaning
idSection id and URL segment. Re-using an existing id appends your pages to that section: a plugin can add a tab to General, Mail or Theme options without touching core
titleHeading of the left tab column
linkSidebar entry. With sub_menu it becomes a child of the group link.id (created if missing). Without it use link: ['id' => 'my-group', 'label' => ..., 'icon' => ..., 'type' => 'section-id'] or 'route' + 'params' for a custom target. Links are only added for authenticated users
pagesArray of page definitions or a closure returning one. The closure form defers building every field (some contain database queries) until the screen is opened — the single most important performance habit (core measured ~30 ms of an ~80 ms admin request)

Page keys

KeyMeaning
idDOM id of the tab pane (unique within the screen)
nameTab label (a left-hand tab appears when the section has two or more pages)
titleCard header text
descriptionOptional description
orderSort position (default: registration order × 10)
fieldsArray of field definitions

Fields

Keys common to every field

KeyTypeMeaning
typestringOne of the types below (required, otherwise the field is silently dropped and the problem is logged)
idstringHTML id and error key. Required
namestringThe request input name and the settings key (unless map is set). Use snake_case prefixed with your alias. Must be set (required)
labelstringLabel (passed through trans())
helpTooltip / helpstringTooltip beside the label / small text below the input (help may contain HTML)
groupstringStorage backend: settings (default, database), env, uploads, theme, static (display only)
mapstringFor env fields, the .env key to write (e.g. MAIL_HOST); for settings fields, a different storage key than name
value / selectedmixedCurrent value shown. Text-like fields use value; select, image-select and font use selected
validationarray|stringLaravel rules. The rules of all pages of a section are validated together on save
itemValidationarray|string(select, multiple) rules applied to each selected value
condition, conditionValuestring, mixedShow this field only while another field's value matches (see below)
conditionsarray[['name' => 'field', 'value' => 'x'], ...] — show only when all match
required, disabled, readonly, placeholder, class, order—Plain HTML attribute helpers

The 18 field types

typeClassExtra keysStored value
textTexttype (HTML input type, e.g. email)string
numberNumbermin, max, stepstring/number
rangeRangemin, max, stepnumber
passwordPassword—string (plain! use for env/credentials only when you accept that)
textareaTextarearowsstring
hiddenHidden—string
selectSelectoptions (map, or list of arrays/models with optionKey + optionValue), multiple, selectedscalar, or array when multiple
image-selectImageSelectas select + images (value → image URL)scalar
fontFontas select; options default to the Google Fonts cataloguefont family
radioRadiooptions (map), default, coloroption key
checkboxCheckboxoptions, selected, default, checked, valuedepends on usage
switchSwitchBoxvalue (the "on" value), checked (bool), default (the saved value when absent from the request), colorvalue or default
colorColor— (a colour picker; validate with a hex regex)string
spacingSpacingvalue = array top/right/bottom/leftarray
fileFileaccepted, preview, delete (bool), deleteName; use group => 'uploads'public path, e.g. /storage/uploads/logo.png
headingHeadingheading, tag, class(display only)
alertAlertmessage, color, dismiss(display only)
ajax-buttonAjaxButtontext, route (URL), method, confirm, color, outline, width(display only; the endpoint returns {success, message, reload?})

Closed list

The registry of types is rebuilt for every field (new Fields), so there is no hook to register a custom type from a plugin. Compose the 18 types, use an ajax-button to trigger custom actions, or put richer UI on your own admin page. (EncryptedPassword and ProviderModelSelect classes exist for the AI settings but are not registered in the list.)

Labels are translation keys — or full sentences

Never use a bare word as a label

Labels go through trans(). A label that equals a language file name ('Pages', 'Settings', 'Admin', 'Widgets'…; matching is case-insensitive on Windows and Mac) makes trans() return the whole file as an array and the screen dies with a TypeError. Always use a namespaced key (__('myplugin::strings.label')) or a multi-word sentence.

Where values go (the group key)

groupWritten byRead withNotes
settings (default)Setting::set($key, $value) then one Setting::save() if anything changedsetting('key', $default)Only changed values are written. Arrays (multi-selects) are stored and read back as arrays. Unchecked switch/checkbox fields (absent from the request) get their default
envDotenvEditor::setKey($map ?: $name, $value), mirrored into settings under nameconfig() / env() in config files, or setting('name')Triggers dotartisan:optimize (rebuilds caches). Use for values that must be present at boot (mail driver, app name). Never put per-request data here
uploadsfileUpload($file, 'uploads'); stores the returned pathsetting('name')Send delete_{name} to clear. Uploaded files live on the configured public disk under uploads/
themeThemeOptionStore::put()theme_option('key')Used by the generated Theme options screen. name is theme[key]
staticnothing—Headings, alerts and buttons; excluded from validation

Conditional fields

['type' => 'select', 'id' => 'pg_mode', 'name' => 'pg_mode', 'selected' => setting('pg_mode', 'a'),
 'options' => ['a' => 'Alpha', 'b' => 'Beta'], 'label' => __('pg::s.mode'), 'validation' => ['required', 'in:a,b']],

['type' => 'text', 'id' => 'pg_beta_note', 'name' => 'pg_beta_note', 'label' => __('pg::s.note'),
 'value' => setting('pg_beta_note'),
 'condition' => 'pg_mode',            // the NAME of another field on the same screen
 'conditionValue' => 'b',              // string, list ('smtp,failover' style or array) or true
 'validation' => ['nullable', 'string']],

The wrapper renders data-conditional-name/data-conditional-value (or data-conditional-all for conditions) and admin/assets/js/conditional.js shows or hides the row live as the controlling field changes. Conditions are display-only: validation still applies, so use nullable or required_if:pg_mode,b (the Mail settings do exactly that).

Reading and using settings

setting('testimonials_default_limit', 6);         // helper; second argument is the default
\Akaunting\Setting\Facade::get('key', 'default'); // same thing through the facade

// Writing from your own code
Setting::set('testimonials_default_limit', 12);
Setting::save();
ResponseCacheInvalidator::flush();                // if public pages depend on it

Recipes

Add a tab to an existing screen

php — inside an AdminBooting listener

SettingsManager::add([
    'id' => 'general',                       // re-use the core section id: pages are appended
    'pages' => fn (): array => [[
        'id' => 'tab-pg-extra',
        'name' => __('pg::s.tab'),
        'title' => __('pg::s.tab_title'),
        'fields' => [[
            'type' => 'text', 'id' => 'pg_extra', 'name' => 'pg_extra',
            'label' => __('pg::s.extra'), 'value' => setting('pg_extra'),
            'validation' => ['nullable', 'string', 'max:20'],
        ]],
    ]],
]);

Let an admin pick a page, a menu or a role

use App\Models\Menu;
use App\Models\Role;

['type' => 'select', 'id' => 'pg_menu', 'name' => 'pg_menu', 'label' => __('pg::s.menu'),
 'selected' => setting('pg_menu'), 'options' => Menu::get(), 'optionKey' => 'id', 'optionValue' => 'name',
 'validation' => ['nullable', 'exists:menus,id']],

['type' => 'select', 'id' => 'pg_roles', 'name' => 'pg_roles', 'label' => __('pg::s.roles'), 'multiple' => true,
 'selected' => (array) setting('pg_roles', []),
 'options' => Role::select(['id', 'name'])->get(), 'optionKey' => 'id', 'optionValue' => 'name',
 'validation' => ['nullable', 'array'], 'itemValidation' => ['integer', 'exists:roles,id']],

For "which CMS page is my Shop page" do not hand-roll a select; use AppPages, which generates the select and keeps the setting key.

Secrets

Settings are stored in clear text in the database and .env. For API keys: use the password type (it only masks the input in the browser; the saved value is still rendered into the page source), restrict the screen with manage settings, never log values, and prefer environment variables set at the server level for production. The AI provider keys use an encrypting store (PageBuilderAiSecretStore); mimic it if you need encryption at rest.

Theme options are settings too

The Theme options screen is a settings section whose pages are generated from the active theme's theme.json (ThemeOptionsSchema::pages()), with every field in group theme. You never register it by hand; add options to theme.json instead — see Theme options and CSS pipeline.

Testing a settings screen

it('stores the testimonials settings', function (): void {
    $user = userWithPermissions(['manage settings']);

    $this->actingAs($user)
        ->get(route('admin.settings', ['type' => 'testimonials']))
        ->assertOk()
        ->assertSee('testimonials_default_limit');

    $this->actingAs($user)
        ->post(route('admin.settings.update', ['type' => 'testimonials']), ['testimonials_default_limit' => 2])
        ->assertRedirect();

    expect((int) setting('testimonials_default_limit'))->toBe(2);
});