Features
MarkdownProject settings
Project settings are the operator-facing configuration for the whole Externa install. From externa-core 1.3.0 (#201), /settings/project is split into dedicated sections (General, Localization, Auth & Security, Content, Files & Media, API, Integrations). Sidebar module order lives under Appearance → Sidebar. They require can-manage-project-settings.

Related
Personal profile/password/passkeys: Account settings · Passkeys · SSO / OIDC. Content locale resolution: Configuration · Collections data model. Public API origins: Client types — Origin allowlist. Outbound delivery: Outbound webhooks · Slack & Telegram notifiers. HTTP overview: Routing.
URL bookmarks (1.3.0)
Deep links that pointed at a single #section on /settings/project no longer apply. Use the paths in the HTTP table (for example /settings/project/security for SSO, /settings/project/integrations for webhooks/notifiers, /settings/appearance/sidebar for sidebar modules). Deprecated test aliases (project.webhook-test, notifier tests under /settings/project/*-test) stay for one minor — prefer the project.integrations.* names.
Access
| Item | Detail |
|---|---|
| Permission | can-manage-project-settings |
| Routes | routes/settings.php (auth + verified + permission:…) |
| Controllers | Project*SettingsController, AppearanceSettingsController, AppearanceSidebarSettingsController |
| Pages | resources/js/pages/settings/project.tsx, project/*.tsx, appearance.tsx, appearance/sidebar.tsx |
| Storage | SettingsRepository project scope (config/settings.php keys) |
The same can-manage-project-settings permission also grants access to Settings → Performance (/settings/performance) — cache ops status and targeted flush actions. See Operations.
HTTP
| Method | Path | Name | Purpose |
|---|---|---|---|
GET | /settings/project | project.edit | General (identity + report URLs) |
PUT | /settings/project | project.update | Persist General keys |
GET | /settings/project/localization | project.localization.edit | UI language + content locales |
PUT | /settings/project/localization | project.localization.update | Persist Localization keys |
GET | /settings/project/security | project.security.edit | Auth, registration, SSO/OIDC |
PUT | /settings/project/security | project.security.update | Persist Auth & Security keys |
GET | /settings/project/content | project.content.edit | Live Preview default + revision retention |
PUT | /settings/project/content | project.content.update | Persist Content keys |
GET | /settings/project/files | project.files.edit | Upload caps + image transforms |
PUT | /settings/project/files | project.files.update | Persist Files & Media keys |
GET | /settings/project/api | project.api.edit | Public API allowed origins |
PUT | /settings/project/api | project.api.update | Persist API keys |
GET | /settings/project/integrations | project.integrations.edit | Webhooks + Slack/Telegram notifiers |
PUT | /settings/project/integrations | project.integrations.update | Persist Integrations keys |
POST | /settings/project/integrations/webhook-test | project.integrations.webhook-test | Queue a signed ping event |
POST | /settings/project/integrations/slack-notifier-test | project.integrations.slack-notifier-test | Send a test Slack message |
POST | /settings/project/integrations/telegram-notifier-test | project.integrations.telegram-notifier-test | Send a test Telegram message |
POST | /settings/project/webhook-test | project.webhook-test | Deprecated alias (one minor) → webhook test |
POST | /settings/project/slack-notifier-test | project.slack-notifier-test | Deprecated alias (one minor) → Slack test |
POST | /settings/project/telegram-notifier-test | project.telegram-notifier-test | Deprecated alias (one minor) → Telegram test |
POST | /settings/project/integrations/deploy | project.integrations.deploy | Trigger deploy hooks (deploy.trigger, 1.4.0) |
GET | /settings/ai-tools | ai-tools.edit | AI write tools allowlist (1.4.0) |
PUT | /settings/ai-tools | ai-tools.update | Persist ai.write_tools |
GET | /settings/appearance | appearance.edit | Branding form |
PUT | /settings/appearance | appearance.update | Persist appearance keys |
GET | /settings/appearance/sidebar | appearance.sidebar.edit | Sidebar module order / visibility |
PUT | /settings/appearance/sidebar | appearance.sidebar.update | Persist sidebar_modules |
Unsaved changes
Each section form shows an Unsaved badge and Discard on the sticky Save bar while dirty. Discard confirms, resets fields to the last loaded values, and stays on the page. Leaving via navigation while dirty uses the same Keep editing / Discard dialog.
Sections and keys
Every writable project setting key appears exactly once below (matched to the merged Form Requests). Write-only secrets never round-trip on Inertia — the UI shows *_configured booleans instead.
General — /settings/project
Request: UpdateProjectSettingsRequest. Page: resources/js/pages/settings/project.tsx.
| Key | Notes |
|---|---|
name | Project display name |
description | Short description |
url | Public / marketing site URL |
report_issue_url | “Report issue” deep link |
report_bug_url | “Report bug” deep link |
report_error_url | “Report error” deep link |
Localization — /settings/project/localization
Request: UpdateProjectLocalizationSettingsRequest. Page: resources/js/pages/settings/project/localization.tsx.
| Key | Notes |
|---|---|
default_language | Admin UI shell locale |
content_locales | Enabled content languages (source of truth once saved) |
default_content_locale | Default content locale |
fallback_content_locales | Ordered fallback chain for missing translations |
Content locales are the source of truth for translatable collection field values and labels once saved. Until then, seed/fallback comes from config/collections.php. Catalog codes live in resources/data/content-locales-catalog.json (ContentLocaleCatalog).
Disabling a content locale does not delete existing translation rows.
Editing Content languages rewrites fallback_content_locales to [default, …other enabled locales in list order] before save — see Locale fallback for the read-time chain (fallback_content_locales → APP_FALLBACK_LOCALE → null) and empty-list normalization.
On the item form, editors switch locales from the toolbar, copy or apply values across locales, and optionally translate with AI — see Translation workspace.
Auth & Security — /settings/project/security
Request: UpdateProjectSecuritySettingsRequest. Page: resources/js/pages/settings/project/security.tsx. Includes SSO/OIDC.
| Key | Notes |
|---|---|
password_policy | weak / medium / strong |
login_max_attempts | Throttle window for login |
registration_enabled | Public self-registration |
default_user_role | Role assigned on register / JIT |
email_verification_required | Require verified email |
two_factor_required | Enforce MFA (TOTP or passkey) |
allowed_domains | Email domain allowlist (empty = no domain gate) |
oidc_enabled | Show SSO button on login |
oidc_issuer | Issuer URL (discovery at /.well-known/openid-configuration) |
oidc_client_id | OIDC client id |
oidc_client_secret | Write-only — encrypted at rest; never shared on Inertia |
oidc_button_label | Optional custom button text |
oidc_jit_provisioning | Create users on first login (default off) |
Full SSO guide: SSO / OIDC.
Require two-factor authentication
When two_factor_required is enabled, EnsureTwoFactorIsEnabled (web middleware) redirects authenticated users who have neither confirmed Fortify TOTP (hasEnabledTwoFactorAuthentication()) nor at least one registered passkey (hasPasskeysEnabled()) to Settings → Security (security.edit). That page passes twoFactorRequired / twoFactorEnforcedForUser and shows an alert banner until the user finishes enrollment (TOTP or a passkey).
| Satisfies the gate? | Condition |
|---|---|
| Yes | Confirmed Fortify TOTP |
| Yes | ≥1 registered passkey (Features::canManagePasskeys() and hasPasskeysEnabled()) |
| Yes | Both of the above |
| No | Neither — forced to Security with the required-MFA banner |
| N/A (gate off) | two_factor_required off — personal TOTP/passkeys stay optional |
| N/A (no-op) | Fortify two-factor feature disabled — middleware does not enforce |
Allowlisted so enrollment works: security/password/profile/locale settings, Fortify 2FA + passkey registration/store/destroy + password-confirm routes, and logout. Deleting a user’s last passkey while TOTP is off and the setting is on returns them to the enrollment gate.
Passkey register / passwordless login / RP config: Passkeys. Pre-release checks: Security checklist.
Content — /settings/project/content
Request: UpdateProjectContentSettingsRequest. Page: resources/js/pages/settings/project/content.tsx. Includes Live Preview default URL.
| Key | Notes |
|---|---|
preview_url_default | Fallback Live Preview URL when the collection field is empty |
revision_retention_count | Max revisions to keep (optional) |
revision_retention_days | Max age in days (optional) |
See Live Preview · Content versioning.
Files & Media — /settings/project/files
Request: UpdateProjectFilesSettingsRequest. Page: resources/js/pages/settings/project/files.tsx.
| Key | Notes |
|---|---|
files_max_upload_bytes | Soft cap for File Manager uploads (null = unlimited aside from PHP) |
chat_max_upload_bytes | Soft cap for team chat attachments |
upload_allowed_extensions | Optional extension allowlist (null/empty = no extra extension filter) |
upload_allowed_mimes | Optional MIME allowlist (null/empty = config/files.php defaults) |
upload_allow_svg | Explicit SVG opt-in (false by default; XSS risk when served/inlined) |
allowed_transformations | Enabled transform operations |
preset_transformations | Named presets (key, fit, width, height, quality, …) |
Named presets, focal point, Public API URLs, caching, and a Next <Image> loader: Image transforms. Disk / MIME / magic-byte rules: Files configuration · File manager — Upload security.
API — /settings/project/api
Request: UpdateProjectApiSettingsRequest. Page: resources/js/pages/settings/project/api.tsx.
| Key | Notes |
|---|---|
public_api_allowed_origins | One origin per line in the UI; empty = no origin gate (CORS from CORS_ALLOWED_ORIGINS) |
When public_api_allowed_origins is non-empty, Externa:
- Sets CORS
allowed_originsforapi/*to that list - Enforces
EnforcePublicApiOriginon REST and GraphQL (matchingOrigin, or a valid Bearer API key whenOriginis absent)
Format: https://host or http://host[:port] — no path. Details: Origin allowlist.
Integrations — /settings/project/integrations
Request: UpdateProjectIntegrationsSettingsRequest. Page: resources/js/pages/settings/project/integrations.tsx. Webhooks + Slack/Telegram notifiers.
| Key | Notes |
|---|---|
webhook_url | Outbound webhook endpoint |
webhook_secret | Write-only — encrypted at rest; never shared on Inertia |
notifier_slack_webhook_url | Slack Incoming Webhook URL |
notifier_telegram_bot_token | Write-only — encrypted; BotFather digits:token format |
notifier_telegram_chat_id | Telegram chat / channel id |
notifier_event_types | Which events fan out to notifiers (null / empty choice = all) |
deploy_hooks | Named deploy hooks (1.4.0); each URL encrypted at rest |
deploy_on_publish | Trigger every deploy hook when an item is published (1.4.0) |
Guides: Outbound webhooks · Slack & Telegram notifiers.
Notifiers (Slack, Telegram)
Outbound chat notifications for domain events (1.2.0+). They run in parallel with outbound webhooks, which stay the machine-to-machine channel.
- Event types:
notifier_event_typespicks which events are sent; an empty choice (null) means all selectable events.ping(test) is always allowed. - Deliveries are queued jobs (
DeliverSlackOutboundNotifierJob,DeliverTelegramOutboundNotifierJob) dispatched after commit, so a queue worker must be running. - Test buttons hit
project.integrations.*-notifier-test(deprecated aliases still work for one minor). - Messages are formatted per channel (Telegram uses HTML parse mode) with item/collection context.
Deploy hooks
From externa-core 1.4.0 (#196), store build hooks from Vercel, Netlify, Cloudflare Pages, Laravel Forge, or any service that starts a deploy on POST.
| Item | Detail |
|---|---|
| Hooks | Up to 10, each with a name (≤120 chars) and URL (≤2048). URLs are encrypted and never sent back to the browser |
| URL check | Same outbound SSRF guard as outbound webhooks (private hosts need WEBHOOKS_ALLOW_PRIVATE_HOSTS=true) |
| Manual trigger | Deploy button in the admin header, for users with deploy.trigger. POST /settings/project/integrations/deploy (optional hook_id) |
| Deploy on publish | deploy_on_publish fires every hook after an item is published (manual or scheduled publish) |
| Delivery | Queued DeliverDeployHookJob: POST, 5 s timeout, no redirects, User-Agent: Externa-Deploy/1.0, 3 tries (10 / 30 / 60 s) |
| Audit | Activity log log_name=deploy, event=deploy_triggered (hook ids + source); Integrations shows the last run status |
A queue worker must be running. Editing hooks needs can-manage-project-settings; triggering them needs the new deploy.trigger permission, which externa:upgrade grants to the built-in admin role. MCP publish_item fires hooks only after its approval — MCP server.
Webhook test
POST …/integrations/webhook-test (or the deprecated /settings/project/webhook-test) requires a configured URL; otherwise redirects with an error. Dispatches OutboundWebhookDispatcher::dispatchPing() onto the queue (needs a worker). Integrations settings also shows last success / last error and a recent delivery ring buffer after jobs finish.
Appearance (branding) — /settings/appearance
Request: UpdateAppearanceSettingsRequest. Page: resources/js/pages/settings/appearance.tsx. Presets: config/theme-presets.php, ThemePresetService.
Settings → Appearance is project branding, not the personal light/dark toggle:
| Key | Purpose |
|---|---|
theme_preset | Built-in theme preset (1.4.0): teal (default), indigo, violet, rose, slate |
project_color / project_color_dark | Optional custom brand colors (#RRGGBB); override the preset |
project_logo / project_logo_dark | Logos (form fields project_logo_id / project_logo_dark_id) |
public_favicon | Favicon (form field public_favicon_id) |
default_appearance | Default theme hint (system / light / dark) |
Personal light/dark preference still uses the appearance cookie + useAppearance / AppearanceTabs in the user menu (HandleAppearance middleware). That path has no Spatie permission gate.
Theme presets (1.4.0)
From externa-core 1.4.0 (#243), Settings → Appearance offers five built-in presets with a live preview: qiick Teal (default), Indigo, Violet, Rose, and Slate. Each preset sets the full light and dark token set (config/theme-presets.php).
- Precedence: custom brand hex → selected preset → default teal. A custom colour is stored only when you set one, and saving Appearance keeps the selected preset (#437).
- AI accent: AI surfaces use violet. With the Violet preset, or a custom hex within about 30° of the violet hue, the AI tokens switch to teal so AI stays visually distinct.
Brand colour and focus-ring contrast
A custom brand colour is used for the focus ring and focused input border only when it reaches 3:1 against the page background (WCAG 1.4.11 / 2.4.7), checked separately for light and dark. Otherwise Externa keeps the default teal ring (#453). The check uses the page background only; a brand that barely passes there may still be lower on card surfaces. Button text on the brand colour picks a contrasting foreground. See Accessibility.
Transactional email branding
Password reset, email verification, and other Markdown / MailMessage emails use the same project branding:
- Logo — light
project_logo(emails use a light theme). If unset, the header shows the project name as text (never the default Laravel logo). - Primary color —
project_colorfor buttons and links (button text uses contrasting black/white). - Name — Project settings name when set; otherwise
APP_NAME.
The logo must be reachable by email clients as an absolute public URL (assets disk / FILE_PUBLIC_URL_BASE / CDN / S3). Localhost URLs work only for local mail testing; production mailboxes cannot load them.
Sidebar — /settings/appearance/sidebar
Request: UpdateAppearanceSidebarSettingsRequest. Page: resources/js/pages/settings/appearance/sidebar.tsx.
| Key | Notes |
|---|---|
sidebar_modules | Ordered modules (id, enabled, locked). ai / dashboard locked |
Moved out of the former monolithic Project page in 1.3.0 so branding and navigation stay under Appearance.
Source map
| Concern | Location |
|---|---|
| Routes | routes/settings.php |
| General | ProjectSettingsController.php + UpdateProjectSettingsRequest.php |
| Localization | ProjectLocalizationSettingsController.php + UpdateProjectLocalizationSettingsRequest.php |
| Auth & Security | ProjectSecuritySettingsController.php + UpdateProjectSecuritySettingsRequest.php |
| Content | ProjectContentSettingsController.php + UpdateProjectContentSettingsRequest.php |
| Files & Media | ProjectFilesSettingsController.php + UpdateProjectFilesSettingsRequest.php |
| API | ProjectApiSettingsController.php + UpdateProjectApiSettingsRequest.php |
| Integrations | ProjectIntegrationsSettingsController.php + UpdateProjectIntegrationsSettingsRequest.php |
| Appearance | AppearanceSettingsController.php + UpdateAppearanceSettingsRequest.php |
| Sidebar | AppearanceSidebarSettingsController.php + UpdateAppearanceSidebarSettingsRequest.php |
| Config keys / defaults | config/settings.php |
| Settings store | app/Services/Settings/SettingsRepository.php, ProjectSettings.php, ProjectAppearance.php |
| UI | resources/js/pages/settings/project.tsx, project/*.tsx, appearance.tsx, appearance/sidebar.tsx |
| Settings nav | resources/js/layouts/settings/layout.tsx |
