17: SEO

De seo resource centraliseert de zoek- en deelmetadata die een app nodig heeft: sitemap.xml, robots.txt, hreflang-alternatieven, de canonieke link, en een <head> blok met <meta> beschrijving plus Open Graph en Twitter kaarten. Het haalt alles af van props die je al hebt ingesteld, zodat een uniforme, complete maar beheersbare set vanuit één plek wordt geleverd.

17.1: Activatie

Voeg de resource toe (het heeft output nodig):

{
	"resources": [..., "seo", "output"]
}

Dat alleen dient twee routes:

Route Output
GET /sitemap.xml Een meertalige sitemap opgebouwd uit %app->pages en %app->langs
GET /robots.txt Allow of Disallow, afhankelijk van de indexable constant (zie 17.4)

De <head> metadata is opt-in per pagina: render %seo->head waar je het wilt (17.3).

17.2: The sitemap and hreflang

The sitemap iterates %app->pages and, for each page, emits an hreflang alternate per entry in %app->langs plus an x-default. Localised paths come from %app->slugs (a uri => localised-uri map) when you have them; otherwise the path is prefixed with the language code.

In your <head>, the same alternates belong as <link rel=alternate hreflang> tags. The resource gives you one view for that, so you loop your languages and let seo format each link:

view head:
<foreach array_keys(%app->langs) AS $lang>
	{{ %seo->link($lang, $lang === %app->lang ? %req->uri : "/$lang".%req->uri) }}
</foreach>
{{ %seo->head }}

The app owns which URLs are alternates (it knows its own routing and localisation); seo owns the markup.

17.2.1 lastmod

An entry in %app->pages may be a plain uri string, or an object carrying that uri plus what else the page has to say:

prop pages => array_merge(
	[void, '/install', '/pricing'],
	array_map(fn($p) => obj(uri: '/blog/'.$p->slug, lastmod: $p->date), %blog->posts),
)

The value may be a Y-m-d date, a full timestamp, or a unix time. Where it comes from is yours to decide: a field on a record, a date in front matter, the modification time of a source file.

For pages whose date is not in your data, the lastmod resource works it out at build time. It resolves each uri in %app->pages to the class that renders it (/pricing to pricing.phlo or page.pricing.phlo), stamps a map next to your generated PHP, and reads it at runtime. Name the exceptions yourself:

prop %lastmod.sources => ['/server-setup' => app.'server.setup.phlo']

Run it from a build hook in data/app.json, so the dates are read where the sources are:

"runAfter": ["php %app/www/app.php lastmod::stamp"],
"release": { "runAfter": ["php %app/www/app.php lastmod::stamp %app/release/"] }

That map is also what a documentation page reads to print "last updated" for its reader, which is usually the better reason to have it.

Lesson. Do not resolve the date at runtime from a file. The assumption "the file is as old as its content" holds on the machine you edit on and breaks everywhere else: a release node carries no .phlo sources at all, and any deploy that copies or clones rewrites every modification time, so every page would claim to have changed on the day it was deployed. Stamp at build time, ship the map. And leave the field off where you have nothing real: a crawler that catches a site inventing them stops trusting it for the whole domain, which costs more than the pages you were trying to help.

17.3: De head block

%seo->head rendert de conventionele metadata set, allemaal afgeleid van bestaande props:

Het is samenstelbaar, geen vervanging: houd de eigen head-tags van je app (titel, CSRF, stijlen) en voeg %seo->head ernaast toe. Er zijn geen zoekwoorden en geen marketingvulling; de set bestaat uit Open Graph, Twitter kaart, canoniek, hreflang, beschrijving en robots, wat de gebruikelijke complete maar beperkte basislijn is.

De standaardwaarden worden gelezen uit props die je al hebt:

Eigenschap Standaardbron
og:title De documenttitel
og:description / beschrijving %app->description
og:image %app->image, met als fallback /icon.webp in de root van de site
og:url / canoniek De huidige aanvraag-URL
og:site_name De app id
og:locale Afgeleid van %app->lang (bijvoorbeeld nl wordt nl_NL)
og:type website

17.4: robots.txt en de indexeerbare constante

robots.txt wordt gegenereerd en is veilig per standaard. Tenzij de app de indexable constant op een waarheidsgetrouwe waarde instelt, levert de resource:

User-agent: *
Disallow: /

Dus dev, stage en elke niet-publieke host worden eenvoudigweg niet geïndexeerd door indexable niet te declareren. Stel het alleen in op de echte productie-entrypoint, waar je ook de echte host instelt:

phlo_app(
	id: 'Example',
	host: 'example.com',
	indexable: true,
);

Met indexable: true dient de resource een Allow: /, één Disallow: regel per invoer in %app->robotsDisallow, en een Sitemap: regel. Omdat robots.txt afkomstig is van de route, is er geen statische robots.txt die onderhouden of per ongeluk van een dev-host gedeployed moet worden.

17.5: Per-app and per-page overrides

The defaults cover most apps. Override the rest with the cross-class injection idiom (see the Advanced chapter), which sets the prop at build time:

prop %seo.twitterCard = true
prop %seo.ogType = 'article'
prop %seo.siteName = 'Example Co'

twitterCard opts in to the Twitter summary card, ogType overrides the Open Graph type, and siteName overrides the site name (default: %app->title, falling back to the app id).

Two opt-outs are read per request from app props, so a single page can drop out of the index without touching the rest of the site:

Property Effect
%app->image The default Open Graph image for the whole app
%app->noIndex (or %app->noLink) Marks the current page noindex,follow and drops its canonical link

Set %app->noIndex in a route before rendering to keep that one page out of search results while the rest of the app stays indexable.


Reference. The seo and lastmod resources are documented per node in the Manual, generated from the resource files so it never drifts from the code.

Laatst bijgewerkt op 09-08-2026

We gebruiken essentiële cookies om deze site te laten werken. Met uw toestemming gebruiken we ook analytics om de site te verbeteren.