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#
- Dependencies
- Configuration — copy the template and fill in the real values:
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.
-
The database — the base schema lives in
docs/sql/. Schema changes are hand-written repeatable scripts insql/; on a deployed serverbin/migrate.phpruns them on deployment (see the deployment checklist). On your own database, where the record does not exist yet, run the first run by hand asphp 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--baselinethe script ends with an error on a database without the record. -
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
- Assets
The front-end theme is built from its own folder — see Front-end themes.
-
Write permissions —
temp/,log/,var/and the upload folders. -
Set the cron token in the administration (
1/cron.token) before wiring up the crons — without it every task returns 403. -
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#
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:
- the non-empty value of
<key>.<alias>for the page language, - otherwise the base
<key>, - 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
- Create the rows in a migration. A key read by the front end or by the admin bootstrap gets
is_public = 1in the migration already; saving the form does not change the flag on an existing row. - Add the keys to
SettingPresenter::HIDDEN_KEYS(language variants throughHIDDEN_LOCALIZED_BASE_KEYS). - Keep the dependent value (the path) out of the form; derive it and save it with the main value.
- Add the tab to
app/UI/Admin/System/Templates/@settingsTabs.latte. - 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#
- Delete
temp/cache. Nette holds the compiled DI container. - For a new language also
temp/cache/translation/— the translation catalogue does not regenerate itself. - For a new class the stale class map may be the problem.
- 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#
- The landing page.
- Signing in to the administration.
- One listing and one form in the administration.
- Submitting one form on the front end.
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