Configuration

Markdown

Configuration

Externa’s runtime settings live in externa-core/config/*.php and are driven by .env (see Environment variables). This page covers the application defaults you should understand before going to production.

Related pages

Application (config/app.php)

Key / envDefaultNotes
APP_NAMELaravelDisplay name in mail/UI strings that read config('app.name'). Shared to Inertia as name.
APP_ENVproduction in config; .env.example uses localEnvironment label.
APP_DEBUGfalse in config; true in .env.exampleDisable in production.
APP_URLhttp://localhostCanonical URL for Artisan, mail links, public disk URLs, and Fortify passkeys RP. Must match how browsers reach the app. App works on HTTP; passkeys need HTTPS / localhost — Passkeys.
APP_KEY(required)Encryption key; generated by composer setup / key:generate.
APP_LOCALEenDefault Laravel locale.
APP_FALLBACK_LOCALEenFallback when a translation key is missing.
APP_FAKER_LOCALEen_USFaker locale for factories/seed demos.
timezoneUTCHard-coded in config (not env-driven).
MaintenanceAPP_MAINTENANCE_DRIVER=file, store default databaseStandard Laravel maintenance mode.
APP_NAME=Laravel
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US

Three locale systems

  1. Admin UI locale — config/i18n.php + user preference / cookie (APP_LOCALE is the default). Controls the shell language (en/it/de).
  2. Content locales — project settings (content_locales, default_content_locale, fallback_content_locales). Source of truth for translatable collection field values and labels. Seeded from config/collections.php until saved. UI: Project settings.
  3. Laravel APP_LOCALE / APP_FALLBACK_LOCALE — framework translator defaults; also used as a last-resort content resolve hint when nothing else matches.

Changing the admin UI language does not change which content locales are enabled.

From externa-core 1.4.0 (#282), server-side strings (flash messages, validation and abort messages wrapped in __()) are also translated: lang/it.json and lang/de.json, with English source strings as keys and a Pest key-parity test. They follow the admin UI locale. Tool errors and AI machine messages stay English.

Fortify (config/fortify.php)

Laravel Fortify provides session authentication for the Inertia SPA.

SettingValue
Guardweb
Password brokerusers
Username / email fieldemail
lowercase_usernamestrue
Home path/dashboard
Route prefix / domainempty / null
Middleware['web']
Viewsenabled (true) — Inertia auth pages under resources/js/pages/auth/*
Login throttlelimiter login (5/min)
Two-factor throttlelimiter two-factor (5/min)
Passkeys throttlelimiter passkeys

Enabled features

Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication([
    'confirm' => true,
    'confirmPassword' => true,
    'window' => 1, // ±30s TOTP skew
]),
Features::passkeys([
    'confirmPassword' => true,
]),

Passkeys also use the passkeys config block (relying_party_id, allowed_origins, user_handle_secret, timeout) derived from APP_URL / APP_KEY. Operator guide: Passkeys.

To disable a feature, remove it from the features array in config/fortify.php and adjust the UI accordingly.

Authenticated product routes also require the verified middleware (see Routing overview).

Session, cache, and queue defaults

Externa’s .env.example uses database drivers for all three. That keeps local setup simple and matches the queue-backed file zip/duplicate and AI import jobs.

ConcernConfig fileEnvDefault
Session driverconfig/session.phpSESSION_DRIVERdatabase
Session lifetimeSESSION_LIFETIME120 minutes
Session encryptSESSION_ENCRYPTfalse
Cache storeconfig/cache.phpCACHE_STOREdatabase
Queue connectionconfig/queue.phpQUEUE_CONNECTIONdatabase
Default filesystem diskconfig/filesystems.phpFILESYSTEM_DISKlocal
SESSION_DRIVER=database
SESSION_LIFETIME=120
CACHE_STORE=database
QUEUE_CONNECTION=database
FILESYSTEM_DISK=local

Queue worker required

With QUEUE_CONNECTION=database (or Redis/etc.), you must run a worker. composer run dev starts php artisan queue:listen. Production needs a supervised worker plus the scheduler — see Operations and Deployment.

Optional Redis variables are present in .env.example if you switch drivers later (REDIS_*, MEMCACHED_HOST).

Super-admin seeding (config/super_admin.php)

Used only by CreateSuperAdminSeeder when you run php artisan db:seed (via DatabaseSeeder).

Config keyEnvDefault
first_nameINITIAL_SUPER_ADMIN_FIRST_NAMESuper
last_nameINITIAL_SUPER_ADMIN_LAST_NAMEAdmin
emailINITIAL_SUPER_ADMIN_EMAILsuperadmin@example.com
passwordINITIAL_SUPER_ADMIN_PASSWORDpassword

DatabaseSeeder order:

  1. PermissionSeeder → php artisan permissions:sync
  2. RoleSeeder → built-in roles (super-admin, admin, reader, public) and permission assignments
  3. CreateSuperAdminSeeder → upserts the user by email and assigns the super-admin role

Production

Override INITIAL_SUPER_ADMIN_* before seeding shared or production environments. Never leave the default password outside local development.

Permission configuration overview

PieceLocation
Spatie package configconfig/permission.php
Canonical permission namesApp\Enums\PermissionEnum
Sync commandphp artisan permissions:sync (--prune removes DB rows no longer in the enum)
TS mirrorRegenerated at resources/js/enums/permission-enum.ts
Teamsdisabled (teams => false)
Permission cache24 hours, key spatie.permission.cache, store default
Effective resolutionApp\Services\Authorization\EffectivePermissionResolver
Middleware aliasespermission, can.manage.files in bootstrap/app.php

Key Spatie settings that matter for Externa:

  • Models: default Spatie Permission / Role
  • register_permission_check_method → true
  • display_permission_in_exception / display_role_in_exception → false (avoid leaking names)
  • Wildcard permissions → disabled

Full authorization walkthrough: Effective permissions.

After deploying enum changes, run:

php artisan permissions:sync
# optional: php artisan permissions:sync --prune

Content locales (project settings)

Source of truth: Project settings → content_locales (ordered, min 1), default_content_locale, optional fallback_content_locales.

config/collections.php (locales / fallback_locales) is only the seed / fallback when settings have not been saved yet (defaults ['en', 'it']).

KeyRole
content_localesEnabled BCP-47 (or language-only) codes for item data translations and field labels
default_content_localeMust be ∈ content_locales; used when no ?locale= / Accept-Language match
fallback_content_localesOrdered chain when a translation is missing

Configure under Settings → Project → Content languages (searchable catalog + sortable list). Catalog codes/names/flags live in resources/data/content-locales-catalog.json (ContentLocaleCatalog). Disabling a locale does not delete existing translation rows in the DB (orphans stay until rewritten).

Shared to the frontend as Inertia props collectionLocales, collectionLocaleMeta, and defaultContentLocale (see Routing overview).

Admin UI language remains separate (config/i18n.php).

Locale fallback

Two separate steps: pick the active locale, then walk a value fallback chain when a translatable field has no usable string for that locale. Applies to top-level translatable fields only (nested block maps are not flattened — see Field types).

Active locale (CollectionLocaleResolver::resolve): explicit override / ?locale= (must be enabled or 422), then first Accept-Language tag that is enabled, then default_content_locale, then APP_LOCALE if enabled, then the first enabled content locale. Same rules for the Public CMS API and Live Preview (published flatten path).

Value fallback (fallbackChain → getTranslated / flattenForLocale):

  1. The requested (or resolved) locale
  2. Each entry in fallback_content_locales that is still enabled (duplicates skipped)
  3. APP_FALLBACK_LOCALE (config('app.fallback_locale'), default en) if that code is enabled and not already in the chain
  4. If every candidate is missing → the field is null in the response

Empty strings count as missing: getTranslated skips a locale slot when the value is null or '', then tries the next locale in the chain.

content_locales = [en, it, de]
fallback_content_locales = [en, it, de]   # typical after Content languages save
?locale=de
title = { en: "Hello", it: "" }
→ "Hello"   # de missing → it empty → en

Admin rewrite: editing Settings → Project → Content languages resets fallback_content_locales in the form to [default_content_locale, …other enabled locales in list order]. Saving that section persists that chain (any earlier custom order is replaced). There is no separate reorder UI; the value is submitted as a hidden field. A custom order sent via the project settings update API is kept until Content languages are edited again.

Empty list on the server: when normalizing locales, entries not in content_locales are dropped. If fallback_content_locales ends up empty, ProjectSettings rebuilds [default_content_locale, …seed codes from config/collections.php fallback_locales that are still enabled, …remaining enabled locales].

Security headers

From externa-core 1.4.0 (#187), SetSecurityHeadersMiddleware (web group) adds browser defence headers to admin HTML responses. It is on by default (config/security.php).

EnvDefaultHeader
SECURITY_HEADERS_ENABLEDtrueTurns all of them on/off
—nosniff (fixed)X-Content-Type-Options
SECURITY_X_FRAME_OPTIONSDENYX-Frame-Options
SECURITY_REFERRER_POLICYstrict-origin-when-cross-originReferrer-Policy
SECURITY_PERMISSIONS_POLICYcamera=(), microphone=(self), geolocation=(), payment=(), usb=()Permissions-Policy
SECURITY_CSPempty = built-in policyContent-Security-Policy (full override, sent verbatim)

Skipped: api/*, graphql, api/graphql (Public API clients never get the admin CSP), and any response that already sets its own Content-Security-Policy (sandboxed user content).

Built-in CSP: default-src 'self'; scripts and styles allow 'self' plus 'unsafe-inline' (scripts also 'unsafe-eval') for Vite/Inertia; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' ws: wss: http: https: (Reverb/Echo and Vite HMR); worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'. On top of that:

  • the origin of the configured files disk url (filesystems.disks.<FILES_DISK>.url, for example AWS_URL or AZURE_STORAGE_URL) is added to img-src and media-src (#440);
  • in local dev, the Vite dev server origin from public/hot (including Herd HTTPS) is added to script, style, font, and image sources.

Microphone: microphone=(self) keeps AI chat voice input (SpeechRecognition) working. Set SECURITY_PERMISSIONS_POLICY with microphone=() to turn voice input off.

HSTS is not sent by Externa; set it on the reverse proxy — Reverse proxy cookbook. If you embed the admin in an iframe on purpose, change both SECURITY_X_FRAME_OPTIONS and the CSP (frame-ancestors) via SECURITY_CSP.

SECURITY_HEADERS_ENABLED=true
# SECURITY_X_FRAME_OPTIONS=DENY
# SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin
# SECURITY_PERMISSIONS_POLICY="camera=(), microphone=(self), geolocation=(), payment=(), usb=()"
# SECURITY_CSP=

Mail and logging (brief)

AreaDefault in .env.example
MailerMAIL_MAILER=log (no SMTP required locally)
LogLOG_CHANNEL=stack, LOG_LEVEL=debug

Configure real SMTP (or another mailer) before relying on password-reset or verification email in non-local environments.

Checklist after changing config

  1. Update .env (and secrets store in production).
  2. php artisan config:clear or rebuild config:cache in production.
  3. If permissions/enums changed: permissions:sync (+ seed roles if needed).
  4. If content locales changed: smoke-test item forms and API ?locale= for each enabled locale.
  5. Confirm APP_URL matches the public host (affects storage URLs and redirects).