Skip to content
V
For developers
Architecture, conventions, the core and security
Getting started / Installation and setup

Installation and running#

Requirements#

What Version / note
PHP ≥ 8.3 (composer.json); verified on 8.4 and 8.5, see PHP 8.5
MySQL / MariaDB
Composer
Node.js and npm for building the assets
Redis recommended, can be turned off
Elasticsearch optional — without it search falls back to SQL
ffmpeg / ffprobe for video in galleries — without ffprobe video is rejected, images keep working
memory_limit PHP-FPM at least 256M (for images up to 40 Mpx about 320M), CLI more or -1; see Operating requirements

Procedure: getting the project running for the first time#

  1. Dependencies
composer install
npm install
  1. Configuration — copy the template and fill in the real values:
cp config/Shared/local.neon.example config/Shared/local.neon
openssl rand -hex 32          # the JWT secret

local.neon holds the database credentials (mariadb.* — the single source of truth, used by Doctrine and by nette/database alike) and jwt.secret. It is included last in config/common.neon, so it overrides every placeholder.

  1. The database — the base schema lives in docs/sql/. Schema changes are hand-written repeatable scripts in sql/; on a deployed server bin/migrate.php runs them on deployment (see the deployment checklist). On your own database, where the record does not exist yet, run the first run by hand as php bin/migrate.php --baseline: it creates the record and marks older scripts as done. So bring the database up to date by hand before it. Without --baseline the script ends with an error on a database without the record.

  2. Redis — on hosting without Redis, uncomment in local.neon:

redis:
    storage: false
    sessions: false
jwt:
    useRedis: false          # the deny-list goes into the DB (needs a cleanup cron)
ratelimit:
    useRedis: false
  1. Assets
npm run build:admin        # the administration

The front-end theme is built from its own folder — see Front-end themes.

  1. Write permissions — temp/, log/, var/ and the upload folders.

  2. Set the cron token in the administration (1/cron.token) before wiring up the crons — without it every task returns 403.

  3. The scheduled tasks per the Deployment checklist and Scheduled tasks.

local.neon does not belong in git

It holds the database password and the JWT secret. It is in .gitignore (/config/local.neon and /config/Shared/local.neon) — check that before you commit anything.

validateClass is not a migration

Validating an entity against the schema only tells you what does not match. The schema change itself has to be made by you, with a script in docs/sql/.

Procedure: turning debug mode on for a development machine#

touch config/debug-mode.flag      # gitignored, its existence = debug ON

Or through the NETTE_DEBUG=1 environment variable, which takes precedence. The production default is off; in detail Deployment checklist.

Debug mode is not configured in local.neon

It is decided in App\Bootstrap::resolveDebugMode(), that is, before the configuration is loaded. Nothing written into NEON has any effect on debug.

Configuration#

File Contents
config/common.neon the skeleton, includes the rest
config/Shared/*.neon shared configuration by area (25 files)
config/Shared/local.neon 🔴 secrets and local overrides, outside git
config/debug.neon what applies only in debug mode
config/Admin/, config/System/ per-section configuration

Part of the settings live in the database, not in files — read by SettingManager and at runtime by SettingContext.

SettingContext has two layers

setSettings() is a no-op for managers. In scripts you also have to write setServerSettings(), otherwise the settings do not reach where you expect them to.

Layer Filled by Contents Read by
client (setSettings()) the presenter at start-up on the front end only the public subset getPublicSettings() — the only thing allowed into a template or over the wire
server (setServerSettings()) the Api SettingManager in its constructor the full map: neon params + publicParams + all DB rows get() and getSettings(); the server layer wins, the client one is the fallback

Why two: settingContext is a DI singleton shared by the front presenter and by Api managers called in the same request through ApiBridge::internalCall() (their startup() does not run). Server code reads SMTP credentials, appTempDir, emailTemplatePath or feedsDir from it. The boundary secrets must not cross is the HTTP response (the getPublicMerged endpoint and the Latte variable $settings), not the memory of the server process.

Do not narrow the context to the public subset

If the server layer held only public keys, mail would stop going out and dropzone uploads would fail — the missing SMTP credentials and paths show up only in another module.

Only the public subset goes to the front end (the client) — SettingManager::getAllPublic(), endpoint :Api:System:Setting:getPublicMerged:

Source To the front end
neon publicParams (config/Shared/parameters.neon) yes — a short whitelist
neon params (directories, absolute paths) never
a cms_system_settings row with is_public = 1 yes
a cms_system_settings row with is_public = 0 no, server layer only

The is_public flag is fail-closed: in PHP and in the DB (DEFAULT 0) the default is false, and an upsert through SettingManager writes 0 for a new row and leaves the flag of an existing one untouched. A new key is therefore private until someone deliberately publishes it. The exception: a new row of a language variant (<key>.<language alias>, e.g. site.name.de) inherits is_public from its base key, otherwise the front end would silently keep showing the default language for a new language.

Publish a key a template or JS needs in the migration

Without is_public = 1 the front end does not get the value and reads emptiness — with no error. Conversely: publishing a key with a password or token (email.smtp.password) means sending it to every visitor. getMerged/getAll are for the administration only and do not read the flag.

A second layer against leaks: SettingSecrecy. The Latte variable $settings is also filled in the Admin, Api and Cron contexts, where the map legitimately holds secrets. SettingInitTrait therefore passes it through SettingSecrecy::stripSecrets(), which drops every key matching secret|password|token|salt|credential. It is not authorization — is_public stays the authority; tests/Unit/Security/SettingPublicExposureTest.php keeps the two layers independent.

Pattern Where Extra
SECRET_KEY_PATTERN stripSecrets(), the test —
CREDENTIAL_KEY_PATTERN only the migration shim in SettingManager (decides without is_public) smtp — email.smtp.host and email.smtp.username are parts of the same credentials

Do not add smtp to the narrower pattern

SECRET_KEY_PATTERN is the contract of the test and of stripSecrets(). With smtp a guarded name could no longer be told apart from a deliberately wider heuristic, and harmless SMTP keys would disappear from templates.

Language variants of a key#

cms_system_settings (module, key, value) has no language column. A language-dependent value (website identity: site.name, site.tagline) therefore lives in its own row with the language alias suffix (site.name.en); the base key without a suffix holds the default language and is the fallback for the others. A key is a flat string across the system ($map[$module][$key]); the dot nests nothing.

LocalizedSettingKey::resolve() reads like this:

  1. the non-empty value of <key>.<alias> for the page language,
  2. otherwise the base <key>,
  3. otherwise ''.

An empty language value = untranslated

A text cannot be switched off in one language only. This is deliberate: an empty field in an admin language tab must not silently erase the website name on /en/. Only keys listed by the caller (website identity) get language variants, not arbitrary keys.

A key with a dedicated settings screen#

The generic Settings → Site settings screen (SettingPresenter) lists every cms_system_settings row of modules 1 and 2 as a form field; a key it does not know goes into the "other" section under its raw name. Keys that need labels, an upload or validation have a tab of their own (operator, site identity, site appearance, admin settings) and are excluded from the generic screen. Each key is therefore changed in exactly one place.

Where the key is excluded When
SettingPresenter::HIDDEN_KEYS a list of keys edited by another tab
SettingPresenter::HIDDEN_LOCALIZED_BASE_KEYS a base key whose language variants (site.name.en) are hidden by prefix; languages are added in the database and a list would silently drift

Some keys form a pair that must match: a template and its path (theme.frontend + theme.frontend.path, theme.admin + theme.admin.path). The path therefore has no field of its own; it is derived from the selected template and saved together with it.

The admin theme is loaded before sign-in (see Admin presenter), so the login page has no identity and the admin-only get-merged answers with a denial. ThemeRepository therefore reads theme.admin* from the public map (get-public-merged) and takes them from module 1, hard-coded. An optional key (theme.admin.skin) may be missing; it does not count toward the completeness check.

Procedure: adding keys with a dedicated settings tab

  1. Create the rows in a migration. A key read by the front end or by the admin bootstrap gets is_public = 1 in the migration already; saving the form does not change the flag on an existing row.
  2. Add the keys to SettingPresenter::HIDDEN_KEYS (language variants through HIDDEN_LOCALIZED_BASE_KEYS).
  3. Keep the dependent value (the path) out of the form; derive it and save it with the main value.
  4. Add the tab to app/UI/Admin/System/Templates/@settingsTabs.latte.
  5. If the admin bootstrap reads the key, keep it in module 1 and check the login page in a signed-out browser.

A typo in the theme.admin + theme.admin.path pair = 500 on the whole admin

ThemeMapper builds the template path from it; on a mismatch DefaultThemeResolver::resolve() ends in variantExists() on null on every admin page including the login, so the error cannot be fixed from the admin. A theme key moved out of module 1 ends the same way, and so does a key without is_public = 1 on the login page once RBAC is enforced.

A key outside HIDDEN_KEYS can be changed from two places

The generic screen shows it as a bare text field without the validation of its own tab; a value saved from there bypasses the check the rest of the code relies on.

An image in the settings#

The logo, favicon, sharing image and operator photo (site.*.assetId, web.operator.avatar.assetId) are kept in the settings only as an asset id; the image is an ordinary cms_system_images record saved by the Api ImageManager, so it is also visible in the admin "Images" and gets variants by its usage.

Saving the tab (SiteIdentityFormTrait, OperatorFormTrait) picks one of three paths for each image field and carries the outcome as the triple id / failed / release:

Path Asset Setting value
no change nothing the current id (<field>CurrentId)
new file written into the same asset, a new one is created only when none existed the asset id
"Remove" deleted only after the settings are saved empty
writing the image failed nothing failed = true: the whole save is cancelled, texts included

All keys of the tab go in one SettingManager::saveBatch() batch in one transaction. When the batch fails, an asset created in this request is deleted.

null must not mean both remove and failed

If the path returned only ?int, a failed upload would write an empty value and detach the current logo while the form reports success. Hence the triple and the original id on failure.

Replacing an image = the same asset

A new asset on every replacement would leave orphan rows in cms_system_images. The Api regenerates the variants of the existing record by the usage plan.

Procedure: I changed the configuration and nothing happened#

  1. Delete temp/cache. Nette holds the compiled DI container.
  2. For a new language also temp/cache/translation/ — the translation catalogue does not regenerate itself.
  3. For a new class the stale class map may be the problem.
  4. Only then look for a bug in what you wrote.

A stale class map brings down the WHOLE site

Not with a comprehensible message — with a 500 on every page at once. After adding a language, a new module or changing the configuration, always delete temp/cache.

Verification after installation#

  1. The landing page.
  2. Signing in to the administration.
  3. One listing and one form in the administration.
  4. Submitting one form on the front end.
  5. log/ — did anything appear?

A live click-through reveals what a harness does not

A test run through the CLI bypasses plenty of layers. Before declaring anything done, click through it in a browser — in detail Tests and tooling.

Check which database you are connected to

Before trying anything out, look in config/Shared/local.neon to see where the database connection points. Development belongs on your own copy of the data, not on the database of a running site.

Where to look#

I want Where
the local configuration template config/Shared/local.neon.example
how debug mode is decided app/Bootstrap.php → resolveDebugMode()
the schema SQL scripts docs/sql/
the generic settings screen and hidden keys app/UI/Admin/System/Presenters/SettingPresenter.php
reading the admin theme app/UI/Admin/System/Models/Repositories/Api/ThemeRepository.php
the build commands package.json, composer.json
the deployment checklist Deployment checklist

Follow-up chapters: Deployment checklist · Scheduled tasks · Project structure · Tests and tooling

Cookie settings