Extra page data has three homes. Pick by what the data needs:
| storage | validated | prop. query | sortable | flat round-trip | setup | |
|---|---|---|---|---|---|---|
| 1. free-form | customProperties JSON | no | = != isSet | no | free | none |
| 2. declared | customProperties JSON | yes | typed operators | yes | free | ~10 lines of config |
| 3. own entity | own table, own columns | its own | no | yes, indexed | hand-written | a class + a CRUD |
Tier 2's edge over tier 3: customProperties round-trips through the markdown frontmatter for free. A satellite entity is invisible to the flat exporter, to pw:flat:sync and to the API's frontmatter shape.
Two levels — fleet-wide at the root, per-site under apps. A site adds properties; redeclaring one replaces the whole descriptor (never a deep merge); name: ~ un-declares a property for that site:
pushword:
page_properties:
author: { type: string }
apps:
- hosts: [example.com]
page_properties:
level: { type: string, constraints: [{ Choice: { choices: [Débutant, Initié] } }] }
price_from: { type: int, constraints: [{ Positive: ~ }] }
author: { type: string, constraints: [{ Length: { max: 60 } }] }
readingTime: ~
type: string (default) | int | float | bool | date | list. The type drives validation, the query operators and the admin widget.constraints: Symfony's validation-YAML shape — a constraint name (short name from Symfony\Component\Validator\Constraints, or a FQCN) mapping to its named constructor options. Nested constraint definitions (All, AtLeastOneOf…) are not resolved.required: reported by pw:schema:dump and the flat import summary — never enforced on save, so declaring it on a fleet with legacy gaps cannot block unrelated edits.Every schema is built at container compile: a typo'd type, option, constraint name or descriptor key fails the build instead of a request.
A bundle declares the properties it reads by implementing Pushword\Core\PropertySchema\PagePropertiesProviderInterface (autoconfigured tag pushword.page_properties_provider). Core declares toc, tocTitle and searchExcerpt. Site config overrides bundle declarations.
Property names are storage keys, verbatim: a frontmatter price_from is stored, declared and queried as price_from, never priceFrom.
bin/console pw:schema:dump [--host=…] prints, per host, the declared properties, the keys managed by dedicated admin fields, and the core frontmatter columns — agent-optimized JSON when run by an AI agent (see agent-output). The same data ships in /api/docs as x-pushword-page-properties.
Declared properties are validated on the entity (admin form and API alike — the API returns 422). Undeclared keys pass untouched. Violations name the property and land on the extra-properties textarea.
The flat import reports and never blocks: pw:flat:sync's summary gains invalid (per-slug violation messages), undeclared (keys not in the schema — the net that catches a toc_title typo'd for tocTitle) and missing_required. The exit code never changes.
API writes (POST/PUT/PATCH) carry the same findings: a successful write response gains a warnings object (undeclared, missingRequired) — only when there is something to say. Informational; the write went through.
Any custom property supports =, !=, isSet, isNotSet in pages_list. A property declared int, float or date also compares — < > <= >= — and both compare and sort numerically on every platform (SQLite's json_extract is typed; MySQL/MariaDB gets an explicit cast):
{{ pages_list([['prop.price_from', '>', 100]], 'prop.price_from ASC') }}
Pages missing the property sort last. An unquoted frontmatter date is stored as a Unix timestamp (that is what YAML parses it to), so date properties compare as timestamps.
Each declared property renders as its own input on the page form — the type picks the widget, a Choice constraint becomes a dropdown — and disappears from the free-form textarea. Labels are the humanized property name (price_from → "Price from"), translatable as natural message ids.
Emptying a field removes the key; unchecking a bool removes it too (a stored false would still satisfy null != page.toc template tests). A form opened before a declaration deployed still saves: the value typed in its textarea writes through instead of erroring.
Works today, no core change — indexed columns, ORDER BY, own repository and CRUD; invisible to the flat export/import and the API frontmatter:
namespace App\Entity;
#[ORM\Entity]
#[ORM\Table(name: 'page_seo')]
class PageSeo
{
#[ORM\Id, ORM\OneToOne(targetEntity: Page::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
public Page $page;
#[ORM\Column(type: Types::INTEGER)]
public int $readingTime = 0;
}
onDelete: CASCADE is not optional. Admin deletes and pw:flat:sync remove pages — the sync deletes every page whose .md file disappeared — and either would hit the foreign key without the cascade.LEFT JOIN — an INNER JOIN silently drops every page that has no row. Do not auto-create rows from a postPersist listener; it defeats the sparse table and puts a write in every bulk import.page.seo accessor.