--- title: "Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder" date: 2026-07-04 category: docs/solutions/conventions module: "grav / plugin configuration" problem_type: convention component: tooling severity: high applies_when: - "Editing functional config for any GPM-managed Grav plugin" - "user/plugins/ is gitignored and only pages/config/accounts/themes are tracked" - "Preparing a fresh install or production cutover" - "A plugin behaves correctly locally but ships with only default config on deploy" related_components: - "grav" - "gpm" - "api plugin" - "content repo" - "deployment" tags: - grav - plugin-config - gpm - config-override - gitignore - deployment - api-plugin --- # Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder ## Context Grav resolves a plugin's config by deep-merging two layers: the plugin's own shipped file `user/plugins//.yaml` (installed by GPM, part of the package) and the tracked override `user/config/plugins/.yaml` (which wins). In this project the content repo tracks only `pages/`, `config/`, `accounts/`, `themes/`; `user/plugins/` and `user/data/` are gitignored (GPM manages plugin *code*). So any functional config a developer edits into a plugin's own `user/plugins//.yaml` is invisible to version control. It was this gap that left the `api` plugin unconfigured on the fresh prod install. Its `enabled`/`route`/`session_enabled`/cors/rate_limit config existed only in the untracked plugin folder locally, while the committed `user/config/plugins/api.yaml` held only a runtime `popularity.salt`. The local machine worked because the plugin folder had been hand-edited; every fresh environment got only the plugin's shipped defaults. ## Guidance Put **functional** plugin configuration in the TRACKED override `user/config/plugins/.yaml`. Grav deep-merges it over the plugin's shipped defaults, so it need only carry the keys that must differ (or the full config, for clarity). Keep **secrets and per-install generated values** OUT of the tracked file — JWT secrets, salts, encrypted tokens belong in gitignored `*-private.php` companion files (e.g. `api-private.php`, `security-private.php`) or should be regenerated per-install. Never rely on edits to the plugin's own `user/plugins//.yaml`: it is gitignored (won't deploy) and is overwritten on the next `php bin/gpm update`. Concrete before/after, using the `api` plugin: ```yaml # WRONG: user/plugins/api/api.yaml (gitignored, GPM-managed, wiped on update) enabled: true route: /api auth: session_enabled: true ``` ```yaml # RIGHT: user/config/plugins/api.yaml (tracked, deploys, survives gpm update) enabled: true route: /api version_prefix: v1 auth: session_enabled: true # JWT secret intentionally NOT here — it lives in the gitignored api-private.php ``` ## Why This Matters Reproducible deploys: a fresh clone or `make remote-install-` must produce a working site from the repo alone. Config stranded in the gitignored plugin folder silently yields a plugin with only its shipped defaults on every new environment — which, for a plugin whose behavior depends on non-default config, means it's misconfigured or effectively off. On prod the `api` plugin's route/auth simply didn't work. The failure is silent and per-environment: it works on the developer's machine (where the plugin folder was hand-edited) and breaks everywhere else. `gpm update` compounds it by wiping the folder edit even locally, so the "working" state is not just unshared — it is also unstable on the one machine that had it. ## When to Apply - Any time you configure a Grav plugin whose non-default settings must work on a server (prod/test) or survive a plugin update. - Especially for plugins whose function depends on config: `api` (route/auth/cors), `admin2`, `flex-objects`, form/media settings, etc. - When auditing a fresh-install failure: check whether the "working" local config actually lives in a tracked path (`git ls-files user/config/plugins/.yaml`) or was stranded in `user/plugins//`. ## Examples - **api plugin (this project):** the functional config was moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`, then `make content-push` + `make remote-fetch-content-` deployed it. The JWT secret stayed in the gitignored `api-private.php`. - **Quick audit command:** `git -C user ls-files config/plugins/` shows exactly which plugin configs are tracked/deployable; anything you rely on that isn't listed is a latent fresh-install failure. ## Related - `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the config gap documented here was the *other* latent problem surfaced in that same investigation: the `api` plugin also had to be *installed* first before any config could take effect. The install gap (GPM version floor) and this config-tracking gap compounded each other on the fresh prod environment. - `docs/working/git-sync-notes.md` — the related third config location: on prod, Grav Admin saves config into the per-environment tree `user/env//config/`, which is *also* untracked. Same "config that doesn't reach the repo" family. - `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — the sync-boomerang consequence of this rule: a per-install value that a plugin regenerates into a *tracked* functional config file (e.g. `popularity.salt` in `api.yaml`) re-commits itself and ping-pongs across environments under bidirectional git-sync. The `*-private.php` companion pattern this doc establishes is exactly the durable fix. - **CLAUDE.md §0 (plugin-management model):** only `pages/`, `config/`, `accounts/`, `themes/` are tracked in the `user/` repo; `plugins/` and `data/` are gitignored and GPM-managed. That tracking boundary is exactly why functional config must live under `config/plugins/`, not in the plugin's own folder.