---
url: /panel/settings-store.md
---
# Settings Store&#x20;

The settings store keeps a site's public business configuration in the database instead of the `.env`: tracking IDs and consent, search engine verification codes, the site name, branding, mail addresses and the company identity. Stored values are laid over the Laravel config at boot, so every consumer keeps reading `config(...)` and does not need to know where a value came from.

Why a store: the `.env` is a deployment file. The settings panel rewriting it on a running server has lost values across deploys and been shadowed by project config files, and there was no place at all for the company identity. The store gives those values one home that survives deploys and database copies, while secrets stay in the `.env`.

::: tip Since 2.18 the panel writes the store
The [Settings](/panel/settings) screen saves the fields of these groups (all but the email logo) to the store and no longer writes their `.env` variables. Existing sites take their `.env` values over once with [`evolve:settings:import`](#importing-existing-values); until then nothing breaks, the `.env` keeps applying and the panel shows an import hint. In 2.12 to 2.17 the store existed but the panel still wrote the `.env`.
:::

## Settings classes

Six classes under `Racerfish\Evolve\Settings`, built on [spatie/laravel-settings](https://github.com/spatie/laravel-settings) with their own repository and their own table `evolve_settings` (a project's own `settings` table is not touched). Every property is nullable. Each property is bridged to one config key and, where one exists, the env variable behind it.

### `TrackingSettings` (group `tracking`)

| Property | Config key | Env variable |
|---|---|---|
| `gtm_id` | `services.google.gtm-id` | `GTM_ID` |
| `ga4_measurement_id` | `services.google.analytics-measurement-id` | `ANALYTICS_MEASUREMENT_ID` |
| `meta_pixel_id` | `services.facebook.pixel-id` | `FACEBOOK_PIXEL_ID` |
| `pinterest_tag_id` | `services.pinterest.tag-id` | `PINTEREST_TAG_ID` |
| `hotjar_site_id` | `services.hotjar.site-id` | `HOTJAR_SITE_ID` |
| `linkedin_partner_id` | `services.linkedin.partner-id` | `LINKEDIN_PARTNER_ID` |
| `linkedin_lead_conversion_id` | `services.linkedin.lead-conversion-id` | `LINKEDIN_LEAD_CONVERSION_ID` |
| `conversion_data` | `services.google.conversion-data` | `ANALYTICS_CONVERSION_DATA` |
| `server_side` (bool) | `evolve.tracking.server-side` | `TRACKING_SERVER_SIDE` |
| `consent_policy` | `evolve.tracking.consent.policy` | `TRACKING_CONSENT_POLICY` |
| `cookie_banner` (bool) | `evolve.cookie-consent` | `APP_COOKIE_CONSENT` |
| `consent_via_cmp` (bool, 2.18) | `evolve.tracking.consent.via-cmp` | `TRACKING_CONSENT_VIA_CMP` |
| `consent_cmp` (string, 2.19) | `evolve.tracking.consent.cmp` | `TRACKING_CMP` |
| `cookiebot_id` (string, 2.19) | `evolve.tracking.consent.cookiebot-id` | `COOKIEBOT_ID` |

API secrets (`ANALYTICS_API_SECRET`, `FACEBOOK_CONVERSIONS_API_KEY`) and `TRACKING_EMIT` are not part of the store. Secrets stay in the `.env`; `TRACKING_EMIT` is posture, see [Environment Variables](/getting-started/environment-variables#tracking).

### `SeoSettings` (group `seo`)

| Property | Config key | Env variable |
|---|---|---|
| `google_site_verification` | `services.google.site-verification` | `GOOGLE_SITE_VERIFICATION` |
| `bing_site_verification` | `services.bing.site-verification` | `BING_SITE_VERIFICATION` |
| `pinterest_site_verification` | `services.pinterest.site-verification` | `PINTEREST_SITE_VERIFICATION` |

### `SiteSettings` (group `site`)

| Property | Config key | Env variable |
|---|---|---|
| `name` | `app.name` | `APP_NAME` |
| `ga_property_id` | `analytics.property_id` | `ANALYTICS_PROPERTY_ID` |

`name` overlays `app.name` only. The mail sender is the `mail` group below.

### `BrandingSettings` (group `branding`)

| Property | Config key | Env variable |
|---|---|---|
| `panel_color` | `evolve.theme-color` | `APP_BACKEND_THEME_COLOR` |
| `login_image_id` (int) | `evolve.login-image-id` | `APP_LOGIN_IMAGE_ID` |
| `logo_media_id` (int) | `evolve.branding.logo-media-id` | none |
| `email_logo_media_id` (int) | `evolve.branding.email-logo-media-id` | none |

The panel edits `panel_color` and `login_image_id` on the General tab and `logo_media_id` on the Company tab. `email_logo_media_id` has no field.

### `MailSettings` (group `mail`)&#x20;

| Property | Config key | Env variable |
|---|---|---|
| `from_address` | `mail.from.address` | `MAIL_FROM_ADDRESS` |
| `from_name` | `mail.from.name` | `MAIL_FROM_NAME` |
| `reply_to_address` | `evolve.mail.reply-to` | `EVOLVE_MAIL_REPLY_TO_ADDRESS` |
| `notification_address` | `evolve.mail.notification-address` | `EVOLVE_MAIL_NOTIFICATION_ADDRESS` |
| `alert_address` | `evolve.mail.alert-address` | `HEALTH_WEBMASTER_ADDRESS` |

The notification address receives new leads of forms without their own administrator address. The alert address receives technical alerts such as health check failures. Mailer and SMTP settings stay in the `.env`. What each address does is on [Mail](/getting-started/mail).

### `CompanySettings` (group `company`)

| Property | Config key |
|---|---|
| `legal_name` | `evolve.company.legal-name` |
| `brand_name` | `evolve.company.brand-name` |
| `street` | `evolve.company.street` |
| `postal_code` | `evolve.company.postal-code` |
| `locality` | `evolve.company.locality` |
| `region` | `evolve.company.region` |
| `country` | `evolve.company.country` |
| `phone` | `evolve.company.phone` |
| `email` | `evolve.company.email` |
| `same_as` (array) | `evolve.company.same-as` |
| `founding_date` | `evolve.company.founding-date` |

The `evolve.branding.*` and `evolve.company.*` keys have `null` defaults (`same-as` defaults to `[]`) and no env variable. Since 2.18 the Company tab of the panel edits them, and the theme builds the site-wide schema.org `Organization` from them once the legal name is set, see [SEO and Head](/site/seo-and-head#structured-data).

## Precedence

For every bridged config key, the first of these that is set wins:

1. **The stored value** in `evolve_settings`, when it is not `null`.
2. **The `.env` and the config files**, as Laravel resolves them.
3. **The package default** from the shipped config file.

`null` in the store means "not stored, fall back". Setting a property back to `null` and saving restores the config value on the spot.

::: danger Only null falls back
An empty string `''` or an empty array `[]` stored in a property is a value, and it overrides the `.env`. Store `null` to clear a setting, never `''`.
:::

## How the overlay works

`SettingsOverlay` runs in an `app->booting()` callback, after every provider has registered its config and before any provider boots and reads it. It runs on web requests and in the console alike.

* **Reads are cached.** The stored values are read with one query and kept in the application cache under `evolve.settings.v1`, without expiry. A warm boot costs no query (with a database cache store, one cache query).
* **Saving refreshes.** Saving any of the settings classes forgets the cache entry and applies the overlay again in the same request.
* **Missing table.** Before `php artisan migrate` has created `evolve_settings`, nothing is applied and a "missing" marker is cached. The marker is forgotten when migrations finish.
* **`config:cache` never contains stored values.** The overlay is skipped while `config:cache`, `optimize` and `package:discover` run, so the cached config file holds only `.env` and config values. At runtime the overlay is applied on top of the cached config, so stored values take effect with or without a config cache, and changing a stored value never requires rebuilding it.

## Reading and writing in code

Read through the config. That gives the resolved value whatever its source:

```php
config('services.google.gtm-id');      // stored value, else GTM_ID, else default
config('evolve.company.legal-name');   // stored value, else null
```

The settings class gives the raw stored value, `null` when nothing is stored:

```php
use Racerfish\Evolve\Settings\TrackingSettings;

app(TrackingSettings::class)->gtm_id;  // null means "not stored"
```

Writing is ordinary spatie/laravel-settings. The panel is the normal way; on a server without panel access, tinker works too:

```bash
php artisan tinker --execute='
    $settings = app(Racerfish\Evolve\Settings\TrackingSettings::class);
    $settings->gtm_id = "GTM-ABC1234";
    $settings->save();
'
```

Set the property to `null` and save to fall back to the `.env` again.

## Importing existing values

Existing sites carry these values in the `.env`. The import copies them into the store; it is explicit and per site, never automatic, because it changes where a site takes its values from. A site installed from the 2.19 starter has nothing to import: its `.env.example` carries no settings, the panel is the only place they are set.

```bash
php artisan evolve:settings:import --dry-run   # review
php artisan evolve:settings:import             # write
```

| Option | Effect |
|---|---|
| `--dry-run` | Prints the table and writes nothing. |
| `--force` | Overwrites stored values that differ from the config. Without it, only properties that are still `null` are filled. |
| `--include-defaults` | Also imports values equal to the package default (see below). |
| `--group=` | Limits the run to `tracking`, `seo`, `site`, `branding`, `company` or `mail`. An unknown group fails. |

The table lists every bridged property with the columns Setting, Source (the env variable, or the config key where there is none), Config value, Stored value and Action. The actions:

| Action | Meaning |
|---|---|
| `import` | Nothing stored yet, the config value is written. |
| `overwrite` | Only with `--force`: the stored value differs and is replaced. |
| `skip (no config value)` | The config has no value to import. |
| `skip (package default)` | The value equals the package default, see below. |
| `skip (same)` | The stored value already equals the config value. |
| `skip (stored, use --force)` | A different value is stored and `--force` was not given. |

Behaviour worth knowing:

* **Package defaults stay out of the store.** A value equal to what the shipped config resolves with no env variable set is skipped, also under `--force`. `TRACKING_CONSENT_POLICY=opt-out`, `TRACKING_SERVER_SIDE=true`, `APP_NAME=Laravel`, the default panel colour and Laravel's `hello@example.com` / `Example` sender are typical. Those keep following the package when a future release changes the default. `--include-defaults` pins them anyway.
* **It reads the config before the overlay**, so a stored value is never imported over itself.
* **Types are cast** to the property: `APP_LOGIN_IMAGE_ID=12` becomes the integer `12`, `TRACKING_SERVER_SIDE=false` the boolean `false`.
* **It is idempotent.** A second run finds nothing to do.
* **It never edits the `.env`.** The env values remain the fallback for anything set back to `null`.
* **It is logged.** Every run that changes something writes one `settings-imported` entry to the activity log with the new values, the old values and whether `--force` was used.
* It fails with a hint when `evolve_settings` does not exist yet: run `php artisan migrate` first.

## Health check

The `tracking` and `mail` blocks of `/api/health-check` carry a `sources` field that says where each value comes from: `settings` (the store), `config` (the `.env` or a config file) or `null` (unset).

```json
"tracking": {
  "gtm_id": "GTM-ABC1234",
  "consent_policy": "opt-out",
  ...
  "sources": {
    "gtm_id": "settings",
    "ga_measurement_id": "config",
    "meta_pixel_id": null,
    "pinterest_tag_id": null,
    "linkedin_partner_id": null,
    "hotjar_site_id": null,
    "consent_policy": "config",
    "cookie_banner": "settings",
    "consent_via_cmp": null,
    "server_side": "config"
  }
}
```

The `mail` block lists `from_address`, `from_name`, `reply_to_address`, `notification_address` and `alert_address` with the same `sources` map (2.18). The meta of the tracking and mailer health checks names the source of each value too (`gtm_id_source`, `notification_address_source` and so on).

The Shadowed Config health check lists config keys a project config file empties. A shadowed key that a stored setting resolves is reported under `resolved_by_settings` in the check's meta and no longer warns. See [Health Checks](/site/health-checks).

## The settings panel

Since 2.18 the panel writes the store for every group on this page: each field shows the stored value with the inherited `.env` value as placeholder, an empty field stores `null`, and switches are selects with an inherit option. Details on [Settings](/panel/settings#store-fields-and-inheritance). The former variables (`APP_NAME`, `GTM_ID`, `MAIL_FROM_ADDRESS` and the rest of the bridge) are no longer written by the panel; an outdated copy of the settings view that still posts them has them dropped and logged. The `.env` lines stay where they are and remain the fallback.

The `.env` part of the screen keeps two fixes from 2.12.

**The config cache survives a save** (#222). Deployed sites run on a cached config. Saving settings used to clear it and leave the site uncached until the next deploy. Now a save checks whether a config cache exists: without one it runs `config:clear` as before, with one it rebuilds it by running `php artisan config:cache` in a separate process (60 second timeout). If the rebuild fails (non-zero exit, no PHP CLI found, `proc_open` disabled), the cache is cleared so the new values take effect, the reason is logged at `error`, and the panel shows an error next to the success message: "Settings saved, but the configuration cache could not be rebuilt. The site works with the new values but runs uncached until the next deploy." A save without changes touches neither the `.env` nor the cache.

To verify on a server: save a setting, then `ls bootstrap/cache/config.php` still exists and `php artisan about --only=cache` reports the config as cached.

**The queue health check has three states** (#225). The Health tab shows it as a select:

| Option | Writes | Effect |
|---|---|---|
| Auto (on/off, queue driver X) | removes `HEALTH_CHECK_QUEUE` from the `.env` | The check runs when the queue driver is not `sync`. The label shows what Auto currently resolves to. |
| On | `HEALTH_CHECK_QUEUE=true` | Always runs. |
| Off | `HEALTH_CHECK_QUEUE=false` | Never runs. |

Before 2.12 the first save pinned the value to on or off and Auto could not be restored. An empty `HEALTH_CHECK_QUEUE=` written by hand now also means Auto.

## Pitfalls

* **Editing the `.env` after an import.** Once a value is stored, changing the variable in the `.env` has no visible effect. Change it in the panel, or empty the field there so the `.env` applies again. (Before 2.18 the panel itself wrote the `.env` and ran into this.)
* **Several servers, one database.** The cached map lives in the application cache. With a per-server cache store (the file driver), a save on one server forgets the entry only there; the others keep the old values until their cache is cleared. Use a shared cache store (database or redis) for multi-server setups, or clear the cache on every server after a change.
* **Changes outside the settings classes.** Editing `evolve_settings` with SQL does not forget the cached map. Run `php artisan cache:forget evolve.settings.v1` afterwards. `evolve:db:copy` and the Envoy database tasks forget it on the receiving side themselves (since 2.12.1). A deploy without `--keep-caches` clears the application cache anyway (`optimize:clear`).
* **Database copies carry the store.** Tracking IDs stored on live arrive on staging with a database copy. That is harmless for tracking, which only emits on live unless `TRACKING_EMIT` says otherwise, but check branding and company values on the copy.

## Verifying on a server

```bash
php artisan evolve:settings:import --dry-run      # what is stored, what would be imported
php artisan tinker --execute='dump(config("services.google.gtm-id"))'
curl -s -H "Authorization: Bearer $TOKEN" https://www.example.ch/api/health-check | jq .tracking.sources
```

The dry run's Stored value column shows every stored value; `sources` in the health check shows which tracking and mail values the store currently supplies.
