Search by

nowo-tech / instance-settings-bundle

HecFranco

Runtime instance settings for Symfony apps: one settings store split into tagged sections, encrypted secret fields, admin forms and import / export (portability).

Package info

github.com/nowo-tech/InstanceSettingsBundle

Documentation

Type:symfony-bundle

pkg:composer/nowo-tech/instance-settings-bundle

Fund package maintenance!

HecFranco

Statistics

Installs: 1 889

Dependents: 5

Suggesters: 2

Stars: 0

Open Issues: 0

v1.8.0 2026-11-09 13:45 UTC

README

CI Packagist Version Packagist Downloads License PHP Symfony GitHub stars Coverage

⭐ Found this useful? Install from Packagist · Give it a star on GitHub so more developers can find it.

Instance Settings Bundle — runtime settings an operator edits without a deploy: one database-backed store split into sections that the host and other bundles contribute (mailer, SMS provider, Mercure hub, feature flags…), typed getters with defaults, secret fields encrypted at rest with DoctrineEncryptBundle, a UiKit admin UI (one page per section, FormKit forms, CSRF, roles), and a versioned JSON export / import (web page and CLI) with secrets left out or sealed with the instance key. Tested on Symfony 7.4, 8.0 and 8.1 · PHP 8.2+ (Symfony 8.x requires PHP 8.4+).

FrankenPHP Friendly Worker Mode

This bundle is FrankenPHP worker mode friendly.

Reads cost one query per request and are memoized for the current main request only, so a saved change applies on the next request in every worker, also when the kernel is not reset between requests. See docs/FRANKENPHP-WORKER-AUDIT.md.

Screenshots

Mailer section of the settings UI: tabs per section, sender fields and an encrypted transport DSN shown only as saved
One tab per section; the encrypted DSN is never rendered back
Import / export page: download a versioned JSON envelope, optionally with sealed secrets, and an all-or-nothing import with dry run
Versioned export (secrets sealed on request) and all-or-nothing import

Features

  • Sections — implement SettingsSectionInterface (or extend AbstractSettingsSection) and declare fields with types, defaults, labels, constraints and flags; autoconfigured, ordered by #[AsSettingsSection(priority:)]. Bundles such as a mailer or an SMS kit contribute their own section without depending on each other.
  • Typed reads with defaults — InstanceSettingsInterface::get('mailer.from'), getString(), getInt(), getFloat(), getBool(), getSection(); the stored value, else the field default. Twig: instance_setting('site.name').
  • Encrypted secrets — SettingField::secret() values are stored in an #[Encrypted] column (DoctrineEncrypt, Halite / Defuse), read only through getSecret(), never rendered back in forms (blank keeps, a checkbox clears), refused by get() and by Twig.
  • Worker-safe cache — every row is loaded with one query on the first read of a request, memoized for that main request, cleared by kernel.reset and after each save; SettingsChangedEvent (field names only) lets listeners reset their own caches.
  • Validation — types, required fields, e-mail / http(s) URL / choice checks and your Symfony constraints run on every save and import, all-or-nothing; messages name fields, never values.
  • Admin UI — UiKit settings scaffold (REQ-UI-003): a tab per section the user may open, FormKit form types, CSRF on every mutation, security.access_roles + per-section security.area_roles + custom access checker; extra section actions (e.g. "send a test e-mail").
  • Config portability — versioned JSON envelope (schema, version, exported_at): every portable plain field, secrets only sealed with the instance key (only an instance with the same key can import them), tagged PortabilityProviderInterface services for data stored elsewhere; size-capped upload reader, credential-shaped keys refused, dry run, single transaction. Admin page plus nowo:instance-settings:export / nowo:instance-settings:import.
  • Seed from fallbacks (DB-only mode) — sections expose their %env()% / YAML fallbacks (SectionFallbackProviderInterface); nowo:instance-settings:seed-from-fallbacks (or the public FallbackSeeder) copies them once where nothing is stored: idempotent, secrets encrypted, audited, names only in the output, safe-guarded against phantom MySQL migration transactions. Section notices show warnings (e.g. missing credentials) on the settings page.
  • Seed file with images (1.7) — nowo:instance-settings:seed loads a committed, versioned seed file (values plus logo / icon paths) fill-only and idempotently; paths confined to seed.base_dir, images through the upload checks (no SVG), secrets refused. Optional files.store: filesystem keeps one stable image file per field (ready to commit) instead of database blobs.
  • Provider-dependent fields — SettingField::withVisibleWhen('provider', ['smtp']) shows a field only for some values of another field (UiKit visible-when peer: hidden, never disabled or cleared); saves validate and write only the fields visible for the submitted provider, so the other providers' values survive a switch.
  • Image fields (1.4) — SettingField::image() for logos and icons: PNG / JPEG / still WebP detected from the content, limits checked from the header before decoding, every upload re-encoded with GD (EXIF and polyglot payloads dropped), PNG variants, stored in the database content-addressed per field, served from an immutable hashed URL outside the admin prefix (nosniff, sandbox CSP, ETag / 304; anonymous only for public fields), exported in envelope version 2 and fully re-validated on import, replaced images purged after a retention. Opt-in (files.enabled: true): hosts without image fields keep the 1.3 schema. SVG uploads are refused. getFile() / instance_setting_file().
  • Brand bridge (1.5) — opt-in brand.enabled: true: brand and appearance sections (name, texts, logos, mark, app icon, favicon, extra images; presets and seven colours per mode with WCAG contrast notices), the nowo-tech/brand-contracts BrandProviderInterface / BrandAssetReaderInterface for UiKit, Pwa, mail, legal and SEO, nowo_brand(), and UiKit's appearance provider when the host set none. Committed default assets (SVG allowed) as fallbacks; uploads raster only.
  • Crop step on image fields (1.8) — opt-in files.crop: true or SettingField::withCrop(): UiKit 2.43's crop dialog on the file input, with the aspect, minimum size, format and byte limit derived from the field's own rules (square icons crop square at their minimum size, max_bytes 95 % of maxBytes). The server still checks and re-encodes every upload; off by default (byte-identical markup).
  • Embeddable sections (1.6) — SettingsSectionEmbedder::handle() shows and saves selected sections inside a host route and layout (nav allowlist, host redirect route, template / layout / header overrides) with exactly the bundle route's checks (UI gate, section role, CSRF, secrets, image pipeline). The allowlist only narrows; redirects go to host route names, never URLs.
  • Theme cards (1.6) — brand.preset_picker: cards (or SettingField::withThemeCards() on any choice field): an accessible radio group of cards with validated #rrggbb SVG swatches instead of a select.
  • Fail-closed wiring — the container refuses to compile without DoctrineEncrypt, with the UI on but no form CSRF, with an empty role list, with allow_unauthenticated outside kernel.debug, or with files.enabled and no GD.
  • Doctrine — table instance_setting_value, plus instance_setting_file with files.enabled: true (1.4, image fields), with a configurable doctrine.table_prefix, attribute mapping, migrations-friendly.

Installation

composer require nowo-tech/instance-settings-bundle

With Symfony Flex the recipe adds config/packages/nowo_instance_settings.yaml, the route import and a security.yaml reminder. Without Flex, register NowoDoctrineEncryptBundle, NowoUiKitBundle, NowoFormKitBundle and NowoInstanceSettingsBundle, then import the routes — see docs/INSTALLATION.md.

# config/routes/nowo_instance_settings.yaml
nowo_instance_settings:
    resource: .
    type: nowo_instance_settings

Create the table with a migration (php bin/console doctrine:migrations:diff), then protect the prefix in your firewall:

# config/packages/security.yaml
security:
    access_control:
        - { path: ^/admin/settings, roles: ROLE_ADMIN }

Quick start

use Nowo\InstanceSettingsBundle\Section\AbstractSettingsSection;
use Nowo\InstanceSettingsBundle\Section\SettingField;
use Symfony\Component\Validator\Constraints\Range;

final class SiteSettingsSection extends AbstractSettingsSection
{
    public function getName(): string { return 'site'; }

    public function getFields(): iterable
    {
        yield SettingField::string('name', 'Acme', 'site.name', required: true);
        yield SettingField::int('page_size', 25, 'site.page_size', constraints: [new Range(min: 5, max: 200)]);
        yield SettingField::secret('api_key', 'site.api_key');
    }
}
public function __construct(private InstanceSettingsInterface $settings) {}

$name   = $this->settings->getString('site.name');   // 'Acme' until an admin changes it
$size   = $this->settings->getInt('site.page_size');
$apiKey = $this->settings->getSecret('site.api_key'); // decrypted, or null

Open /admin/settings: one tab per section, the form built from the fields.

Requirements

  • PHP 8.2+ (>=8.2 <8.6; Symfony 8.x requires PHP 8.4+)
  • Symfony 7.4, 8.0 or 8.1 (FrameworkBundle, Form, Validator, Security CSRF, TwigBundle; SecurityBundle for the admin UI)
  • ext-gd with PNG, JPEG and WebP support, only with files.enabled: true (image fields, 1.4) or brand.enabled: true (1.5)
  • nowo-tech/brand-contracts ^1.0, only with brand.enabled: true (1.5)
  • Doctrine ORM 3.3+ with DoctrineBundle, nowo-tech/doctrine-encrypt-bundle ^2.10 (secrets), nowo-tech/ui-kit-bundle ^2.43 and nowo-tech/form-kit-bundle ^2.17 (admin UI), twig/extra-bundle

Configuration

Every key, its default and its validation are documented in docs/CONFIGURATION.md. Safe defaults: UI under /admin/settings for ROLE_ADMIN only, sealed secrets in exports only when asked for, 1 MiB import cap, no table prefix.

Usage

Contributing a section (the API a mailer or an SMS bundle builds on), typed getters, reacting to changes, secrets, export / import and portability providers: docs/USAGE.md.

Demo

  • demo/symfony8 — Symfony 8.1 (FrankenPHP PHP 8.5, worker mode) with MySQL; host port 8079 by default. Sections Site, Mailer (encrypted DSN) and SMS (encrypted token); the home page reads them on every request.

Runtime mode is FRANKENPHP_MODE (worker default, or classic) — see docs/DEMO-FRANKENPHP.md.

make -C demo up-symfony8          # http://localhost:8079
make -C demo/symfony8 setup       # encryption key, schema, demo settings
make test-e2e                     # Playwright e2e against the running demo
make demo-screenshots             # refresh docs/images/demo/*.webp
make demo-smoke                   # boot, HTTP 200, down (REQ-TEST-011)

Development

make up
make install
make test
make cs-check
make phpstan
make igor
make test-coverage-100
make release-check

Documentation

Additional documentation

Tests and coverage

  • Tests: PHPUnit (PHP) — unit tests plus kernel integration tests (admin UI end to end with SecurityBundle, CSRF, encryption at rest on SQLite, import / export, opt-in routes, invalid configuration) and the UiKit page contract test; Playwright e2e on the demo
  • PHP: 100% (1638/1638 statements, 1864/1864 elements; gate enforced in CI by composer test-coverage-100, no exclusions)
  • TS/JS: N/A
  • Python: N/A

License and author

MIT · Nowo.tech · Héctor Franco Aceituno