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 — regular Page rows are for that).
  • After resolution, only [A-Za-z0-9._~/-] characters survive. A |raw value 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:

  1. Header columns must cover the template's variables — none missing. Three kinds of extra column are allowed on top of them: meta_title and meta_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.
  2. Every row must have the same column count as the header.
  3. The slug pattern must resolve cleanly for every row (no missing required vars, no invalid chars).
  4. No two rows in the CSV can resolve to the same slug.
  5. 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 from metaTitlePattern or — if blank — falls back to the first variable's value (capitalised) or the dataset name.
  • A <meta name="description"> from metaDescriptionPattern, or none if blank.
  • A <link rel="canonical"> pointing at the row's own absolute URL (website origin + stored slug). The renderer passes this in seo_meta; a template must not have to work it out. The shared seo partial's own canonical fallback asks page for its URL, and page is null on a landing render, so without seo_meta every row in a dataset would declare / as its canonical and none of them would be indexed. If a template sets seo_meta itself 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=300 like regular pages. ETag mixes in the row's updated_at + the dataset's updated_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 published flag 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.php
    • LandingDatasetRowsController.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