Landing pages module
Module id: landingpages. Dependencies: none.
What it adds:
- Programmatic SEO: one template + a dataset of rows = N published landing pages, each at its own slug, each with its own meta-title + meta-description.
- A sidebar tab Landing pages with dataset list + per-dataset row-table editor.
- Bulk CSV import — paste a CSV, get N pages live in one click, with fail-closed validation.
- Public-route fallback: when a request slug doesn't match a regular page, the renderer tries a landing page next.
Use case: "Shopping in Amsterdam", "Shopping in Rotterdam", "Shopping
in Utrecht" — same template, different {{ vars.city }} substitution
per row.
What's per-website
Three tables (landing_template, landing_dataset, landing_dataset_row),
all created by migration. The admin only directly manages
datasets + rows. Templates are discovered automatically from the
theme repo at theme-version activation time.
How a landing page comes to exist
designer: writes templates/landing/shopping-city.html.twig
with {{ vars.city }} in it
↓
designer: commits + tags theme version (e.g. v0.4.2)
↓
operator: deploys + activates v0.4.2 in the admin
↓
[automatic]: scanner reads the new theme dir, upserts a LandingTemplate
row with variables: ["city"]
↓
operator: creates a LandingDataset named "Cities NL", picks template
"shopping-city", sets slug pattern "/shopping-in-{city|slug}"
↓
operator: imports CSV (city header + 50 rows) — or adds rows one by one
↓
public: /shopping-in-amsterdam now serves the rendered page
Templates live in the theme repo
<theme-repo>/
└── templates/
└── landing/
└── shopping-city.html.twig
A bare minimum template:
{% extends '@website/base.html.twig' %}
{% block body %}
<h1>Shopping in {{ vars.city }}</h1>
<p>The best time to go shopping in {{ vars.city }} is now!</p>
<p>There are {{ vars.coffee_count }} coffee shops nearby.</p>
{% endblock %}
That's it. The scanner picks up vars.city and vars.coffee_count
as the template's variables. The admin's dataset form auto-renders
two text inputs for each row.
What variables the scanner catches
The scanner walks templates/landing/**/*.html.twig (recursive) and
matches both vars.X and vars["X"] / vars['X'] references —
where X is an identifier ([A-Za-z_][A-Za-z0-9_]*).
Anything more dynamic — vars[someExpression()], vars[loopVar] —
is not caught. Stick to plain vars.X references and the
operator's form columns line up with what the template renders.
Templates that include partials
If a landing template includes a partial that uses vars.X, the
partial's references aren't picked up automatically — only the host
template is scanned. Either inline the markup in the landing
template, or remember to declare the variables on the host template
yourself ({% set _force_scan = vars.X %} early in the template
is one trick, though awkward).
For v1, keep landing templates flat — they're short by design.
Per-template scanning vs. per-theme
The scanner runs on theme-version activation, not on every render.
Old version's LandingTemplate rows stay in the DB (so historical
datasets pointing at them still resolve their slug-builder lookups)
but are filtered out of the admin's template picker. Bump the theme
version → scanner runs against the new dir → admin offers the new
templates.
Slug patterns
The dataset's slugPattern controls every row's URL:
| Pattern | Row variables | Resolved slug |
|---|---|---|
/shopping-in-{city|slug} |
{city: "Amsterdam"} |
/shopping-in-amsterdam |
/shopping-in-{city|slug} |
{city: "Den Bosch"} |
/shopping-in-den-bosch |
/{service|slug}/{city|slug} |
{service: "Brows", city: "Utrecht"} |
/brows/utrecht |
/products/{sku|raw} |
{sku: "PMU-001-Z"} |
/products/PMU-001-Z |
Token syntax:
{var}— defaults to|slug(lowercase, ASCII-only, runs of non-alphanumeric →-).{var|slug}— explicit slugify.{var|raw}— no transform. Use with care — only when the variable is itself constrained (a SKU, an ID). Arbitrary user input here can produce unsafe slugs.
Constraints enforced by LandingSlugBuilder:
- Must start with
/. - Can't resolve to
/alone (landing pages can't be the homepage — regularPagerows are for that). - After resolution, only
[A-Za-z0-9._~/-]characters survive. A|rawvalue with anything else hard-rejects rather than silently mangling.
Conflict with regular pages
Regular pages win. If a website has a Page with slug amsterdam
and a landing-page row with slug /shopping-in-amsterdam, both
coexist. If both resolve to the same slug, the regular page
serves and the landing row never renders.
Operators who want a hand-curated /amsterdam page later can write
one — it will shadow whatever landing page used to live there.
Conversely, deleting that hand-curated page restores the landing
page (assuming the landing-page row is still published).
There's no per-slug priority dial. The order is wired in
cms-public/src/Controller/PageController::show: try regular page
first, then landing fallback.
CSV import
The fastest way to create many landing pages. Open the dataset → Import CSV → paste content → import.
Format: header row = variable names, each subsequent row = one landing page.
city,coffee_count
Amsterdam,247
Rotterdam,189
Utrecht,134
What the admin validates before persisting anything:
- Header columns must cover the template's variables — none missing.
Three kinds of extra column are allowed on top of them:
meta_titleandmeta_description(per-row SEO overrides, see below), and any variable the dataset's slug or meta patterns reference without the template ever printing it (a{usp}column that only appears in the meta description). Anything else is rejected as a typo. - Every row must have the same column count as the header.
- The slug pattern must resolve cleanly for every row (no missing required vars, no invalid chars).
- No two rows in the CSV can resolve to the same slug.
- No row's slug can clash with an existing row in a different dataset on the same website.
If any check fails on any row, the whole import is rejected with a per-row error report. Operator fixes the CSV, re-uploads. No partial state.
Two import modes:
- Append (default): the import adds rows. A clash with this dataset's own existing rows updates them in place (idempotent re-upload); a clash with a different dataset's rows is rejected.
- Replace: deletes every existing row in this dataset before importing. Use when re-uploading a canonical source CSV.
Realistic CSV example
A salon with services × cities (45 pages = 5 services × 9 cities):
Template templates/landing/service-city.html.twig:
{% extends '@website/base.html.twig' %}
{% block body %}
<h1>{{ vars.service_label }} in {{ vars.city }}</h1>
<p class="lead">{{ vars.intro }}</p>
<section>
<h2>Boekingsinformatie</h2>
<p>Tarief vanaf {{ vars.price_from }}. Behandeling duurt
{{ vars.duration }} minuten.</p>
</section>
{% endblock %}
Dataset config:
Name: Services × Cities NL
Template: service-city
Slug pattern: /{service_slug|slug}/{city|slug}
Meta title pattern: {service_label} in {city} — PMU Studio
Meta description pattern: Boek je {service_label}-behandeling in {city}. Vanaf {price_from}, behandeling {duration} minuten.
CSV (excerpt):
service_slug,service_label,city,intro,price_from,duration
powder-brows,Powder brows,Amsterdam,De fijnste salons voor powder-brows in Amsterdam.,€350,90
powder-brows,Powder brows,Rotterdam,Rustig en hygiënisch — powder-brows in Rotterdam.,€350,90
combi-brows,Combi-brows,Amsterdam,Combineer hairstrokes + powder voor het beste van twee werelden.,€395,120
…
Imports 45 published pages at /powder-brows/amsterdam,
/combi-brows/rotterdam, etc., each with its own meta tags.
SEO
Each landing page carries:
- A
<title>derived frommetaTitlePatternor — if blank — falls back to the first variable's value (capitalised) or the dataset name. - A
<meta name="description">frommetaDescriptionPattern, or none if blank. - A
<link rel="canonical">pointing at the row's own absolute URL (website origin + stored slug). The renderer passes this inseo_meta; a template must not have to work it out. The shared seo partial's own canonical fallback askspagefor its URL, andpageis null on a landing render, so withoutseo_metaevery row in a dataset would declare/as its canonical and none of them would be indexed. If a template setsseo_metaitself it takes over that responsibility completely, canonical included. - The canonical-host redirect applies as normal — visitors hitting a non-canonical host get 301'd to the canonical equivalent before the landing renderer fires.
Cache-Control: public, s-maxage=300like regular pages. ETag mixes in the row'supdated_at+ the dataset'supdated_at+ the website's active theme version, so a row update or a theme bump invalidates the cached HTML.
Published rows are listed in /sitemap-landing.xml, which the sitemap
index advertises whenever the module is on and the dataset has at least
one published row.
Per-row overrides
The dataset patterns are the right default for a long tail of hundreds of rows: one sentence, one word swapped per row. They are the wrong answer for the handful of pages that actually earn traffic, and search consoles flag a few hundred identical descriptions as duplicates.
So a row can carry its own meta_title and meta_description, which
beat the pattern. Null means "no opinion" and the row follows the
pattern; clearing an override restores it rather than blanking the tag.
Overrides go through the same {var} interpolation, so a hand-written
description can still carry {city}.
Set them per row in the admin (the pencil in the SEO column of the row table) or as two optional CSV columns:
service,city,meta_title,meta_description
Dakreiniging,Breda,,
Dakreiniging,Amsterdam,Dakreiniging Amsterdam binnen een week,"Grachtenpand of nieuwbouw: we reinigen het dak zonder de pannen te beschadigen."
Theme helpers (when landingpages is enabled)
The module's sandbox contributor exposes nothing additional —
the operator-defined vars arrive in the render context as a plain
PHP array (the sandbox already allows property access on arrays), so
templates just write {{ vars.X }} and it works.
Also in scope on a landing render:
| Context variable | What it is |
|---|---|
vars |
The operator-defined variables for this specific row. |
website |
Same Website entity as on regular pages. |
landing.slug |
The row's resolved slug. |
landing.dataset_name |
The dataset's display name (for breadcrumbs etc.). |
landing.meta_title |
The resolved meta title. |
landing.meta_description |
The resolved meta description (may be empty). |
seo_meta |
{title, description, canonical}, already resolved. The |
| shared seo partial reads this, so a template that does | |
| nothing about SEO still gets correct tags. See below. | |
page |
null on landing pages. cms_meta(...) won't work — the |
| meta tags come from the row's resolved values, not editor input. |
Public routes
The module adds no new routes. Landing pages fire through the
existing catch-all page route in cms-public — PageController::show
calls the module's LandingPageRenderer as a fallback when the
regular page lookup misses. See
cms-public/src/Controller/PageController.php for the wiring.
Things this module does NOT do
- No multilingual landing pages. v1 stores one variables map +
one slug per row, with no locale. If you need landing pages in
multiple languages, create one dataset per locale (e.g.
cities-nl,cities-en) with different slug patterns (/nl/winkelen-in-{city}vs./en/shopping-in-{city}). - No A/B testing / variant rotation. Each row is one canonical page.
- No scheduled publishing. The
publishedflag is on/off; no "publish at" timestamp. - No dynamic data sources. Variables are static text per row;
the template can't
{% for product in cms_products() %}directly (well, it can, if the catalog module is also on, but the row's variables are static). - No UTM-param tracking built in. Visitors to landing pages get cached HTML; analytics integration is up to the theme (Google Analytics, Plausible, etc. via the standard script blocks).
- No per-row template override. The template is dataset-wide. To test a new template against the same row data, create a second dataset with the same slug pattern and the new template — but remember: same-slug clashes between datasets are rejected.
Reference
- Module:
cms-core/src/Module/LandingPagesModule.php - Entities:
cms-core/src/Entity/Landing/ - Scanner:
cms-core/src/Service/LandingTemplateScanner.php - Slug builder:
cms-core/src/Service/LandingSlugBuilder.php - Renderer:
cms-core/src/Service/LandingPageRenderer.php - Migration:
cms-core/migrations/Version20260529120000.php - Admin API:
cms-api/src/Controller/LandingDatasetsController.phpLandingDatasetRowsController.php
- Admin UI:
cms-admin/src/views/LandingIndex.vue+LandingDatasetEdit.vue - Page-controller wiring:
cms-public/src/Controller/PageController.php - Theme-activation hook:
cms-api/src/Controller/ThemesController.php