diff --git a/composer.json b/composer.json
index ea7251820..c4d856d54 100755
--- a/composer.json
+++ b/composer.json
@@ -87,7 +87,10 @@
"Unusualify\\Modularous\\Providers\\ModularousProvider"
],
"aliases": {
- "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite"
+ "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite",
+ "SystemSettings": "Unusualify\\Modularous\\Facades\\SystemSettings",
+ "CmsSettings": "Unusualify\\Modularous\\Facades\\CmsSettings",
+ "SiteSettings": "Unusualify\\Modularous\\Facades\\SiteSettings"
}
},
"branch-alias": {
diff --git a/config/merges/cms_seo.php b/config/merges/cms_seo.php
index 231f1968a..bf8d639d6 100644
--- a/config/merges/cms_seo.php
+++ b/config/merges/cms_seo.php
@@ -7,6 +7,7 @@
use Modules\Cms\Services\CmsAdminWarnings;
use Modules\Cms\Services\CmsSiteSeoSettingsService;
use Modules\Cms\Support\CmsPublicSeo;
+use Modules\SystemSetting\Support\MigrateRobotsTxtFromSiteSetting;
use Unusualify\Modularous\Entities\Traits\Core\HasScopes;
return [
@@ -32,19 +33,23 @@
* Global robots.txt (served at GET /robots.txt when route enabled).
*
* @see RobotsTxtController
+ * @see CmsSiteSeoSettingsService
*/
'robots' => [
'route_enabled' => env('MODULAROUS_CMS_ROBOTS_TXT_ROUTE_ENABLED', true),
'global_robots_txt' => env('MODULAROUS_CMS_SEO_GLOBAL_ROBOTS_TXT', ''),
/**
- * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService} (um_cms_site_settings).
+ * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService}
+ * ({@see \Unusualify\Modularous\Facades\SiteSettings} → CmsSettings with SystemSettings fallback).
* When false, only env/config {@code global_robots_txt} is used (legacy / headless deploys).
*/
- 'use_site_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true),
+ 'use_system_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SYSTEM_SETTINGS', env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true)),
/**
- * Composite key for the global robots.txt body row (must match unique index on site_settings).
+ * Legacy KV row keys used only by {@see MigrateRobotsTxtFromSiteSetting}.
+ *
+ * @deprecated Robots.txt is stored on SystemSetting General / Cms SiteSetting (IsSingular).
*/
- 'site_setting' => [
+ 'legacy_site_setting' => [
'group_key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_GROUP', 'seo'),
'key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_KEY', 'global_robots_txt'),
'locale' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_LOCALE', '*'),
diff --git a/config/merges/cms_settings.php b/config/merges/cms_settings.php
new file mode 100644
index 000000000..d019a3b96
--- /dev/null
+++ b/config/merges/cms_settings.php
@@ -0,0 +1,8 @@
+ (int) env('MODULAROUS_CMS_SETTINGS_CACHE_TTL', env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600)),
+ 'sensitive_keys' => [
+ 'smtp.password',
+ ],
+];
diff --git a/config/merges/input_types.php b/config/merges/input_types.php
index 14c80e655..23575bb6d 100644
--- a/config/merges/input_types.php
+++ b/config/merges/input_types.php
@@ -3,6 +3,24 @@
use Camroncade\Timezone\Timezone;
return [
+ '@collapsible_wrap' => [
+ 'type' => 'wrap',
+ 'typeInt' => 'ue-collapsible',
+ 'typeIntModelValue' => false,
+ 'typeIntTitle' => 'Wrap',
+ 'noLabel' => true,
+ 'bordered' => true,
+ 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12],
+ ],
+ '@collapsible_group' => [
+ 'type' => 'group',
+ 'typeInt' => 'ue-collapsible',
+ 'typeIntModelValue' => false,
+ 'typeIntTitle' => 'Group',
+ 'noLabel' => true,
+ 'bordered' => true,
+ 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12],
+ ],
'_language' => [
'type' => 'select',
'name' => 'language',
diff --git a/config/merges/system_settings.php b/config/merges/system_settings.php
new file mode 100644
index 000000000..7ad9b005c
--- /dev/null
+++ b/config/merges/system_settings.php
@@ -0,0 +1,45 @@
+ 'SystemSetting',
+ 'system_prefix' => true,
+ 'group' => 'system',
+ 'headline' => 'System Settings',
+ 'cache_ttl' => (int) env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600),
+ /**
+ * Fallback when SystemSettings `analytics.enabled` is unset in the DB.
+ * Staging should leave DB off (or unset) so this stays false.
+ * Prod: set analytics.enabled=true in System Settings (or MODULAROUS_ANALYTICS_ENABLED=true as seed default).
+ */
+ 'analytics_enabled_default' => (bool) env('MODULAROUS_ANALYTICS_ENABLED', false),
+ /**
+ * When true, smtp.* from System Settings overrides config/mail.php.
+ * Prefer .env MAIL_* unless you explicitly need CMS-managed SMTP.
+ */
+ 'mail_override_enabled' => (bool) env('MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE', false),
+ 'sensitive_keys' => [
+ 'smtp.password',
+ ],
+ 'extra_inputs' => [],
+ 'input_overrides' => [],
+ 'hidden_inputs' => [],
+ 'input_aliases' => [
+ '@system_social_links' => [
+ 'type' => 'json-repeater',
+ 'name' => 'social',
+ 'label' => 'Social Links',
+ 'default' => [],
+ 'draggable' => true,
+ 'orderKey' => 'position',
+ 'noHeaders' => true,
+ 'collapsible' => true,
+ 'collapsibleTitleField' => 'platform',
+ 'collapsibleDefaultOpen' => false,
+ 'schema' => [
+ ['name' => 'platform', 'type' => 'text', 'label' => 'Platform', 'rules' => 'required'],
+ ['name' => 'url', 'type' => 'text', 'label' => 'URL', 'rules' => 'required'],
+ ['name' => 'icon', 'type' => 'image', 'label' => 'Icon', 'rules' => 'nullable'],
+ ],
+ ]
+ ],
+];
diff --git a/docs/src/pages/system-reference/backend/facades/cms-settings.md b/docs/src/pages/system-reference/backend/facades/cms-settings.md
new file mode 100644
index 000000000..f659483ee
--- /dev/null
+++ b/docs/src/pages/system-reference/backend/facades/cms-settings.md
@@ -0,0 +1,52 @@
+---
+sidebarPos: 2
+sidebarTitle: CmsSettings
+---
+
+# CmsSettings
+
+**Facade**: `Unusualify\Modularous\Facades\CmsSettings`
+**Accessor**: `cms.settings`
+**Underlying**: `Modules\Cms\Services\CmsSettingsService`
+**Model**: `Modules\Cms\Entities\SiteSetting` (`IsSingular`)
+
+Frontend/CMS-facing settings singleton. No System fallback — use [`SiteSettings`](./site-settings) when empty CMS keys should fall through to System. Full guide: [Settings Services](/system-reference/backend/services/settings/overview).
+
+## Sections
+
+`site`, `social`, `contact`, `seo` only.
+
+`smtp`, `analytics`, and `locale` are **not** editable on `SiteSetting` — use [`SystemSettings`](./system-settings).
+
+## Methods
+
+Shared singular API — see [Shared API](/system-reference/backend/services/settings/overview#shared-api).
+
+| Method | Signature | Description |
+|--------|-----------|-------------|
+| `get` / `value` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Dot-path read with locale / media-leaf expansion |
+| `first` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | First list element when applicable |
+| `all` | `(?string $locale = null): array` | Locale-resolved section tree |
+| `has` | `(string $key): bool` | Key exists in snapshot |
+| `filled` | `(string $key, ?string $locale = null): bool` | Resolved value is non-empty |
+| `set` | `(string $key, mixed $value): void` | Persist + forget cache |
+| `forgetCache` | `(): void` | Clear cached snapshot |
+| `snapshot` | `(): array` | Raw cached sections |
+
+## Usage
+
+```php
+use Unusualify\Modularous\Facades\CmsSettings;
+
+CmsSettings::set('site.name', 'Public site name');
+$tagline = CmsSettings::get('site.tagline');
+
+if (! CmsSettings::filled('seo.default_meta_title')) {
+ // CMS empty — SiteSettings::forFrontend() would fall back to System
+}
+```
+
+## Notes
+
+- Seed empty CMS from System with [`DuplicateSystemSettingsToCmsSettings`](/system-reference/backend/services/settings/overview#duplicatesystemsettingstocmssettings).
+- Cache key: `cms_settings.snapshot` (`modularous.cms_settings.cache_ttl`).
diff --git a/docs/src/pages/system-reference/backend/facades/overview.md b/docs/src/pages/system-reference/backend/facades/overview.md
index 1a096d1c6..26c748544 100644
--- a/docs/src/pages/system-reference/backend/facades/overview.md
+++ b/docs/src/pages/system-reference/backend/facades/overview.md
@@ -5,7 +5,7 @@ sidebarTitle: Overview
# Facades
-Modularous registers 18 Laravel facades, all under the `Unusualify\Modularous\Facades\` namespace. Each facade is an alias to a bound service container entry, providing static-style access to the underlying service class.
+Modularous registers 21 Laravel facades, all under the `Unusualify\Modularous\Facades\` namespace. Each facade is an alias to a bound service container entry, providing static-style access to the underlying service class.
## Overview
@@ -28,8 +28,13 @@ Modularous registers 18 Laravel facades, all under the `Unusualify\Modularous\Fa
| [Register](./register) | `auth.register` | `RegisterBrokerManager` |
| [HostRouting](./host-routing) | `unusualify.hosting` | `Support\HostRouting` |
| [HostRoutingRegistrar](./host-routing-registrar) | `unusualify.hostRouting` | `Support\HostRouting` |
+| [SystemSettings](./system-settings) | `system.settings` | `Modules\SystemSetting\Services\SystemSettingsService` |
+| [CmsSettings](./cms-settings) | `cms.settings` | `Modules\Cms\Services\CmsSettingsService` |
+| [SiteSettings](./site-settings) | `site.settings` | `Modules\Cms\Services\SiteSettingsService` |
| [UFinder](./u-finder) *(deprecated)* | `Finder::class` | `Support\Finder` |
+Settings facades share one API and differ by backing store / context routing — see [Settings Services](/system-reference/backend/services/settings/overview).
+
## Usage Pattern
All Modularous facades follow standard Laravel facade usage:
diff --git a/docs/src/pages/system-reference/backend/facades/site-settings.md b/docs/src/pages/system-reference/backend/facades/site-settings.md
new file mode 100644
index 000000000..14fd5f9ae
--- /dev/null
+++ b/docs/src/pages/system-reference/backend/facades/site-settings.md
@@ -0,0 +1,68 @@
+---
+sidebarPos: 19
+sidebarTitle: SiteSettings
+---
+
+# SiteSettings
+
+**Facade**: `Unusualify\Modularous\Facades\SiteSettings`
+**Accessor**: `site.settings`
+**Underlying**: `Modules\Cms\Services\SiteSettingsService`
+
+Context-aware router over CMS and System settings. Prefer this facade in public layouts and when code may run in both frontend and panel contexts. Full guide: [Settings Services](/system-reference/backend/services/settings/overview).
+
+| Context | Behaviour |
+|---------|-----------|
+| Frontend (non-panel HTTP) | [`CmsSettings`](./cms-settings) with fallback to [`SystemSettings`](./system-settings) |
+| Backend / panel / console | [`SystemSettings`](./system-settings) only |
+
+## Methods
+
+### Context
+
+| Method | Signature | Description |
+|--------|-----------|-------------|
+| `forFrontend` | `(): SiteSettingsService` | Force CMS layer + System fallback |
+| `forBackend` | `(): SiteSettingsService` | Force System only |
+| `forCms` | `(): SiteSettingsService` | Alias of `forFrontend()` |
+| `forSystem` | `(): SiteSettingsService` | Alias of `forBackend()` |
+| `usesCmsLayer` | `(): bool` | Whether the CMS layer is active |
+| `cms` | `(): CmsSettingsService` | Underlying CMS service |
+| `system` | `(): SystemSettingsService` | Underlying System service |
+
+### Shared read/write
+
+Same signatures as the singular services — see [Shared API](/system-reference/backend/services/settings/overview#shared-api): `get`, `value`, `first`, `all`, `has`, `filled`, `set`, `forgetCache`, `snapshot`.
+
+On the CMS layer, `set` writes to CMS only; `forgetCache` clears both layers.
+
+## Usage
+
+```blade
+
+```
+
+```php
+use Unusualify\Modularous\Facades\SiteSettings;
+
+// Auto: frontend → CMS+fallback, panel/console → System
+$name = SiteSettings::get('site.name', config('app.name'));
+
+// Explicit
+$public = SiteSettings::forFrontend()->get('seo.robots_txt');
+SiteSettings::forBackend()->set('seo.robots_txt', $body);
+
+// System-only keys still resolve on frontend via fallback
+$gtm = SiteSettings::forFrontend()->get('analytics.gtm_id');
+```
+
+```php
+public function __construct(
+ protected \Modules\Cms\Services\SiteSettingsService $siteSettings,
+) {}
+```
+
+## Notes
+
+- Analytics snippets: [`AnalyticsScripts`](/system-reference/backend/services/settings/overview#analyticsscripts) (reads through this router).
+- Empty CMS leaves do not overwrite System values in `all()` / `snapshot()`.
diff --git a/docs/src/pages/system-reference/backend/facades/system-settings.md b/docs/src/pages/system-reference/backend/facades/system-settings.md
new file mode 100644
index 000000000..bd77bd29e
--- /dev/null
+++ b/docs/src/pages/system-reference/backend/facades/system-settings.md
@@ -0,0 +1,53 @@
+---
+sidebarPos: 20
+sidebarTitle: SystemSettings
+---
+
+# SystemSettings
+
+**Facade**: `Unusualify\Modularous\Facades\SystemSettings`
+**Accessor**: `system.settings`
+**Underlying**: `Modules\SystemSetting\Services\SystemSettingsService`
+**Model**: `Modules\SystemSetting\Entities\General` (`IsSingular`)
+
+Backend / system source of truth for global settings. Full usage guide: [Settings Services](/system-reference/backend/services/settings/overview).
+
+## Sections
+
+`site`, `social`, `contact`, `seo`, `smtp`, `analytics`, `locale`
+
+## Methods
+
+Shared singular API — see [Shared API](/system-reference/backend/services/settings/overview#shared-api).
+
+| Method | Signature | Description |
+|--------|-----------|-------------|
+| `get` / `value` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Dot-path read with locale / media-leaf expansion |
+| `first` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | First list element when applicable |
+| `all` | `(?string $locale = null): array` | Locale-resolved section tree |
+| `has` | `(string $key): bool` | Key exists in snapshot |
+| `filled` | `(string $key, ?string $locale = null): bool` | Resolved value is non-empty |
+| `set` | `(string $key, mixed $value): void` | Persist + forget cache |
+| `forgetCache` | `(): void` | Clear cached snapshot |
+| `snapshot` | `(): array` | Raw cached sections |
+
+## Usage
+
+```php
+use Unusualify\Modularous\Facades\SystemSettings;
+
+$host = SystemSettings::get('smtp.host');
+SystemSettings::set('locale.timezone', 'Europe/Amsterdam');
+
+$favicon = SystemSettings::value('site.favicon.original');
+```
+
+```blade
+
+```
+
+## Notes
+
+- Prefer `SystemSettings` (or `SiteSettings::forBackend()`) for SMTP, analytics, and locale — those sections are not on `SiteSetting`.
+- Optional mail override: [ApplySmtpMailConfig](/system-reference/backend/services/settings/overview#applysmtpmailconfig) via `MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE`.
+- Cache key: `system_settings.snapshot` (`modularous.system_settings.cache_ttl`).
diff --git a/docs/src/pages/system-reference/backend/facades/u-finder.md b/docs/src/pages/system-reference/backend/facades/u-finder.md
index 128e4d24b..bf3e62ceb 100644
--- a/docs/src/pages/system-reference/backend/facades/u-finder.md
+++ b/docs/src/pages/system-reference/backend/facades/u-finder.md
@@ -1,5 +1,5 @@
---
-sidebarPos: 19
+sidebarPos: 22
sidebarTitle: UFinder (deprecated)
---
diff --git a/docs/src/pages/system-reference/backend/facades/utm.md b/docs/src/pages/system-reference/backend/facades/utm.md
index 21365896f..01bd50723 100644
--- a/docs/src/pages/system-reference/backend/facades/utm.md
+++ b/docs/src/pages/system-reference/backend/facades/utm.md
@@ -1,5 +1,5 @@
---
-sidebarPos: 18
+sidebarPos: 21
sidebarTitle: Utm
---
diff --git a/docs/src/pages/system-reference/backend/overview.md b/docs/src/pages/system-reference/backend/overview.md
index f73f5a40c..cf4385d5f 100644
--- a/docs/src/pages/system-reference/backend/overview.md
+++ b/docs/src/pages/system-reference/backend/overview.md
@@ -235,9 +235,11 @@ Events fire at lifecycle boundaries (model created/updated, user registered, sta
**Directory**: `src/Facades/` · **Namespace**: `Unusualify\Modularous\Facades`
-18 Laravel facades providing static-style access to bound services. Each facade aliases a container entry to a concrete service class.
+21 Laravel facades providing static-style access to bound services. Each facade aliases a container entry to a concrete service class.
-**Selected facades**: [`Coverage`](/system-reference/backend/facades/coverage), [`CurrencyExchange`](/system-reference/backend/facades/currency-exchange), [`Filepond`](/system-reference/backend/facades/filepond), [`HostRouting`](/system-reference/backend/facades/host-routing), [`MigrationBackup`](/system-reference/backend/facades/migration-backup), [`Modularous`](/system-reference/backend/facades/modularous), [`ModularousCache`](/system-reference/backend/facades/modularous-cache), [`ModularousRoutes`](/system-reference/backend/facades/modularous-routes), [`ModularousVite`](/system-reference/backend/facades/modularous-vite), [`Navigation`](/system-reference/backend/facades/navigation), [`Redirect`](/system-reference/backend/facades/redirect), [`Register`](/system-reference/backend/facades/register), [`RelationshipGraph`](/system-reference/backend/facades/relationship-graph), [`Utm`](/system-reference/backend/facades/utm).
+**Selected facades**: [`Coverage`](/system-reference/backend/facades/coverage), [`CurrencyExchange`](/system-reference/backend/facades/currency-exchange), [`Filepond`](/system-reference/backend/facades/filepond), [`HostRouting`](/system-reference/backend/facades/host-routing), [`MigrationBackup`](/system-reference/backend/facades/migration-backup), [`Modularous`](/system-reference/backend/facades/modularous), [`ModularousCache`](/system-reference/backend/facades/modularous-cache), [`ModularousRoutes`](/system-reference/backend/facades/modularous-routes), [`ModularousVite`](/system-reference/backend/facades/modularous-vite), [`Navigation`](/system-reference/backend/facades/navigation), [`Redirect`](/system-reference/backend/facades/redirect), [`Register`](/system-reference/backend/facades/register), [`RelationshipGraph`](/system-reference/backend/facades/relationship-graph), [`SiteSettings`](/system-reference/backend/facades/site-settings), [`SystemSettings`](/system-reference/backend/facades/system-settings), [`CmsSettings`](/system-reference/backend/facades/cms-settings), [`Utm`](/system-reference/backend/facades/utm).
+
+Settings deep-dive: [Settings Services](/system-reference/backend/services/settings/overview).
→ [Full Facades reference](/system-reference/backend/facades/overview)
@@ -446,6 +448,7 @@ Bound in the service container; injected via constructor or accessed through the
| **Cache concerns** | CacheHelpers, CacheInvalidation, CacheTags | [Cache Concerns →](/system-reference/backend/services/cache-concerns/overview) |
| **Core services** | Assets, BroadcastManager, Connector, CoverageService, CurrencyExchangeService, FilepondManager, FileTranslation, MessageStage, MigrationBackup, ModularousCacheService, RedirectService, Translation, UtmParameters | [Services →](/system-reference/backend/services/overview) |
| **Currency** | NullCurrencyProvider, SystemPricingCurrencyProvider | [Currency →](/system-reference/backend/services/currency/overview) |
+| **Settings** | AbstractSingularSettingsService, SystemSettingsService, CmsSettingsService, SiteSettingsService | [Settings →](/system-reference/backend/services/settings/overview) |
| **FileLibrary** | Disk, FileService | [FileLibrary →](/system-reference/backend/services/file-library/overview) |
| **MediaLibrary** | Glide, Imgix, Local, TwicPics drivers | [MediaLibrary →](/system-reference/backend/services/media-library/overview) |
| **Uploader** | SignAzureUpload, SignS3Upload, SignUploadListener | [Uploader →](/system-reference/backend/services/uploader/overview) |
diff --git a/docs/src/pages/system-reference/backend/services/overview.md b/docs/src/pages/system-reference/backend/services/overview.md
index be2049677..c1a73a610 100644
--- a/docs/src/pages/system-reference/backend/services/overview.md
+++ b/docs/src/pages/system-reference/backend/services/overview.md
@@ -25,3 +25,4 @@ The `src/Services/` directory contains the top-level service classes that power
| [CurrencyExchangeService](/system-reference/backend/services/currency-exchange-service) | `CurrencyExchange` | Fetches and caches live exchange rates |
| [CacheRelationshipGraph](/system-reference/backend/services/cache-relationship-graph) | — | Builds a model→module-route dependency graph for targeted cache invalidation |
| [CoverageService](/system-reference/backend/services/coverage-service) | `coverage.service` | Parses Clover XML reports; generates coverage reports and PR checks |
+| [Settings](/system-reference/backend/services/settings/overview) | `SystemSettings` / `CmsSettings` / `SiteSettings` | Singular system & CMS settings (dot paths, locale, media leaves, context routing) |
diff --git a/docs/src/pages/system-reference/backend/services/settings/overview.md b/docs/src/pages/system-reference/backend/services/settings/overview.md
new file mode 100644
index 000000000..6eeb03d9e
--- /dev/null
+++ b/docs/src/pages/system-reference/backend/services/settings/overview.md
@@ -0,0 +1,280 @@
+---
+sidebarPos: 22
+sidebarTitle: Overview
+sidebarGroupTitle: Settings
+outline: deep
+---
+
+# Settings Services
+
+Singular settings are stored as JSON sections on `IsSingular` models and read through a shared API. Three facades expose the same read/write surface with different backing stores and routing rules.
+
+| Facade | Accessor | Service | Model | Sections |
+|--------|----------|---------|-------|----------|
+| [`SystemSettings`](/system-reference/backend/facades/system-settings) | `system.settings` | `Modules\SystemSetting\Services\SystemSettingsService` | `General` | `site`, `social`, `contact`, `seo`, `smtp`, `analytics`, `locale` |
+| [`CmsSettings`](/system-reference/backend/facades/cms-settings) | `cms.settings` | `Modules\Cms\Services\CmsSettingsService` | `SiteSetting` | `site`, `social`, `contact`, `seo` only |
+| [`SiteSettings`](/system-reference/backend/facades/site-settings) | `site.settings` | `Modules\Cms\Services\SiteSettingsService` | (router) | Context-aware — see below |
+
+**Abstract base**: `Unusualify\Modularous\Services\Settings\AbstractSingularSettingsService` — shared by `SystemSettingsService` and `CmsSettingsService`.
+
+## Which facade to use
+
+| Context | Prefer | Why |
+|---------|--------|-----|
+| Public Blade / frontend layouts | `SiteSettings` | Uses CMS values when filled; falls back to System |
+| Admin / panel / console | `SystemSettings` or `SiteSettings::forBackend()` | System is the backend source of truth |
+| Explicit CMS-only read/write | `CmsSettings` | No System fallback |
+| SMTP, analytics, locale | `SystemSettings` | Not editable on `SiteSetting` |
+
+`smtp`, `analytics`, and `locale` exist only on `General` / SystemSettings. Do not treat them as CMS Site Setting fields.
+
+## Context routing (`SiteSettings`)
+
+```
+SiteSettings
+├── Frontend (non-panel HTTP) → CmsSettings ──(empty)──► SystemSettings
+├── Backend / panel / console → SystemSettings only
+└── Forced
+ ├── forFrontend() / forCms() → frontend semantics
+ └── forBackend() / forSystem() → backend semantics
+```
+
+Detection (when not forced):
+
+- Console → backend (System only)
+- HTTP → CMS layer when `Modularous::isPanelUrl()` is false; otherwise System only
+
+Direct layer access:
+
+```php
+SiteSettings::cms(); // CmsSettingsService
+SiteSettings::system(); // SystemSettingsService
+SiteSettings::usesCmsLayer(); // bool
+```
+
+## Shared API
+
+Available on `SystemSettings`, `CmsSettings`, and `SiteSettings` (and on the two singular services).
+
+| Method | Signature | Description |
+|--------|-----------|-------------|
+| `get` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Dot-path read with locale / media-leaf expansion |
+| `value` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Alias of `get()` |
+| `first` | `(string $key, mixed $default = null, ?string $locale = null): mixed` | Like `get()`; if the result is a list, returns the first element |
+| `all` | `(?string $locale = null): array` | Locale-resolved tree of all sections |
+| `has` | `(string $key): bool` | Raw key exists in the snapshot (`Arr::has`) |
+| `filled` | `(string $key, ?string $locale = null): bool` | Resolved value is non-empty after locale/leaf expansion |
+| `set` | `(string $key, mixed $value): void` | Persist via repository, then forget cache |
+| `forgetCache` | `(): void` | Clear request + cache snapshots |
+| `snapshot` | `(): array` | Cached raw section tree (SMTP password decrypted when present) |
+
+### Media leaf expansion
+
+When a direct path is empty, `get` / `value` expand the last segment through locale (and optional list index):
+
+- `site.logo.frontend` → `site.logo.{locale}.frontend` → `site.logo.{locale}.0.frontend` → …
+- `site.favicon.original` / `seo.og_image.original` work the same way
+
+Locale fallback order: request locale → Modularous fallback locale → `*`.
+
+### SiteSettings fallback rules
+
+On the frontend layer:
+
+- `get` / `value` / `first` — if `CmsSettings::filled($key)` use CMS; otherwise System (then `$default`)
+- `all` / `snapshot` — `array_replace_recursive(System, filterEmpty(Cms))` so empty CMS leaves do not blank System values
+- `has` / `filled` — true if either layer has / is filled
+- `set` — writes to CMS only
+- `forgetCache` — clears both layers
+
+On the backend layer, every method delegates to SystemSettings only.
+
+---
+
+## Usage examples
+
+### Blade (frontend)
+
+```blade
+{{-- Logo / favicon with media leaf expansion --}}
+
+
+
+{{-- SEO defaults --}}
+
{{ SiteSettings::get('seo.default_meta_title', config('app.name')) }}
+
+
+{{-- Social repeater --}}
+@foreach (SiteSettings::get('social', []) as $link)
+ {{ $link['platform'] ?? '' }}
+@endforeach
+```
+
+### System-only (SMTP / backend)
+
+```php
+use Unusualify\Modularous\Facades\SystemSettings;
+
+$host = SystemSettings::get('smtp.host');
+$timezone = SystemSettings::get('locale.timezone', 'UTC');
+```
+
+```blade
+{{-- Admin favicon from System Settings --}}
+
+```
+
+### PHP — facade vs injection
+
+```php
+use Unusualify\Modularous\Facades\SiteSettings;
+use Unusualify\Modularous\Facades\SystemSettings;
+use Modules\Cms\Services\SiteSettingsService;
+use Modules\SystemSetting\Services\SystemSettingsService;
+
+// Facade
+$name = SiteSettings::get('site.name', config('app.name'));
+SystemSettings::set('smtp.host', 'smtp.example.com');
+
+// Constructor injection
+public function __construct(
+ protected SiteSettingsService $siteSettings,
+ protected SystemSettingsService $systemSettings,
+) {}
+
+public function logoUrl(): ?string
+{
+ return $this->siteSettings->forFrontend()->value('site.logo.frontend');
+}
+```
+
+### Frontend vs backend routing
+
+```php
+use Unusualify\Modularous\Facades\SiteSettings;
+
+// Force public-site semantics (CMS → System fallback)
+$publicName = SiteSettings::forFrontend()->get('site.name');
+
+// Force admin / System-only semantics
+SiteSettings::forBackend()->set('seo.robots_txt', $body);
+
+// Aliases
+SiteSettings::forCms(); // === forFrontend()
+SiteSettings::forSystem(); // === forBackend()
+```
+
+### Fallback: empty CMS → System
+
+```php
+SystemSettings::set('site.name', 'System Name');
+SystemSettings::set('site.email', 'system@example.com');
+
+// CMS empty → reads System
+SiteSettings::forFrontend()->get('site.name'); // "System Name"
+SiteSettings::forFrontend()->get('site.email'); // "system@example.com"
+
+CmsSettings::set('site.name', 'Cms Name');
+
+// Filled CMS key wins; still-empty keys keep System
+SiteSettings::forFrontend()->get('site.name'); // "Cms Name"
+SiteSettings::forFrontend()->get('site.email'); // "system@example.com"
+
+// Backend ignores CMS
+SiteSettings::forBackend()->get('site.name'); // "System Name"
+```
+
+Analytics keys are System-only; frontend `SiteSettings` still resolves them via System fallback:
+
+```php
+SystemSettings::set('analytics.gtm_id', 'GTM-XXXXXXX');
+SiteSettings::forFrontend()->get('analytics.gtm_id'); // "GTM-XXXXXXX"
+CmsSettings::filled('analytics.gtm_id'); // false
+```
+
+### Set + cache invalidation
+
+```php
+use Unusualify\Modularous\Facades\CmsSettings;
+use Unusualify\Modularous\Facades\SystemSettings;
+
+SystemSettings::set('contact.support_email', 'support@example.com');
+// set() persists through the repository and calls forgetCache()
+
+CmsSettings::set('site.tagline', 'Welcome');
+CmsSettings::forgetCache(); // also safe to call explicitly
+
+// Repository saves (admin forms) also forget system.settings / cms.settings / site.settings caches
+```
+
+Cache keys: `system_settings.snapshot`, `cms_settings.snapshot`. TTLs:
+
+| Config | Env | Default |
+|--------|-----|---------|
+| `modularous.system_settings.cache_ttl` | `MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL` | `3600` |
+| `modularous.cms_settings.cache_ttl` | `MODULAROUS_CMS_SETTINGS_CACHE_TTL` | same as system |
+
+---
+
+## Related support
+
+### `ApplySmtpMailConfig`
+
+`Modules\SystemSetting\Support\ApplySmtpMailConfig` — opt-in override of Laravel mail from SystemSettings SMTP.
+
+- Disabled by default; prefer `.env` `MAIL_*`
+- Enable with `MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE=true` (`modularous.system_settings.mail_override_enabled`)
+- Applies only when `smtp.host` is non-empty — sets `mail.default` to `smtp` and merges host / port / username / password, plus optional `from` address / name
+- Encryption helper maps `tls` / `smtp` → Laravel scheme `smtp`, and `ssl` / `smtps` → `smtps`
+- Booted from `SystemSettingServiceProvider`
+
+```env
+MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE=true
+```
+
+### `AnalyticsScripts`
+
+`Modules\SystemSetting\Support\AnalyticsScripts` — builds GTM / optional GA4 snippets.
+
+- Reads via `SiteSettingsService` (frontend CMS → System fallback; analytics values live on System)
+- Enablement: DB `analytics.enabled` when set; otherwise `modularous.system_settings.analytics_enabled_default` / `MODULAROUS_ANALYTICS_ENABLED`
+- Validates IDs (`GTM-…`, `G-…`)
+
+```blade
+{!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->headHtml() !!}
+{{-- immediately after --}}
+{!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->bodyOpenHtml() !!}
+```
+
+Or via `SiteSettings` / `SystemSettings` directly:
+
+```php
+SiteSettings::get('analytics.gtm_id');
+SystemSettings::get('analytics.enabled');
+```
+
+### `DuplicateSystemSettingsToCmsSettings`
+
+`Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings` — one-time seed that copies `General` sections into an empty `SiteSetting` (CMS sections only: `site`, `social`, `contact`, `seo`). Skips when CMS already has content. Invoked from `CmsServiceProvider` boot (and host operations when needed).
+
+```php
+app(\Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded();
+```
+
+---
+
+## Registration
+
+| Binding | Provider |
+|---------|----------|
+| `SystemSettingsService` → `system.settings` | `Modules\SystemSetting\Providers\SystemSettingServiceProvider` |
+| `CmsSettingsService` → `cms.settings` | `Modules\Cms\Providers\CmsServiceProvider` |
+| `SiteSettingsService` → `site.settings` | `Modules\Cms\Providers\CmsServiceProvider` |
+
+Composer aliases: `SystemSettings`, `CmsSettings`, `SiteSettings` → `Unusualify\Modularous\Facades\*`.
+
+## Facade pages
+
+- [SystemSettings](/system-reference/backend/facades/system-settings)
+- [CmsSettings](/system-reference/backend/facades/cms-settings)
+- [SiteSettings](/system-reference/backend/facades/site-settings)
diff --git a/lang/en/modules.php b/lang/en/modules.php
index e3cd91e84..15631bedf 100755
--- a/lang/en/modules.php
+++ b/lang/en/modules.php
@@ -107,4 +107,9 @@
'name' => 'State | States | {n} State',
],
],
+ 'system_setting' => [
+ 'general' => [
+ 'name' => 'General Settings | General Settings | {n} General Settings',
+ ],
+ ],
];
diff --git a/modules/Cms/Config/config.php b/modules/Cms/Config/config.php
index 7473e9f05..2f18c29e0 100644
--- a/modules/Cms/Config/config.php
+++ b/modules/Cms/Config/config.php
@@ -442,22 +442,52 @@
'url' => 'site-settings',
'route_name' => 'site_setting',
'icon' => 'mdi-cog-sync-outline',
- 'title_column_key' => 'key',
- 'table_options' => [
- 'createOnModal' => true,
- 'editOnModal' => true,
- ],
- 'headers' => [
- ['title' => 'Group', 'key' => 'group_key', 'searchable' => true],
- ['title' => 'Key', 'key' => 'key', 'searchable' => true],
- ['title' => 'Locale', 'key' => 'locale', 'searchable' => true],
- ['title' => 'Actions', 'key' => 'actions', 'sortable' => false],
- ],
+ 'title_column_key' => 'site.name',
'inputs' => [
- ['name' => 'group_key', 'label' => 'Group', 'type' => 'text', 'rules' => 'required'],
- ['name' => 'key', 'label' => 'Key', 'type' => 'text', 'rules' => 'required'],
- ['name' => 'locale', 'label' => 'Locale', 'type' => 'locale', 'rules' => 'required'],
- ['name' => 'value', 'label' => 'Value', 'type' => 'textarea'],
+ [
+ 'type' => '@collapsible_group',
+ 'name' => 'site',
+ 'typeIntTitle' => 'Site',
+ 'schema' => [
+ ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]],
+ ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]],
+ ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'],
+ ],
+ ],
+ [
+ 'type' => '@collapsible_wrap',
+ 'typeIntTitle' => 'Social Links',
+ 'schema' => [
+ ['type' => '@system_social_links'],
+ ],
+ ],
+ [
+ 'type' => '@collapsible_group',
+ 'typeIntTitle' => 'Contact',
+ 'name' => 'contact',
+ 'schema' => [
+ ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ],
+ ],
+ [
+ 'type' => '@collapsible_group',
+ 'name' => 'seo',
+ 'typeIntTitle' => 'SEO',
+ 'schema' => [
+ ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'],
+ ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'],
+ ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'],
+ ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'],
+ ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'],
+ ],
+ ],
],
],
'redirect' => [
diff --git a/modules/Cms/Entities/SiteSetting.php b/modules/Cms/Entities/SiteSetting.php
index 029dbcafa..34d6646cc 100644
--- a/modules/Cms/Entities/SiteSetting.php
+++ b/modules/Cms/Entities/SiteSetting.php
@@ -1,25 +1,51 @@
+ */
protected $fillable = [
- 'group_key',
- 'key',
- 'locale',
- 'value',
- 'is_active',
+ 'published',
+ 'site',
+ 'social',
+ 'contact',
+ 'seo',
];
+ /**
+ * @var array
+ */
protected $casts = [
- 'is_active' => 'boolean',
+ 'site' => 'array',
+ 'social' => 'array',
+ 'contact' => 'array',
+ 'seo' => 'array',
+ 'published' => 'boolean',
];
- public function getTable(): string
+ /**
+ * @return list
+ */
+ public static function settingsSections(): array
{
- return modularousConfig('tables.cms_site_settings', 'um_cms_site_settings');
+ return ['site', 'social', 'contact', 'seo'];
}
}
diff --git a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php
index 706e8812d..d67fd3388 100644
--- a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php
+++ b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php
@@ -5,28 +5,17 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use Modules\Cms\Http\Requests\SiteSeoSettingsRequest;
-use Modules\Cms\Services\CmsSiteSeoSettingsService;
/**
- * Persists site-wide SEO fields (session web route for the panel).
+ * @deprecated Global robots.txt is edited under System Settings → General.
*/
class SiteSeoSettingsController extends Controller
{
- public function update(SiteSeoSettingsRequest $request, CmsSiteSeoSettingsService $service): JsonResponse
+ public function update(SiteSeoSettingsRequest $request): JsonResponse
{
- if (! modularousConfig('cms_seo.robots.use_site_settings', true)) {
- return response()->json([
- 'ok' => false,
- 'message' => __('Database-backed site SEO is disabled in configuration.'),
- ], 422);
- }
-
- $data = $request->validated();
- $service->saveGlobalRobotsTxt($data['global_robots_txt'] ?? null);
-
return response()->json([
- 'ok' => true,
- 'message' => __('Site SEO settings saved.'),
- ]);
+ 'ok' => false,
+ 'message' => __('Global robots.txt is managed under System Settings → General.'),
+ ], 410);
}
}
diff --git a/modules/Cms/Http/Controllers/SiteSeoToolController.php b/modules/Cms/Http/Controllers/SiteSeoToolController.php
index fd3bb3f79..639a26997 100644
--- a/modules/Cms/Http/Controllers/SiteSeoToolController.php
+++ b/modules/Cms/Http/Controllers/SiteSeoToolController.php
@@ -7,12 +7,11 @@
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
-use Modules\Cms\Services\CmsSiteSeoSettingsService;
use Unusualify\Modularous\Facades\Modularous;
use Unusualify\Modularous\Http\Controllers\BaseController;
/**
- * Inertia shell for CMS site-wide SEO (global robots.txt body stored in site_settings).
+ * Inertia shell for CMS site-wide SEO tools (page-level SEO; robots.txt lives in System Settings).
*/
class SiteSeoToolController extends BaseController
{
@@ -25,7 +24,7 @@ public function __construct(Application $app, Request $request)
parent::__construct($app, $request);
}
- public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response
+ public function __invoke(): Response
{
$pageTitle = __('Site SEO') . ' - ' . Modularous::pageTitle();
$headerTitle = __('Site SEO');
@@ -40,20 +39,32 @@ public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response
$this->shareInertiaStoreVariables();
- $prefix = $this->module->panelRouteNamePrefix() . '.';
+ $systemSettingsUrl = $this->resolveSystemSettingsUrl();
return Inertia::render('SiteSeo', [
- 'siteSeoEndpoints' => [
- 'save' => route($prefix . 'siteSeo.save'),
- ],
- 'globalRobotsTxt' => $siteSeo->globalRobotsTxtForEditor(),
- 'useSiteSettings' => (bool) modularousConfig('cms_seo.robots.use_site_settings', true),
+ 'systemSettingsUrl' => $systemSettingsUrl,
'endpoints' => new \stdClass,
'mainConfiguration' => $this->getInertiaMainConfiguration($data),
'headLayoutData' => $this->getHeadLayoutData($data),
]);
}
+ protected function resolveSystemSettingsUrl(): ?string
+ {
+ $candidates = [
+ systemRouteNamePrefix() . '.systemsetting.general.index',
+ modularousConfig('admin_route_name_prefix', 'admin') . '.system.systemsetting.general.index',
+ ];
+
+ foreach ($candidates as $routeName) {
+ if (Route::has($routeName)) {
+ return route($routeName);
+ }
+ }
+
+ return null;
+ }
+
/**
* @return array
*/
diff --git a/modules/Cms/Http/Requests/SiteSettingRequest.php b/modules/Cms/Http/Requests/SiteSettingRequest.php
index 1500809da..4d63afd7e 100644
--- a/modules/Cms/Http/Requests/SiteSettingRequest.php
+++ b/modules/Cms/Http/Requests/SiteSettingRequest.php
@@ -8,13 +8,7 @@ class SiteSettingRequest extends Request
{
public function rulesForAll()
{
- return [
- 'group_key' => 'required|string|max:100',
- 'key' => 'required|string|max:100',
- 'locale' => 'required|string|max:12',
- 'value' => 'nullable|string',
- 'is_active' => 'nullable|boolean',
- ];
+ return [];
}
public function rulesForCreate()
diff --git a/modules/Cms/Providers/CmsServiceProvider.php b/modules/Cms/Providers/CmsServiceProvider.php
index 62405903c..6da2fbe53 100644
--- a/modules/Cms/Providers/CmsServiceProvider.php
+++ b/modules/Cms/Providers/CmsServiceProvider.php
@@ -46,6 +46,7 @@
use Modules\Cms\Services\CmsParentSegmentResolver;
use Modules\Cms\Services\CmsPromotionService;
use Modules\Cms\Services\CmsPublicModelResolver;
+use Modules\Cms\Services\CmsSettingsService;
use Modules\Cms\Services\CmsSignedPreviewTargetResolver;
use Modules\Cms\Services\CmsSignedPreviewUrlGenerator;
use Modules\Cms\Services\CmsSitemapBuildService;
@@ -58,6 +59,7 @@
use Modules\Cms\Services\DefaultCmsPromotionScopeApplier;
use Modules\Cms\Services\NullLeadDelivery;
use Modules\Cms\Services\RedirectValidationService;
+use Modules\Cms\Services\SiteSettingsService;
use Modules\Cms\Services\Stylesheet\FrameworkArtifactResolver;
use Modules\Cms\Services\Stylesheet\FrameworkScriptResolver;
use Modules\Cms\Services\Stylesheet\RootVariablesEmitter;
@@ -66,6 +68,7 @@
use Modules\Cms\Services\Stylesheet\UtilityCssGenerator;
use Modules\Cms\Support\CmsPublicUrlRegistryAboutReporter;
use Modules\Cms\Support\CmsPublicUrlRegistryCacheManager;
+use Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings;
use Unusualify\Modularous\Facades\ModularousCache;
use Unusualify\Modularous\Services\Security\SecurityService;
use Unusualify\Modularous\Services\SlugInputValidationService;
@@ -110,6 +113,11 @@ public function register(): void
$this->app->singleton(CmsSitemapCacheService::class);
$this->app->singleton(CmsAdminWarnings::class);
$this->app->singleton(CmsSiteSeoSettingsService::class);
+ $this->app->singleton(CmsSettingsService::class);
+ $this->app->alias(CmsSettingsService::class, 'cms.settings');
+ $this->app->singleton(SiteSettingsService::class);
+ $this->app->alias(SiteSettingsService::class, 'site.settings');
+ $this->app->singleton(DuplicateSystemSettingsToCmsSettings::class);
$this->app->singleton(SlugInputValidationService::class, CmsSlugInputValidationService::class);
$this->app->singleton(CmsSignedPreviewUrlGenerator::class);
$this->app->singleton(CmsSignedPreviewTargetResolver::class);
@@ -188,6 +196,12 @@ public function boot(): void
$this->registerCmsSignedPreviewRoutes();
$this->registerCmsPublicStylesheetRoutes();
$this->registerCmsPublishSchedule();
+
+ try {
+ $this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded();
+ } catch (\Throwable) {
+ // Seed is best-effort when singletons / DB are unavailable.
+ }
}
/**
diff --git a/modules/Cms/Repositories/SiteSettingRepository.php b/modules/Cms/Repositories/SiteSettingRepository.php
index 4c8ee11e2..e56faa0d6 100644
--- a/modules/Cms/Repositories/SiteSettingRepository.php
+++ b/modules/Cms/Repositories/SiteSettingRepository.php
@@ -1,58 +1,37 @@
model = $model;
}
/**
- * Single site-setting row for the given composite key (includes soft-deleted for restore semantics).
+ * @param \Illuminate\Database\Eloquent\Model $object
+ * @param array $fields
*/
- public function findScoped(string $groupKey, string $key, string $locale): ?SiteSetting
+ public function afterSave($object, $fields): void
{
- return $this->model->newQuery()
- ->withTrashed()
- ->where('group_key', $groupKey)
- ->where('key', $key)
- ->where('locale', $locale)
- ->first();
- }
+ parent::afterSave($object, $fields);
- /**
- * Persist a value or remove the row when {@code $value} is null or whitespace-only (revert to env/config fallback).
- */
- public function putScoped(string $groupKey, string $key, string $locale, ?string $value): void
- {
- if ($value === null || trim($value) === '') {
- $existing = $this->findScoped($groupKey, $key, $locale);
- if ($existing !== null) {
- $existing->forceDelete();
- }
-
- return;
+ if (app()->bound('cms.settings')) {
+ app('cms.settings')->forgetCache();
}
- $model = $this->findScoped($groupKey, $key, $locale) ?? $this->model->newInstance([
- 'group_key' => $groupKey,
- 'key' => $key,
- 'locale' => $locale,
- ]);
-
- if ($model->trashed()) {
- $model->restore();
+ if (app()->bound('site.settings')) {
+ app('site.settings')->forgetCache();
}
-
- $model->fill([
- 'value' => $value,
- 'is_active' => true,
- ]);
- $model->save();
}
}
diff --git a/modules/Cms/Resources/views/layout_builder/shell.blade.php b/modules/Cms/Resources/views/layout_builder/shell.blade.php
index 381e29e41..089f63cb0 100644
--- a/modules/Cms/Resources/views/layout_builder/shell.blade.php
+++ b/modules/Cms/Resources/views/layout_builder/shell.blade.php
@@ -5,25 +5,44 @@
- @if(! empty($seoDescription))
-
- @endif
- @if(! empty($canonicalUrl))
-
- @endif
- @if(! empty($robotsMeta))
-
- @endif
+
+ @php
+ $resolvedSiteName = SiteSettings::get('site.name', config('app.name'));
+ $resolvedMetaTitle = $seoTitle ?? $siteTitle ?? SiteSettings::get('seo.default_meta_title', $resolvedSiteName);
+ $resolvedMetaDescription = $seoDescription ?? SiteSettings::get('seo.default_meta_description', '');
+ $resolvedFavicon = SiteSettings::value('site.favicon.original') ?: asset('favicon.ico');
+ @endphp
+
+ {{ $resolvedMetaTitle }}
+
+
+
+
+
+
+
+
+
+
+
+ {{-- Optional CMP / consent (e.g. CookieFirst) must precede GTM --}}
+ {!! $consentHeadHtml ?? '' !!}
+ {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->headHtml() !!}
+
+
+
@foreach ($stylesheetHrefs ?? [] as $href)
@endforeach
+
{!! $headHtml ?? '' !!}
@if (! empty($cmsLayoutUseInjectionMarkers))
{!! $cmsLayoutMarkerHeadAppend ?? '' !!}
@endif
+ {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->bodyOpenHtml() !!}
{!! $bodyHtml ?? '' !!}
@if (! empty($cmsLayoutUseInjectionMarkers))
{!! $cmsLayoutMarkerBeforeFooter ?? '' !!}
diff --git a/modules/Cms/Routes/web.php b/modules/Cms/Routes/web.php
index e03099076..da2b5063b 100644
--- a/modules/Cms/Routes/web.php
+++ b/modules/Cms/Routes/web.php
@@ -2,10 +2,11 @@
use Illuminate\Support\Facades\Route;
use Modules\Cms\Http\Controllers\API\PromotionController;
+use Modules\Cms\Http\Controllers\LayoutBuilderHtmlPreviewController;
+use Modules\Cms\Http\Controllers\LayoutBuilderShellDraftPreviewController;
use Modules\Cms\Http\Controllers\PromotionToolController;
use Modules\Cms\Http\Controllers\SignedPublicPreviewMintController;
use Modules\Cms\Http\Controllers\SitemapController;
-use Modules\Cms\Http\Controllers\SiteSeoSettingsController;
use Modules\Cms\Http\Controllers\SiteSeoToolController;
use Unusualify\Modularous\Facades\ModularousRoutes;
diff --git a/modules/Cms/Services/CmsSettingsService.php b/modules/Cms/Services/CmsSettingsService.php
new file mode 100644
index 000000000..628b0d3b4
--- /dev/null
+++ b/modules/Cms/Services/CmsSettingsService.php
@@ -0,0 +1,47 @@
+siteSettingRepository;
+ }
+
+ protected function settingsSections(): array
+ {
+ return SiteSetting::settingsSections();
+ }
+}
diff --git a/modules/Cms/Services/CmsSiteSeoSettingsService.php b/modules/Cms/Services/CmsSiteSeoSettingsService.php
index 7d1475ab7..d53226230 100644
--- a/modules/Cms/Services/CmsSiteSeoSettingsService.php
+++ b/modules/Cms/Services/CmsSiteSeoSettingsService.php
@@ -2,23 +2,15 @@
namespace Modules\Cms\Services;
-use Modules\Cms\Entities\SiteSetting;
-use Modules\Cms\Http\Controllers\Front\RobotsTxtController;
-use Modules\Cms\Repositories\SiteSettingRepository;
use Modules\Cms\Support\CmsPublicSeo;
+use Unusualify\Modularous\Facades\SiteSettings;
/**
- * Persists site-wide SEO options in {@see SiteSetting} (key-value rows).
- *
- * Global robots.txt body is stored under the configured group/key/locale and read by
- * {@see RobotsTxtController} when `cms_seo.robots.use_site_settings` is true.
+ * Site-wide SEO helpers. Global robots.txt is resolved via {@see SiteSettings}
+ * (CmsSettings on frontend with SystemSettings fallback).
*/
class CmsSiteSeoSettingsService
{
- public function __construct(
- protected SiteSettingRepository $siteSettings,
- ) {}
-
/**
* Body served at GET /robots.txt (normalized trailing newline).
*/
@@ -31,7 +23,7 @@ public function resolvedRobotsTxtBody(): string
$default = "User-agent: *\nAllow: /";
$raw = null;
- if (modularousConfig('cms_seo.robots.use_site_settings', true)) {
+ if (modularousConfig('cms_seo.robots.use_system_settings', true)) {
$persisted = $this->persistedGlobalRobotsTxt();
if ($persisted !== null) {
$raw = trim($persisted);
@@ -53,14 +45,17 @@ public function resolvedRobotsTxtBody(): string
}
/**
- * Raw value from DB, or null when unset (use env/config in UI and public fallback).
+ * Raw value from SiteSettings, or null when unset (use env/config in UI and public fallback).
*/
public function persistedGlobalRobotsTxt(): ?string
{
- [$g, $k, $locale] = $this->robotsSettingKeys();
- $row = $this->siteSettings->findScoped($g, $k, $locale);
+ if (! SiteSettings::forFrontend()->has('seo.robots_txt')) {
+ return null;
+ }
- return $row !== null ? (string) $row->value : null;
+ $value = SiteSettings::forFrontend()->get('seo.robots_txt');
+
+ return $value !== null ? (string) $value : null;
}
/**
@@ -88,21 +83,9 @@ public function globalRobotsTxtForEditor(): string
public function saveGlobalRobotsTxt(?string $value): void
{
- [$g, $k, $locale] = $this->robotsSettingKeys();
- $this->siteSettings->putScoped($g, $k, $locale, $value);
- }
-
- /**
- * @return array{0: string, 1: string, 2: string}
- */
- protected function robotsSettingKeys(): array
- {
- $cfg = (array) modularousConfig('cms_seo.robots.site_setting', []);
+ $normalized = $value === null || trim($value) === '' ? null : rtrim($value, "\r\n");
- return [
- (string) ($cfg['group_key'] ?? 'seo'),
- (string) ($cfg['key'] ?? 'global_robots_txt'),
- (string) ($cfg['locale'] ?? '*'),
- ];
+ // Panel SEO tool writes the system-wide default; CMS Site Settings can override per frontend.
+ SiteSettings::forBackend()->set('seo.robots_txt', $normalized);
}
}
diff --git a/modules/Cms/Services/SiteSettingsService.php b/modules/Cms/Services/SiteSettingsService.php
new file mode 100644
index 000000000..7340c83e5
--- /dev/null
+++ b/modules/Cms/Services/SiteSettingsService.php
@@ -0,0 +1,219 @@
+forcedContext = 'frontend';
+
+ return $clone;
+ }
+
+ /**
+ * Force SystemSettings only (backend semantics).
+ */
+ public function forBackend(): static
+ {
+ $clone = clone $this;
+ $clone->forcedContext = 'backend';
+
+ return $clone;
+ }
+
+ /**
+ * Alias of {@see forFrontend()}.
+ */
+ public function forCms(): static
+ {
+ return $this->forFrontend();
+ }
+
+ /**
+ * Alias of {@see forBackend()}.
+ */
+ public function forSystem(): static
+ {
+ return $this->forBackend();
+ }
+
+ public function usesCmsLayer(): bool
+ {
+ if ($this->forcedContext === 'frontend') {
+ return true;
+ }
+
+ if ($this->forcedContext === 'backend') {
+ return false;
+ }
+
+ if (app()->runningInConsole()) {
+ return false;
+ }
+
+ try {
+ return ! Modularous::isPanelUrl();
+ } catch (\Throwable) {
+ return true;
+ }
+ }
+
+ public function get(string $key, mixed $default = null, ?string $locale = null): mixed
+ {
+ if ($this->usesCmsLayer()) {
+ if ($this->cmsSettings->filled($key, $locale)) {
+ return $this->cmsSettings->get($key, null, $locale);
+ }
+
+ return $this->systemSettings->get($key, $default, $locale);
+ }
+
+ return $this->systemSettings->get($key, $default, $locale);
+ }
+
+ public function value(string $key, mixed $default = null, ?string $locale = null): mixed
+ {
+ return $this->get($key, $default, $locale);
+ }
+
+ public function first(string $key, mixed $default = null, ?string $locale = null): mixed
+ {
+ if ($this->usesCmsLayer()) {
+ if ($this->cmsSettings->filled($key, $locale)) {
+ return $this->cmsSettings->first($key, null, $locale);
+ }
+
+ return $this->systemSettings->first($key, $default, $locale);
+ }
+
+ return $this->systemSettings->first($key, $default, $locale);
+ }
+
+ /**
+ * @return array
+ */
+ public function all(?string $locale = null): array
+ {
+ if (! $this->usesCmsLayer()) {
+ return $this->systemSettings->all($locale);
+ }
+
+ return array_replace_recursive(
+ $this->systemSettings->all($locale),
+ $this->filterEmptyBranches($this->cmsSettings->all($locale)),
+ );
+ }
+
+ public function has(string $key): bool
+ {
+ if ($this->usesCmsLayer()) {
+ return $this->cmsSettings->has($key) || $this->systemSettings->has($key);
+ }
+
+ return $this->systemSettings->has($key);
+ }
+
+ public function filled(string $key, ?string $locale = null): bool
+ {
+ if ($this->usesCmsLayer()) {
+ return $this->cmsSettings->filled($key, $locale) || $this->systemSettings->filled($key, $locale);
+ }
+
+ return $this->systemSettings->filled($key, $locale);
+ }
+
+ public function set(string $key, mixed $value): void
+ {
+ if ($this->usesCmsLayer()) {
+ $this->cmsSettings->set($key, $value);
+
+ return;
+ }
+
+ $this->systemSettings->set($key, $value);
+ }
+
+ public function forgetCache(): void
+ {
+ $this->cmsSettings->forgetCache();
+ $this->systemSettings->forgetCache();
+ }
+
+ /**
+ * @return array
+ */
+ public function snapshot(): array
+ {
+ if ($this->usesCmsLayer()) {
+ return array_replace_recursive(
+ $this->systemSettings->snapshot(),
+ $this->filterEmptyBranches($this->cmsSettings->snapshot()),
+ );
+ }
+
+ return $this->systemSettings->snapshot();
+ }
+
+ public function cms(): CmsSettingsService
+ {
+ return $this->cmsSettings;
+ }
+
+ public function system(): SystemSettingsService
+ {
+ return $this->systemSettings;
+ }
+
+ /**
+ * Drop empty leaves so SystemSettings values remain visible under array_replace_recursive.
+ *
+ * @param array $tree
+ * @return array
+ */
+ protected function filterEmptyBranches(array $tree): array
+ {
+ $filtered = [];
+
+ foreach ($tree as $key => $value) {
+ if (is_array($value)) {
+ $nested = $this->filterEmptyBranches($value);
+ if ($nested !== []) {
+ $filtered[$key] = $nested;
+ }
+
+ continue;
+ }
+
+ if ($value !== null && $value !== '') {
+ $filtered[$key] = $value;
+ }
+ }
+
+ return $filtered;
+ }
+}
diff --git a/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php
new file mode 100644
index 000000000..499c4931d
--- /dev/null
+++ b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php
@@ -0,0 +1,72 @@
+hasMeaningfulContent($siteSetting)) {
+ return false;
+ }
+
+ if (! $this->hasMeaningfulContent($general)) {
+ return false;
+ }
+
+ $fields = ['published' => (bool) ($general->published ?? true)];
+
+ foreach (SiteSetting::settingsSections() as $section) {
+ $value = $general->getAttribute($section);
+ $fields[$section] = is_array($value) ? $value : [];
+ }
+
+ $this->siteSettingRepository->update($siteSetting->id, $fields);
+ $this->cmsSettings->forgetCache();
+
+ return true;
+ }
+
+ protected function hasMeaningfulContent(object $model): bool
+ {
+ foreach (SiteSetting::settingsSections() as $section) {
+ $value = $model->getAttribute($section);
+
+ if (is_array($value) && $value !== []) {
+ return true;
+ }
+
+ if ($value !== null && $value !== '' && $value !== []) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/modules/Cms/Transformers/SiteSettingResource.php b/modules/Cms/Transformers/SiteSettingResource.php
new file mode 100644
index 000000000..8caad966f
--- /dev/null
+++ b/modules/Cms/Transformers/SiteSettingResource.php
@@ -0,0 +1,33 @@
+
+ */
+ public function toArray($request): array
+ {
+ $data = parent::toArray($request);
+
+ if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) {
+ $password = $data['smtp']['password'];
+ $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null;
+ }
+
+ foreach ((array) modularousConfig('cms_settings.sensitive_keys', modularousConfig('system_settings.sensitive_keys', ['smtp.password'])) as $dotKey) {
+ if (Arr::has($data, $dotKey)) {
+ $value = Arr::get($data, $dotKey);
+ Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null);
+ }
+ }
+
+ return $data;
+ }
+}
diff --git a/modules/SystemSetting/Config/config.php b/modules/SystemSetting/Config/config.php
index 2cf8f658f..325dce1dd 100644
--- a/modules/SystemSetting/Config/config.php
+++ b/modules/SystemSetting/Config/config.php
@@ -8,68 +8,115 @@
'routes' => [
'general' => [
'name' => 'General',
- 'headline' => 'Generals',
+ 'headline' => 'General Settings',
'url' => 'generals',
'route_name' => 'general',
'icon' => '$submodule',
- 'title_column_key' => 'name',
- 'table_options' => [
- 'createOnModal' => true,
- 'editOnModal' => true,
- 'isRowEditing' => false,
- 'rowActionsType' => 'inline',
- ],
- 'headers' => [
+ 'title_column_key' => 'site.name',
+ 'inputs' => [
[
- 'title' => 'Name',
- 'key' => 'name',
- 'formatter' => [
- 'edit',
+ 'type' => '@collapsible_group',
+ 'typeIntTitle' => 'SMTP',
+ 'name' => 'smtp',
+ 'col' => ['cols' => 12],
+ 'schema' => [
+ ['name' => 'host', 'type' => 'text', 'label' => 'Host', 'placeholder' => 'smtp.example.com', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'port', 'type' => 'number-input', 'label' => 'Port', 'placeholder' => 587, 'rules' => 'nullable|integer', 'col' => ['cols' => 12, 'lg' => 6]],
+ [
+ 'name' => 'encryption',
+ 'type' => 'select',
+ 'label' => 'Encryption',
+ 'col' => ['cols' => 12, 'lg' => 6],
+ 'returnObject' => false,
+ 'itemTitle' => 'title',
+ 'itemValue' => 'value',
+ 'setFirstDefault' => true,
+ 'default' => 'tls',
+ 'items' => [
+ ['title' => 'TLS', 'value' => 'tls'],
+ ['title' => 'SSL', 'value' => 'ssl'],
+ ],
+ 'rules' => 'nullable|string|in:,tls,ssl',
+ ],
+ ['name' => 'username', 'type' => 'text', 'label' => 'Username', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'password', 'type' => 'password', 'label' => 'Password', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'from_address', 'type' => 'text', 'label' => 'From address', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'from_name', 'type' => 'text', 'label' => 'From name', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
],
- 'searchable' => true,
],
[
- 'title' => 'Created Time',
- 'key' => 'created_at',
- 'formatter' => [
- 'date',
- 'long',
+ 'type' => '@collapsible_group',
+ 'typeIntTitle' => 'Contact',
+ 'name' => 'contact',
+ 'schema' => [
+ ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
],
- 'searchable' => true,
],
[
- 'title' => 'Actions',
- 'key' => 'actions',
- 'sortable' => false,
+ 'type' => '@collapsible_group',
+ 'typeIntTitle' => 'Locale & region',
+ 'name' => 'locale',
+ 'schema' => [
+ ['name' => 'timezone', 'type' => 'text', 'label' => 'Timezone', 'rules' => 'nullable|string|max:64'],
+ ['name' => 'default_currency', 'type' => 'text', 'label' => 'Default currency', 'rules' => 'nullable|string|max:8'],
+ ['name' => 'date_format', 'type' => 'text', 'label' => 'Date format', 'rules' => 'nullable|string|max:32'],
+ ],
],
- ],
- 'inputs' => [
[
- 'name' => 'name',
- 'label' => 'Name',
- 'type' => 'text',
+ 'type' => '@collapsible_group',
+ 'typeIntTitle' => 'Analytics',
+ 'name' => 'analytics',
+ 'schema' => [
+ [
+ 'name' => 'enabled',
+ 'type' => 'switch',
+ 'label' => 'Enable analytics',
+ 'default' => false,
+ 'hideDetails' => 'auto',
+ 'rules' => 'nullable|boolean',
+ 'col' => ['cols' => 12],
+ ],
+ ['name' => 'gtm_id', 'type' => 'text', 'label' => 'GTM container ID', 'placeholder' => 'GTM-XXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'ga_measurement_id', 'type' => 'text', 'label' => 'GA measurement ID', 'placeholder' => 'G-XXXXXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]],
+ ],
],
- // [
- // 'name' => 'balance',
- // 'label' => 'Balance',
- // 'type' => 'text',
- // 'spreadable' => true,
- // ],
+
[
- 'name' => 'cont',
- 'label' => 'Spread',
- 'type' => 'spread',
- 'connector' => 'SystemSetting:General',
- 'reservedKeys' => ['name'],
- 'height' => '250px',
- // 'scrollable' => true,
+ 'type' => '@collapsible_group',
+ 'name' => 'site',
+ 'typeIntTitle' => 'Site',
+ 'schema' => [
+ ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]],
+ ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]],
+ ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]],
+ ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'],
+ ],
],
[
- 'name' => 'logo',
- 'label' => 'Logo',
- 'type' => 'image',
- 'translated' => true,
+ 'type' => '@collapsible_wrap',
+ 'typeIntTitle' => 'Social Links',
+ 'schema' => [
+ ['type' => '@system_social_links'],
+ ],
],
+ [
+ 'type' => '@collapsible_group',
+ 'name' => 'seo',
+ 'typeIntTitle' => 'SEO',
+ 'schema' => [
+ ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'],
+ ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'],
+ ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'],
+ ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'],
+ ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'],
+ ],
+ ]
],
],
],
diff --git a/modules/SystemSetting/Entities/General.php b/modules/SystemSetting/Entities/General.php
index 5e8a9e60c..ffb179366 100644
--- a/modules/SystemSetting/Entities/General.php
+++ b/modules/SystemSetting/Entities/General.php
@@ -1,33 +1,51 @@
- */
- use HasImages, HasSpreadable, IsSingular;
+ use HasImages, IsSingular, HasRepeaters;
/**
- * Override the default `spread_payload` so the trait's
- * `getSpreadableSavingKey()` returns `cont`, matching the input
- * name in Config/config.php and the entry in `$fillable`. Without
- * this, SpreadHydrate would rewrite the input name to
- * `spread_payload` and the form column would silently desync.
+ * @var list
*/
- protected static $spreadableSavingKey = 'cont';
-
protected $fillable = [
- 'name',
'published',
- 'cont',
+ 'site',
+ 'social',
+ 'contact',
+ 'seo',
+ 'smtp',
+ 'analytics',
+ 'locale',
];
+
+ /**
+ * @var array
+ */
+ protected $casts = [
+ 'site' => 'array',
+ 'social' => 'array',
+ 'contact' => 'array',
+ 'seo' => 'array',
+ 'smtp' => 'array',
+ 'analytics' => 'array',
+ 'locale' => 'array',
+ 'published' => 'boolean',
+ ];
+
+ /**
+ * @return list
+ */
+ public static function settingsSections(): array
+ {
+ return ['site', 'social', 'contact', 'seo', 'smtp', 'analytics', 'locale'];
+ }
}
diff --git a/modules/SystemSetting/Providers/SystemSettingServiceProvider.php b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php
new file mode 100644
index 000000000..5e3d4a349
--- /dev/null
+++ b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php
@@ -0,0 +1,76 @@
+app->singleton(SystemSettingsService::class);
+ $this->app->alias(SystemSettingsService::class, 'system.settings');
+
+ $this->app->singleton(AnalyticsScripts::class);
+ $this->app->singleton(ApplySmtpMailConfig::class);
+ $this->app->singleton(SystemSettingsInputMerger::class);
+ $this->app->singleton(MigrateRobotsTxtFromSiteSetting::class);
+ }
+
+ public function boot(): void
+ {
+ $this->registerInputAliases();
+ $this->mergeRouteInputs();
+
+ $this->app->make(ApplySmtpMailConfig::class)->apply();
+ try {
+ } catch (\Throwable) {
+ // Mail override is best-effort when settings / DB are unavailable.
+ }
+
+ if ($this->app->runningInConsole()) {
+ return;
+ }
+
+ try {
+ $this->app->make(MigrateRobotsTxtFromSiteSetting::class)->migrateIfNeeded();
+ } catch (\Throwable) {
+ // Migration is best-effort when CMS tables are absent.
+ }
+ }
+
+ protected function mergeRouteInputs(): void
+ {
+ $module = Modularous::find('SystemSetting');
+
+ if ($module === null) {
+ return;
+ }
+
+ $defaultInputs = (array) $module->getConfig('routes.general.inputs', []);
+ $merged = $this->app->make(SystemSettingsInputMerger::class)->merge($defaultInputs);
+
+ $module->setConfig($merged, 'routes.general.inputs');
+ }
+
+ protected function registerInputAliases(): void
+ {
+ $aliases = (array) modularousConfig('system_settings.input_aliases', []);
+
+ foreach ($aliases as $alias => $definition) {
+ if (! is_string($alias) || ! is_array($definition)) {
+ continue;
+ }
+
+ config(["modularous.input_types.{$alias}" => $definition]);
+ }
+ }
+}
diff --git a/modules/SystemSetting/Repositories/GeneralRepository.php b/modules/SystemSetting/Repositories/GeneralRepository.php
index a070964b9..aca9c5186 100644
--- a/modules/SystemSetting/Repositories/GeneralRepository.php
+++ b/modules/SystemSetting/Repositories/GeneralRepository.php
@@ -1,17 +1,97 @@
model = $model;
}
+
+ public function prepareFieldsBeforeCreate($fields)
+ {
+ $fields = $this->encryptSmtpPassword($fields);
+
+ return parent::prepareFieldsBeforeCreate($fields);
+ }
+
+ /**
+ * @param Model $object
+ * @param array $fields
+ * @return array
+ */
+ public function prepareFieldsBeforeSave($object, $fields)
+ {
+ $fields = $this->encryptSmtpPassword($fields, $object);
+
+ return parent::prepareFieldsBeforeSave($object, $fields);
+ }
+
+ /**
+ * @param Model $object
+ * @param array $fields
+ */
+ public function afterSave($object, $fields): void
+ {
+ parent::afterSave($object, $fields);
+
+ if (app()->bound('system.settings')) {
+ app('system.settings')->forgetCache();
+ }
+
+ if (app()->bound('site.settings')) {
+ app('site.settings')->forgetCache();
+ }
+ }
+
+ /**
+ * @param array $fields
+ * @return array
+ */
+ protected function encryptSmtpPassword(array $fields, mixed $object = null): array
+ {
+ $password = Arr::get($fields, 'smtp.password');
+
+ if ($password === null) {
+ return $fields;
+ }
+
+ if ($password === '') {
+ if ($object !== null) {
+ $existing = is_array($object->smtp ?? null) ? $object->smtp : [];
+ Arr::set($fields, 'smtp.password', $existing['password'] ?? null);
+ }
+
+ return $fields;
+ }
+
+ if (is_string($password) && ! $this->alreadyEncrypted($password)) {
+ Arr::set($fields, 'smtp.password', encrypt($password, false));
+ }
+
+ return $fields;
+ }
+
+ protected function alreadyEncrypted(string $value): bool
+ {
+ try {
+ decrypt($value, false);
+
+ return true;
+ } catch (\Throwable) {
+ return false;
+ }
+ }
}
diff --git a/modules/SystemSetting/Services/SystemSettingsService.php b/modules/SystemSetting/Services/SystemSettingsService.php
new file mode 100644
index 000000000..118bbde21
--- /dev/null
+++ b/modules/SystemSetting/Services/SystemSettingsService.php
@@ -0,0 +1,47 @@
+generalRepository;
+ }
+
+ protected function settingsSections(): array
+ {
+ return General::settingsSections();
+ }
+}
diff --git a/modules/SystemSetting/Support/AnalyticsScripts.php b/modules/SystemSetting/Support/AnalyticsScripts.php
new file mode 100644
index 000000000..7a93c3cf3
--- /dev/null
+++ b/modules/SystemSetting/Support/AnalyticsScripts.php
@@ -0,0 +1,132 @@
+settings->get('analytics.enabled');
+
+ if ($db === null || $db === '') {
+ return (bool) modularousConfig(
+ 'system_settings.analytics_enabled_default',
+ env('MODULAROUS_ANALYTICS_ENABLED', false)
+ );
+ }
+
+ return filter_var($db, FILTER_VALIDATE_BOOLEAN);
+ }
+
+ public function gtmId(): ?string
+ {
+ return $this->normalizeId(
+ $this->settings->get('analytics.gtm_id'),
+ '/^GTM-[A-Z0-9]+$/i'
+ );
+ }
+
+ public function gaMeasurementId(): ?string
+ {
+ return $this->normalizeId(
+ $this->settings->get('analytics.ga_measurement_id'),
+ '/^G-[A-Z0-9]+$/i'
+ );
+ }
+
+ /**
+ * GTM bootstrap + optional gtag.js for GA4 (only when measurement ID is set).
+ */
+ public function headHtml(): string
+ {
+ if (! $this->isEnabled()) {
+ return '';
+ }
+
+ $parts = [];
+
+ $gtmId = $this->gtmId();
+ if ($gtmId !== null) {
+ $escaped = e($gtmId);
+ $parts[] = <<
+
+
+HTML;
+ }
+
+ $gaId = $this->gaMeasurementId();
+ if ($gaId !== null) {
+ $escaped = e($gaId);
+ $parts[] = <<
+
+
+HTML;
+ }
+
+ return implode("\n", $parts);
+ }
+
+ /**
+ * GTM noscript iframe for immediately after the opening body tag.
+ */
+ public function bodyOpenHtml(): string
+ {
+ if (! $this->isEnabled()) {
+ return '';
+ }
+
+ $gtmId = $this->gtmId();
+ if ($gtmId === null) {
+ return '';
+ }
+
+ $escaped = e($gtmId);
+
+ return <<
+
+
+HTML;
+ }
+
+ protected function normalizeId(mixed $value, string $pattern): ?string
+ {
+ if (! is_string($value) && ! is_numeric($value)) {
+ return null;
+ }
+
+ $id = trim((string) $value);
+ if ($id === '' || ! preg_match($pattern, $id)) {
+ return null;
+ }
+
+ return strtoupper($id);
+ }
+}
diff --git a/modules/SystemSetting/Support/ApplySmtpMailConfig.php b/modules/SystemSetting/Support/ApplySmtpMailConfig.php
new file mode 100644
index 000000000..78b3b92d6
--- /dev/null
+++ b/modules/SystemSetting/Support/ApplySmtpMailConfig.php
@@ -0,0 +1,80 @@
+ 'smtp',
+ 'host' => $host,
+ 'port' => is_numeric($port) ? (int) $port : null,
+ 'username' => is_string($username) && $username !== '' ? $username : null,
+ 'password' => is_string($password) && $password !== '' ? $password : null,
+ // 'scheme' => $this->mapEncryptionToScheme($encryption),
+ ], static fn ($value) => $value !== null);
+
+ config([
+ 'mail.default' => 'smtp',
+ 'mail.mailers.smtp' => array_merge(
+ (array) config('mail.mailers.smtp', []),
+ $mailerConfig,
+ ),
+ ]);
+
+ if (is_string($fromAddress) && $fromAddress !== '') {
+ config(['mail.from.address' => $fromAddress]);
+ }
+
+ if (is_string($fromName) && $fromName !== '') {
+ config(['mail.from.name' => $fromName]);
+ }
+ }
+
+ /**
+ * Map admin encryption values to Laravel SMTP mailer schemes.
+ *
+ * Laravel only accepts "smtp" and "smtps" as schemes.
+ */
+ private function mapEncryptionToScheme(mixed $encryption): ?string
+ {
+ if (! is_string($encryption) || $encryption === '') {
+ return null;
+ }
+
+ return match (strtolower($encryption)) {
+ 'ssl', 'smtps' => 'smtps',
+ 'tls', 'smtp' => 'smtp',
+ default => 'smtp',
+ };
+ }
+}
diff --git a/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php
new file mode 100644
index 000000000..4d6dec839
--- /dev/null
+++ b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php
@@ -0,0 +1,79 @@
+legacyRobotsKeys();
+
+ $row = DB::table($legacyTable)
+ ->where('group_key', $group)
+ ->where('key', $key)
+ ->where('locale', $locale)
+ ->whereNull('deleted_at')
+ ->first();
+
+ if ($row === null || trim((string) ($row->value ?? '')) === '') {
+ return false;
+ }
+
+ $general = General::single();
+ $seo = is_array($general->seo) ? $general->seo : [];
+ $seo['robots_txt'] = rtrim((string) $row->value, "\r\n");
+
+ $this->generalRepository->update($general->id, [
+ 'seo' => $seo,
+ ]);
+
+ $this->systemSettings->forgetCache();
+
+ return true;
+ }
+
+ /**
+ * @return array{0: string, 1: string, 2: string}
+ */
+ protected function legacyRobotsKeys(): array
+ {
+ $cfg = (array) modularousConfig('cms_seo.robots.legacy_site_setting', []);
+
+ return [
+ (string) ($cfg['group_key'] ?? 'seo'),
+ (string) ($cfg['key'] ?? 'global_robots_txt'),
+ (string) ($cfg['locale'] ?? '*'),
+ ];
+ }
+}
diff --git a/modules/SystemSetting/Support/SystemSettingsInputMerger.php b/modules/SystemSetting/Support/SystemSettingsInputMerger.php
new file mode 100644
index 000000000..abe3c145f
--- /dev/null
+++ b/modules/SystemSetting/Support/SystemSettingsInputMerger.php
@@ -0,0 +1,95 @@
+|string> $defaultInputs
+ * @return array>
+ */
+ public function merge(array $defaultInputs): array
+ {
+ $config = (array) modularousConfig('system_settings', []);
+ $inputs = $this->expandAliasesInList($defaultInputs);
+ $inputs = $this->applyOverrides($inputs, (array) ($config['input_overrides'] ?? []));
+ $inputs = array_merge($inputs, $this->expandAliasesInList((array) ($config['extra_inputs'] ?? [])));
+
+ $hidden = array_values(array_filter((array) ($config['hidden_inputs'] ?? [])));
+
+ if ($hidden === []) {
+ return $inputs;
+ }
+
+ return array_values(array_filter(
+ $inputs,
+ fn (array $input) => ! in_array((string) ($input['name'] ?? ''), $hidden, true)
+ ));
+ }
+
+ /**
+ * @param array> $inputs
+ * @param array> $overrides
+ * @return array>
+ */
+ protected function applyOverrides(array $inputs, array $overrides): array
+ {
+ if ($overrides === []) {
+ return $inputs;
+ }
+
+ return array_map(function (array $input) use ($overrides): array {
+ $name = (string) ($input['name'] ?? '');
+
+ if ($name === '' || ! isset($overrides[$name])) {
+ return $input;
+ }
+
+ return array_replace_recursive($input, $overrides[$name]);
+ }, $inputs);
+ }
+
+ /**
+ * @param array|string> $inputs
+ * @return array>
+ */
+ protected function expandAliasesInList(array $inputs): array
+ {
+ $resolved = [];
+
+ foreach ($inputs as $input) {
+ if (is_string($input)) {
+ $definition = modularousConfig('input_types.' . $input);
+ if (is_array($definition)) {
+ $resolved[] = $definition;
+
+ continue;
+ }
+
+ $hydrated = function_exists('hydrate_input_type')
+ ? hydrate_input_type(['type' => $input])
+ : null;
+ if (is_array($hydrated)) {
+ $resolved[] = $hydrated;
+ }
+
+ continue;
+ }
+
+ if (is_array($input)) {
+ if (isset($input['type']) && is_string($input['type']) && str_starts_with($input['type'], '@')) {
+ $input = function_exists('hydrate_input_type') ? hydrate_input_type($input) : $input;
+ }
+
+ $resolved[] = $input;
+ }
+ }
+
+ return $resolved;
+ }
+}
diff --git a/modules/SystemSetting/Transformers/GeneralResource.php b/modules/SystemSetting/Transformers/GeneralResource.php
index e451b44b6..9569b6950 100644
--- a/modules/SystemSetting/Transformers/GeneralResource.php
+++ b/modules/SystemSetting/Transformers/GeneralResource.php
@@ -1,16 +1,33 @@
*/
public function toArray($request): array
{
- return parent::toArray($request);
+ $data = parent::toArray($request);
+
+ if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) {
+ $password = $data['smtp']['password'];
+ $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null;
+ }
+
+ foreach ((array) modularousConfig('system_settings.sensitive_keys', ['smtp.password']) as $dotKey) {
+ if (Arr::has($data, $dotKey)) {
+ $value = Arr::get($data, $dotKey);
+ Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null);
+ }
+ }
+
+ return $data;
}
}
diff --git a/modules/SystemSetting/module.json b/modules/SystemSetting/module.json
index 2a14a347b..9fa86d6fa 100644
--- a/modules/SystemSetting/module.json
+++ b/modules/SystemSetting/module.json
@@ -5,7 +5,7 @@
"keywords": [],
"priority": 0,
"providers": [
-
+ "Modules\\SystemSetting\\Providers\\SystemSettingServiceProvider"
],
"files": []
}
diff --git a/resources/views/partials/favicons.blade.php b/resources/views/partials/favicons.blade.php
index f9e79ebfe..c918891e3 100644
--- a/resources/views/partials/favicons.blade.php
+++ b/resources/views/partials/favicons.blade.php
@@ -4,14 +4,24 @@
$faviconVersion = fn (string $path) => file_exists(public_path($path))
? asset($path) . '?v=' . filemtime(public_path($path))
: asset($path);
+
+ // Panel/admin always uses SystemSettings (not SiteSettings / CmsSettings).
+ $dbFavicon = SystemSettings::value('site.favicon.original');
@endphp
-
-
-
-
-@if (file_exists(public_path('favicon.svg')))
-
+@if (! empty($dbFavicon))
+
+
+
+
+@else
+
+
+
+
+ @if (file_exists(public_path('favicon.svg')))
+
+ @endif
+
+
@endif
-
-
diff --git a/src/Entities/Media.php b/src/Entities/Media.php
index c01f4231c..1477175f5 100755
--- a/src/Entities/Media.php
+++ b/src/Entities/Media.php
@@ -86,6 +86,7 @@ public function mediableFormat()
'name' => $this->filename,
'thumbnail' => ImageService::getCmsUrl($this->uuid, ['h' => '256']),
'original' => ImageService::getRawUrl($this->uuid),
+ 'frontend' => ImageService::getFrontendUrl($this->uuid),
'medium' => ImageService::getUrl($this->uuid, ['h' => '430']),
'width' => $this->width,
'height' => $this->height,
diff --git a/src/Entities/Traits/Core/ModelHelpers.php b/src/Entities/Traits/Core/ModelHelpers.php
index a07ce2183..777b735c2 100755
--- a/src/Entities/Traits/Core/ModelHelpers.php
+++ b/src/Entities/Traits/Core/ModelHelpers.php
@@ -96,9 +96,13 @@ public static function bootModelHelpers()
// dd($attributesToBeLogged);
if ($batchActivityModel) {
+ $batchActivityProperties = $batchActivityModel->properties;
+ if($batchActivityProperties instanceof \Illuminate\Support\Collection) {
+ $batchActivityProperties = $batchActivityProperties->toArray();
+ }
$batchActivityModel->update(
[
- 'properties' => array_merge_recursive_preserve($batchActivityModel->properties, $properties),
+ 'properties' => array_merge_recursive_preserve($batchActivityProperties, $properties),
]
);
} else {
diff --git a/src/Facades/CmsSettings.php b/src/Facades/CmsSettings.php
new file mode 100644
index 000000000..0f8f59b80
--- /dev/null
+++ b/src/Facades/CmsSettings.php
@@ -0,0 +1,29 @@
+{$item['name']} ?? null;
+ $acc[$item['name']] = data_get($object, $item['name'], null) ?? null;
break;
default:
diff --git a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php
index 75f2ecb74..388d55c74 100644
--- a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php
+++ b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php
@@ -79,6 +79,10 @@ protected function attachmentRoleIsPresentInFields(array $fields, string $role):
return true;
}
+ if(data_get($fields, $role) !== null) {
+ return true;
+ }
+
foreach (getLocales() as $locale) {
if (array_key_exists($role, $fields[$locale] ?? [])) {
return true;
@@ -97,6 +101,10 @@ protected function getAttachmentPayloadForRole(array $fields, string $role): mix
return $fields[$role];
}
+ if(($notation = data_get($fields, $role)) !== null) {
+ return $notation;
+ }
+
$nested = [];
foreach (getLocales() as $locale) {
if (array_key_exists($role, $fields[$locale] ?? [])) {
diff --git a/src/Repositories/Traits/FilesTrait.php b/src/Repositories/Traits/FilesTrait.php
index 6c4b604a3..a835a9f0b 100755
--- a/src/Repositories/Traits/FilesTrait.php
+++ b/src/Repositories/Traits/FilesTrait.php
@@ -101,17 +101,19 @@ public function afterSaveFilesTrait($object, $fields)
*/
private function detachFilesForRoleLocale($object, string $role, string $locale): void
{
- $relatedKey = $object->files()->getRelated()->getQualifiedKeyName();
- $ids = $object->files()
- ->wherePivot('role', $role)
- ->wherePivot('locale', $locale)
- ->pluck($relatedKey);
-
- if ($ids->isEmpty()) {
+ $relation = $object->files();
+ $deleted = $relation->newPivotStatement()
+ ->where($relation->getMorphType(), $object->getMorphClass())
+ ->where($relation->getForeignPivotKeyName(), $object->getKey())
+ ->where('role', $role)
+ ->where('locale', $locale)
+ ->delete();
+
+ if ($deleted === 0) {
return;
}
- $object->files()->detach($ids->all());
+ $object->unsetRelation('files');
$this->mustTouchEloquentModel();
}
diff --git a/src/Repositories/Traits/ImagesTrait.php b/src/Repositories/Traits/ImagesTrait.php
index be744b086..edb91287c 100755
--- a/src/Repositories/Traits/ImagesTrait.php
+++ b/src/Repositories/Traits/ImagesTrait.php
@@ -6,9 +6,11 @@
use Illuminate\Support\Collection;
use Unusualify\Modularous\Entities\Media;
use Unusualify\Modularous\Entities\Model;
+use Unusualify\Modularous\Repositories\Traits\Concerns\InteractsWithAttachmentPayloads;
trait ImagesTrait
{
+ use InteractsWithAttachmentPayloads;
/**
* When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveImagesTrait` during pending-only
* revision saves so {@see afterSaveImagesTrait} is skipped.
@@ -199,20 +201,21 @@ public function afterSaveImagesTrait($object, $fields)
*/
private function detachMediasForRoleLocale($object, string $role, string $locale): void
{
- $relatedKey = $object->medias()->getRelated()->getQualifiedKeyName();
- $relation = $object->medias()->wherePivot('role', $role);
+ $relation = $object->medias();
+ $query = $relation->newPivotStatement()
+ ->where($relation->getMorphType(), $object->getMorphClass())
+ ->where($relation->getForeignPivotKeyName(), $object->getKey())
+ ->where('role', $role);
if (modularousConfig('media_library.translated_form_fields', false)) {
- $relation->wherePivot('locale', $locale);
+ $query->where('locale', $locale);
}
- $ids = $relation->pluck($relatedKey);
-
- if ($ids->isEmpty()) {
+ if ($query->delete() === 0) {
return;
}
- $object->medias()->detach($ids->all());
+ $object->unsetRelation('medias');
$this->mustTouchEloquentModel();
}
@@ -242,9 +245,10 @@ private function attachImageSpecsFromRows($object, array $rows, string $role, st
public function getFormFieldsImagesTrait($object, $fields, $schema)
{
$imageInputs = $this->getColumns(__TRAIT__);
+
if (! empty($imageInputs) && $object->has('medias')) {
$schema = $schema ?? $this->inputs();
- $schema = $this->chunkInputs($schema, all: true, noGroupChunk: true);
+ $schema = $this->chunkInputs($schema, all: true, noGroupChunk: false);
$mediasByRole = $object->medias->groupBy('pivot.role');
$default_locale = config('app.locale');
$fallback_locale = config('app.fallback_locale');
diff --git a/src/Services/MediaLibrary/Local.php b/src/Services/MediaLibrary/Local.php
index 53379a3cb..909ffe244 100755
--- a/src/Services/MediaLibrary/Local.php
+++ b/src/Services/MediaLibrary/Local.php
@@ -64,6 +64,21 @@ public function getCmsUrl($id, array $params = [])
return $this->getRawUrl($id);
}
+ /**
+ * @param string $id
+ * @param array $params
+ * @return string
+ */
+ public function getFrontendUrl($id, array $params = [])
+ {
+ /** @var FilesystemAdapter $disk */
+ $disk = Storage::disk(config(modularousBaseKey().'.media_library.disk'));
+ $storageUrl = $disk->url($id);
+ $endpoint = parse_url($storageUrl, PHP_URL_PATH) ?: $storageUrl;
+
+ return rtrim(modularousConfig('app_url'), '/') . '/' . ltrim($endpoint, '/');
+ }
+
/**
* @param string $id
* @return string
diff --git a/src/Services/Settings/AbstractSingularSettingsService.php b/src/Services/Settings/AbstractSingularSettingsService.php
new file mode 100644
index 000000000..c62c7c8ca
--- /dev/null
+++ b/src/Services/Settings/AbstractSingularSettingsService.php
@@ -0,0 +1,415 @@
+|null */
+ private ?array $requestSnapshot = null;
+
+ abstract protected function cacheKey(): string;
+
+ abstract protected function cacheTtl(): int;
+
+ /**
+ * @return class-string
+ */
+ abstract protected function modelClass(): string;
+
+ abstract protected function repository(): Repository;
+
+ /**
+ * @return list
+ */
+ abstract protected function settingsSections(): array;
+
+ /**
+ * Get a nested setting by dot path, resolving locale maps along the way.
+ *
+ * When the direct path is empty and the key has at least two segments, expands through
+ * locale (+ optional first list index) using the last segment as an arbitrary leaf:
+ * `site.logo.frontend` → `site.logo.{locale}.frontend`
+ * → `site.logo.{locale}.0.frontend`
+ * → `site.logo.{locale}.frontend.0`
+ */
+ public function get(string $key, mixed $default = null, ?string $locale = null): mixed
+ {
+ $resolvedLocale = $locale ?? app()->getLocale();
+ $snapshot = $this->snapshot();
+ $segments = $key === '' ? [] : explode('.', $key);
+
+ $value = $this->walkPath($snapshot, $key, $resolvedLocale);
+
+ if (! $this->isEmptySettingValue($value) && ! $this->isExpandableListResult($value, $segments)) {
+ return $value;
+ }
+
+ if (count($segments) >= 2) {
+ $leaf = (string) $segments[array_key_last($segments)];
+ $parentKey = implode('.', array_slice($segments, 0, -1));
+ $expanded = $this->resolveLeafViaLocalePaths($snapshot, $parentKey, $leaf, $resolvedLocale);
+
+ if (! $this->isEmptySettingValue($expanded)) {
+ return $expanded;
+ }
+ }
+
+ if (! $this->isEmptySettingValue($value)) {
+ return $value;
+ }
+
+ return $default;
+ }
+
+ /**
+ * Alias of {@see get()}.
+ */
+ public function value(string $key, mixed $default = null, ?string $locale = null): mixed
+ {
+ return $this->get($key, $default, $locale);
+ }
+
+ /**
+ * Get a setting; when the resolved value is a list, return its first element.
+ */
+ public function first(string $key, mixed $default = null, ?string $locale = null): mixed
+ {
+ $resolved = $this->get($key, null, $locale);
+
+ if ($resolved === null) {
+ return $default;
+ }
+
+ if (is_array($resolved) && Arr::isList($resolved)) {
+ return $resolved[0] ?? $default;
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * @return array
+ */
+ public function all(?string $locale = null): array
+ {
+ $snapshot = $this->snapshot();
+ $resolvedLocale = $locale ?? app()->getLocale();
+
+ return $this->resolveTranslatedTree($snapshot, $resolvedLocale);
+ }
+
+ public function has(string $key): bool
+ {
+ return Arr::has($this->snapshot(), $key);
+ }
+
+ /**
+ * True when the resolved value for {@see $key} is non-empty (after locale/leaf expansion).
+ */
+ public function filled(string $key, ?string $locale = null): bool
+ {
+ return ! $this->isEmptySettingValue($this->get($key, null, $locale));
+ }
+
+ public function set(string $key, mixed $value): void
+ {
+ $modelClass = $this->modelClass();
+ $model = $modelClass::single();
+ $fields = $this->snapshotFromModel($model);
+ Arr::set($fields, $key, $value);
+
+ $this->repository()->update($model->id, $fields);
+ $this->forgetCache();
+ }
+
+ public function forgetCache(): void
+ {
+ $this->requestSnapshot = null;
+ Cache::forget($this->cacheKey());
+ ModularousCache::forget($this->cacheKey());
+ }
+
+ /**
+ * @return array
+ */
+ public function snapshot(): array
+ {
+ if ($this->requestSnapshot !== null) {
+ return $this->requestSnapshot;
+ }
+
+ $ttl = $this->cacheTtl();
+
+ $this->requestSnapshot = Cache::remember($this->cacheKey(), $ttl, function (): array {
+ return $this->buildSnapshotFromDatabase();
+ });
+
+ return $this->requestSnapshot;
+ }
+
+ /**
+ * @return array
+ */
+ protected function buildSnapshotFromDatabase(): array
+ {
+ $modelClass = $this->modelClass();
+
+ if (! class_exists($modelClass)) {
+ return $this->defaultSnapshot();
+ }
+
+ try {
+ $model = $modelClass::single();
+ } catch (\Throwable) {
+ return $this->defaultSnapshot();
+ }
+
+ return $this->snapshotFromModel($model);
+ }
+
+ /**
+ * @param TModel $model
+ * @return array
+ */
+ protected function snapshotFromModel(object $model): array
+ {
+ $snapshot = $this->defaultSnapshot();
+
+ foreach ($this->settingsSections() as $section) {
+ $value = $model->getAttribute($section);
+ if ($value !== null) {
+ $snapshot[$section] = is_array($value) ? $value : [];
+ }
+ }
+
+ if (isset($snapshot['smtp']['password']) && is_string($snapshot['smtp']['password']) && $snapshot['smtp']['password'] !== '') {
+ $snapshot['smtp']['password'] = $this->decryptSecret($snapshot['smtp']['password']);
+ }
+
+ return $snapshot;
+ }
+
+ /**
+ * @return array
+ */
+ protected function defaultSnapshot(): array
+ {
+ $snapshot = [];
+
+ foreach ($this->settingsSections() as $section) {
+ $snapshot[$section] = [];
+ }
+
+ return $snapshot;
+ }
+
+ protected function decryptSecret(string $value): string
+ {
+ try {
+ return decrypt($value, false);
+ } catch (DecryptException) {
+ return $value;
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function walkPath(array $data, string $key, string $locale): mixed
+ {
+ if ($key === '') {
+ return $this->resolveTranslatedValue($data, $locale);
+ }
+
+ $cursor = $data;
+
+ foreach (explode('.', $key) as $segment) {
+ if (is_array($cursor) && $this->looksLikeLocaleMap($cursor)) {
+ $cursor = $this->resolveTranslatedValue($cursor, $locale);
+ }
+
+ if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) {
+ return null;
+ }
+
+ $cursor = $cursor[$segment];
+ }
+
+ return $this->resolveTranslatedValue($cursor, $locale);
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function resolveLeafViaLocalePaths(array $data, string $parentKey, string $leaf, string $locale): mixed
+ {
+ $parent = $parentKey === ''
+ ? $data
+ : $this->walkPathRaw($data, $parentKey);
+
+ if (! is_array($parent)) {
+ return null;
+ }
+
+ $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en'));
+ $locales = [];
+
+ foreach ([$locale, $fallback, '*'] as $candidate) {
+ if ($candidate === '' || in_array($candidate, $locales, true)) {
+ continue;
+ }
+
+ $locales[] = $candidate;
+ }
+
+ foreach ($locales as $tryLocale) {
+ if (! array_key_exists($tryLocale, $parent)) {
+ continue;
+ }
+
+ $listDeferred = null;
+
+ foreach ([
+ "{$tryLocale}.{$leaf}",
+ "{$tryLocale}.0.{$leaf}",
+ "{$tryLocale}.{$leaf}.0",
+ ] as $relativePath) {
+ $candidate = data_get($parent, $relativePath);
+
+ if ($this->isEmptySettingValue($candidate)) {
+ continue;
+ }
+
+ if (is_array($candidate) && Arr::isList($candidate)) {
+ $listDeferred ??= $candidate;
+
+ continue;
+ }
+
+ return $candidate;
+ }
+
+ if ($listDeferred !== null) {
+ return $listDeferred;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function walkPathRaw(array $data, string $key): mixed
+ {
+ if ($key === '') {
+ return $data;
+ }
+
+ $cursor = $data;
+
+ foreach (explode('.', $key) as $segment) {
+ if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) {
+ return null;
+ }
+
+ $cursor = $cursor[$segment];
+ }
+
+ return $cursor;
+ }
+
+ /**
+ * @param list $segments
+ */
+ protected function isExpandableListResult(mixed $value, array $segments): bool
+ {
+ return count($segments) >= 2
+ && is_array($value)
+ && Arr::isList($value);
+ }
+
+ protected function isEmptySettingValue(mixed $value): bool
+ {
+ return $value === null || $value === '' || $value === [];
+ }
+
+ protected function resolveTranslatedValue(mixed $value, string $locale): mixed
+ {
+ if (! is_array($value)) {
+ return $value;
+ }
+
+ if ($this->looksLikeLocaleMap($value)) {
+ $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en'));
+
+ return $value[$locale]
+ ?? $value[$fallback]
+ ?? $value['*']
+ ?? reset($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param array $tree
+ * @return array
+ */
+ protected function resolveTranslatedTree(array $tree, string $locale): array
+ {
+ $resolved = [];
+
+ foreach ($tree as $key => $value) {
+ if (is_array($value) && ! $this->looksLikeLocaleMap($value) && Arr::isAssoc($value)) {
+ $resolved[$key] = $this->resolveTranslatedTree($value, $locale);
+
+ continue;
+ }
+
+ $resolved[$key] = $this->resolveTranslatedValue($value, $locale);
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * @param array $value
+ */
+ protected function looksLikeLocaleMap(array $value): bool
+ {
+ if ($value === [] || Arr::isList($value)) {
+ return false;
+ }
+
+ foreach (array_keys($value) as $key) {
+ if (! is_string($key) || ! $this->looksLikeLocaleKey($key)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected function looksLikeLocaleKey(string $key): bool
+ {
+ if ($key === '*') {
+ return true;
+ }
+
+ return (bool) preg_match('/^[a-z]{2}([_-][A-Za-z]{2,8})?$/', $key);
+ }
+}
diff --git a/src/Traits/ManageTraits.php b/src/Traits/ManageTraits.php
index 461638d13..24448cea5 100755
--- a/src/Traits/ManageTraits.php
+++ b/src/Traits/ManageTraits.php
@@ -37,6 +37,12 @@ public function hasTranslatedInput($schema = [])
$hasTranslated = true;
break;
+ } elseif (isset($input['schema']) && is_array($input['schema'])) {
+ $hasTranslated = $this->hasTranslatedInput($input['schema']);
+
+ if ($hasTranslated) {
+ break;
+ }
}
}
diff --git a/tests/Models/MediaTest.php b/tests/Models/MediaTest.php
index a1eaa3108..6f63e66e3 100644
--- a/tests/Models/MediaTest.php
+++ b/tests/Models/MediaTest.php
@@ -175,6 +175,7 @@ public function test_mediable_format()
'name' => 'test-image.jpg',
'thumbnail' => ImageService::getCmsUrl('test-uuid-1', ['h' => '256']),
'original' => ImageService::getRawUrl('test-uuid-1'),
+ 'frontend' => ImageService::getFrontendUrl('test-uuid-1'),
'medium' => ImageService::getUrl('test-uuid-1', ['h' => '430']),
'width' => 1920,
'height' => 1080,
diff --git a/tests/Models/Traits/Core/ModelHelpersTest.php b/tests/Models/Traits/Core/ModelHelpersTest.php
index 30c2b6329..738070582 100644
--- a/tests/Models/Traits/Core/ModelHelpersTest.php
+++ b/tests/Models/Traits/Core/ModelHelpersTest.php
@@ -501,8 +501,6 @@ public function test_activity_log_options_with_json_attributes()
public function test_translatable_model_updates_existing_batch_activity()
{
- $this->expectException(\TypeError::class);
-
$this->app['config']->set('activitylog.enabled', true);
Auth::login($this->user);
@@ -531,7 +529,7 @@ public function test_translatable_model_updates_existing_batch_activity()
$batchUuid = LogBatch::getUuid();
$this->assertNotNull($batchUuid);
- Activity::create([
+ $batchActivity = Activity::create([
'log_name' => 'default',
'description' => 'created',
'subject_type' => TranslatableModel::class,
@@ -545,9 +543,30 @@ public function test_translatable_model_updates_existing_batch_activity()
],
]);
+ $activitiesBefore = Activity::forBatch($batchUuid)
+ ->where('subject_type', TranslatableModel::class)
+ ->where('subject_id', $model->id)
+ ->count();
+
$model->translate('en')->name = 'Updated Name';
$model->translate('en')->description = 'Updated Description';
- $model->save(); // TypeError thrown here
+ $model->save();
+
+ $activitiesAfter = Activity::forBatch($batchUuid)
+ ->where('subject_type', TranslatableModel::class)
+ ->where('subject_id', $model->id)
+ ->count();
+
+ $this->assertEquals($activitiesBefore, $activitiesAfter);
+
+ $batchActivity->refresh();
+
+ $this->assertEquals('Updated Name', $batchActivity->properties['attributes']['en']['name']);
+ $this->assertEquals('Updated Description', $batchActivity->properties['attributes']['en']['description']);
+ $this->assertEquals('Original Name', $batchActivity->properties['old']['en']['name']);
+ $this->assertEquals('Original Description', $batchActivity->properties['old']['en']['description']);
+
+ LogBatch::endBatch();
}
public function test_translatable_model_creates_new_activity_when_no_batch_activity_exists()
diff --git a/tests/Modules/Cms/SiteSettingsServiceTest.php b/tests/Modules/Cms/SiteSettingsServiceTest.php
new file mode 100644
index 000000000..808be8e3b
--- /dev/null
+++ b/tests/Modules/Cms/SiteSettingsServiceTest.php
@@ -0,0 +1,97 @@
+app->singleton(SystemSettingsService::class);
+ $this->app->alias(SystemSettingsService::class, 'system.settings');
+ $this->app->singleton(CmsSettingsService::class);
+ $this->app->alias(CmsSettingsService::class, 'cms.settings');
+ $this->app->singleton(SiteSettingsService::class);
+ $this->app->alias(SiteSettingsService::class, 'site.settings');
+ }
+
+ public function test_backend_context_reads_system_settings_only(): void
+ {
+ SystemSettings::set('site.name', 'System Name');
+ CmsSettings::set('site.name', 'Cms Name');
+
+ $this->assertSame('System Name', SiteSettings::forBackend()->get('site.name'));
+ }
+
+ public function test_frontend_context_prefers_cms_then_falls_back_to_system(): void
+ {
+ SystemSettings::set('site.name', 'System Name');
+ SystemSettings::set('site.email', 'system@example.com');
+
+ $this->assertSame('System Name', SiteSettings::forFrontend()->get('site.name'));
+ $this->assertSame('system@example.com', SiteSettings::forFrontend()->get('site.email'));
+
+ CmsSettings::set('site.name', 'Cms Name');
+
+ $this->assertSame('Cms Name', SiteSettings::forFrontend()->get('site.name'));
+ $this->assertSame('system@example.com', SiteSettings::forFrontend()->get('site.email'));
+ }
+
+ public function test_frontend_falls_back_to_system_for_analytics_section(): void
+ {
+ SystemSettings::set('analytics.gtm_id', 'GTM-SYSTEM01');
+ SystemSettings::set('analytics.enabled', true);
+
+ $this->assertSame(
+ ['site', 'social', 'contact', 'seo'],
+ SiteSetting::settingsSections()
+ );
+ $this->assertFalse(CmsSettings::filled('analytics.gtm_id'));
+ $this->assertSame('GTM-SYSTEM01', SiteSettings::forFrontend()->get('analytics.gtm_id'));
+ $this->assertTrue((bool) SiteSettings::forFrontend()->get('analytics.enabled'));
+ }
+
+ public function test_duplicate_copies_general_into_empty_site_setting(): void
+ {
+ SystemSettings::set('site.name', 'Copied Name');
+ SystemSettings::set('contact.sales_email', 'sales@example.com');
+ SystemSettings::set('analytics.gtm_id', 'GTM-SHOULD-NOT-COPY');
+
+ $site = SiteSetting::single();
+ $this->assertTrue($this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded());
+
+ $site->refresh();
+ $this->assertSame('Copied Name', data_get($site->site, 'name'));
+ $this->assertSame('sales@example.com', data_get($site->contact, 'sales_email'));
+ $this->assertNull(data_get($site->getAttributes(), 'analytics'));
+ $this->assertSame('GTM-SHOULD-NOT-COPY', SiteSettings::forFrontend()->get('analytics.gtm_id'));
+
+ $this->assertFalse($this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded());
+ }
+
+ public function test_site_setting_is_singular_like_general(): void
+ {
+ $a = SiteSetting::single();
+ $b = SiteSetting::single();
+
+ $this->assertSame($a->id, $b->id);
+ $this->assertSame(General::single()->getTable(), $a->getTable());
+ }
+}
diff --git a/tests/Modules/SystemSetting/AnalyticsScriptsTest.php b/tests/Modules/SystemSetting/AnalyticsScriptsTest.php
new file mode 100644
index 000000000..f83a9c662
--- /dev/null
+++ b/tests/Modules/SystemSetting/AnalyticsScriptsTest.php
@@ -0,0 +1,146 @@
+app->singleton(SystemSettingsService::class);
+ $this->app->alias(SystemSettingsService::class, 'system.settings');
+ $this->app->singleton(CmsSettingsService::class);
+ $this->app->alias(CmsSettingsService::class, 'cms.settings');
+ $this->app->singleton(SiteSettingsService::class);
+ $this->app->alias(SiteSettingsService::class, 'site.settings');
+ $this->app->singleton(AnalyticsScripts::class);
+
+ config(['modularous.system_settings.analytics_enabled_default' => false]);
+
+ $this->analytics = $this->app->make(AnalyticsScripts::class);
+ }
+
+ public function test_disabled_emits_empty_snippets(): void
+ {
+ SystemSettings::set('analytics.enabled', false);
+ SystemSettings::set('analytics.gtm_id', 'GTM-MHL7RP8');
+ SystemSettings::set('analytics.ga_measurement_id', 'G-ABCDEF123');
+
+ $this->assertFalse($this->analytics->isEnabled());
+ $this->assertSame('', $this->analytics->headHtml());
+ $this->assertSame('', $this->analytics->bodyOpenHtml());
+ }
+
+ public function test_unset_enabled_falls_back_to_config_default(): void
+ {
+ $this->assertFalse($this->analytics->isEnabled());
+
+ config(['modularous.system_settings.analytics_enabled_default' => true]);
+ SystemSettings::set('analytics.gtm_id', 'GTM-MHL7RP8');
+
+ $this->assertTrue($this->analytics->isEnabled());
+ $this->assertStringContainsString('GTM-MHL7RP8', $this->analytics->headHtml());
+ }
+
+ public function test_enabled_with_gtm_emits_head_and_body_snippets(): void
+ {
+ SystemSettings::set('analytics.enabled', true);
+ SystemSettings::set('analytics.gtm_id', 'GTM-MHL7RP8');
+
+ $head = $this->analytics->headHtml();
+ $body = $this->analytics->bodyOpenHtml();
+
+ $this->assertStringContainsString('Google Tag Manager', $head);
+ $this->assertStringContainsString('GTM-MHL7RP8', $head);
+ $this->assertStringContainsString("dataLayer','GTM-MHL7RP8'", $head);
+
+ $this->assertStringContainsString('googletagmanager.com/ns.html?id=GTM-MHL7RP8', $body);
+ $this->assertStringContainsString('