Meta Tag

MetaTag is final — unlike every other middleware on this list, it isn't designed for extension, and has no __invoke(). Call MetaTag::set() from a controller/view; call MetaTag::render() once, from the layout's <head>.

The two things it must have first

MetaTag has no configure() call — it reads two ambient sources the consuming app owns:

  • A global APP constant with name/base_url keys, defined once at boot (e.g. in config/bootstrap.php) — the title/URL fallback when a page doesn't set its own.
  • SEO_DEFAULT_DESCRIPTION, SEO_DEFAULT_IMAGE, SEO_TWITTER_SITE, SEO_LOCALE, SEO_ORG_NAME, SEO_ORG_LOGO environment variables, read via getenv().

Without both, fields that should have a sensible default silently fall back to 'Application' or an empty string instead.

Setting a page's meta

MetaTag::set([
    'title' => 'Routing',
    'description' => 'The full Route API.',
    'canonical' => '', // '' resolves to the current request URL
    'robots' => 'index, follow',
    'og_type' => 'article',
]);

Or the fluent instance form, for building it up incrementally: MetaTag::set([])->title('Routing')->description('...').

Structured data (JSON-LD)

MetaTag::set(['json_ld' => MetaTag::schemaArticle([
    'headline' => 'Routing',
    'datePublished' => '2026-08-08',
])]);

MetaTag::set(['json_ld' => MetaTag::schemaBreadcrumb([
    ['name' => 'Docs', 'url' => '/docs'],
    ['name' => 'Routing', 'url' => '/docs/concepts/routing'],
])]);

Also available: schemaWebPage(), schemaOrganization(). Each is a plain method returning an array — call set(['json_ld' => ...]) or addJsonLd() as many times as needed; every call appends its own <script type="application/ld+json"> tag.

Rendering

<head>
    <?= MetaTag::render(); ?>
</head>

Emits <title>, description, robots, canonical link, every Open Graph tag, every Twitter Card tag, and every registered JSON-LD block — in that order, all HTML-escaped.

Passing meta into a view explicitly

MetaTag::toViewData(): array // title, description, canonical, og_url, images, json_ld, ... — for custom partial rendering

Not injected automatically — View has no implicit variable injection, so whoever wants this in a template passes it in themselves.

Next steps