Summary
Wrap the first-party laravel/head package as a Winter plugin (Winter.Head) to give the platform one fluent, resolvable API for everything in <head>, plus a {% head %} Twig tag for themes.
Head management in Winter is currently spread across several mechanisms that don't compose, and the seams produce real bugs.
Why
backend.layout.extendHead concatenates, so contributions collide
EventEmitter::fireViewEvent() implodes every listener's return value:
// modules/system/traits/EventEmitter.php:86
if ($result = Event::fire($event, $params)) {
return implode(PHP_EOL.PHP_EOL, (array) $result);
}
Two plugins that each contribute a favicon set therefore leave both in the document, and the browser arbitrates. Listener priority only reorders them — it doesn't make one authoritative. I hit this with two plugins on a multi-domain install, and the only clean fix was to make one plugin the sole listener and have the other override a config value it reads. That works, but it's a pattern every plugin author has to reinvent, and it only holds while exactly one plugin is willing to own the tag.
There's no way for a plugin to say "replace the favicon set" rather than "append another one". A resolver with precedence layers solves this class of problem outright.
Winter.SEO renders og: tags with the wrong attribute
plugins/winter/seo/components/seotags/default.htm renders a scalar value as:
<meta name="{{ tagName }}" content="{{ tagContent }}">
So the documented Meta::set('og:image', $url) emits name="og:image". The Open Graph protocol specifies property, and Facebook, LinkedIn and Slack read property. Getting a conformant tag today requires knowing to pass an array so the other branch of that template fires — which is easy to miss and silently produces cards that don't render.
laravel/head handles exactly this, switching attribute by key and letting it be forced:
Head::meta('description', 'About') // name="description"
->meta('og:title', 'About'); // property="og:title"
Themes hand-roll the rest
Every theme ends up with its own partials/meta/seo.htm assembling titles, canonicals, OG tags, favicons and JSON-LD by hand, with no shared notion of precedence between site defaults, page values and runtime overrides.
Proposal
Winter.Head wrapping laravel/head, providing:
-
A {% head %} Twig tag — the CMS equivalent of the @head Blade directive, rendering the resolved tags in a theme layout:
<head>
<meta charset="utf-8">
{% head %}
</head>
-
A Head facade usable from plugins, components and page PHP sections, with the package's five-layer precedence (page defaults → route group → route → runtime → error). That model maps cleanly onto Winter: plugin boot() sets defaults, a CMS page's onStart() or a component sets runtime values, and the later layer wins field by field rather than wholesale.
-
Backend head management on the same API, replacing backend.layout.extendHead for tag contributions. Favicons, theme colour, application name and manifest all have first-class methods (favicon(), appleTouchIcon(), manifest(), themeColor(), pwa()), so a plugin overriding an icon set replaces it rather than appending a competing one.
First-party plugin integrations
- Winter.SEO — the biggest one. Its
Meta / Link classes overlap almost entirely with HeadBuilder. Ideally Winter.SEO becomes a thin compatibility layer over Winter.Head, keeping Meta::set() / Link::set() working while fixing the property vs name issue underneath. Its SeoableModel behaviour maps onto route/runtime metadata.
- Winter.Pages / Winter.Blog — per-page and per-post metadata (
meta_title, meta_description, meta_image) resolved as a layer instead of each theme wiring them manually. Schema::blogPosting() and Schema::article() give posts JSON-LD for free.
- Winter.Sitemap —
feed() for RSS/Atom discovery and alternates() for locale variants, which today are hand-written in theme partials.
- Winter.Translate —
alternates() for hreflang, driven by the locale set rather than hand-maintained.
- Winter.Redirect —
canonical() so a redirect target's canonical URL is consistent with the rule set.
Blocker: framework version
laravel/head v0.2.2 requires:
"php": "^8.3",
"illuminate/contracts": "^13.17.0",
"illuminate/routing": "^13.17.0",
"illuminate/support": "^13.17.0",
"illuminate/view": "^13.17.0"
Winter currently requires laravel/framework ^12.30.1 (winter/storm), so this can't be adopted until Winter moves to Laravel 13. Worth noting it's also pre-1.0, so the API may still move.
That makes this a design-direction issue rather than something immediately actionable. Two things could still happen ahead of the framework bump:
- Fix the
og: attribute bug in Winter.SEO directly — it's a real, user-visible bug today and shouldn't wait on this.
- Decide whether backend head contributions should move to a resolver-style API regardless, since the concatenation problem exists independently of which package backs it.
References
Summary
Wrap the first-party
laravel/headpackage as a Winter plugin (Winter.Head) to give the platform one fluent, resolvable API for everything in<head>, plus a{% head %}Twig tag for themes.Head management in Winter is currently spread across several mechanisms that don't compose, and the seams produce real bugs.
Why
backend.layout.extendHeadconcatenates, so contributions collideEventEmitter::fireViewEvent()implodes every listener's return value:Two plugins that each contribute a favicon set therefore leave both in the document, and the browser arbitrates. Listener priority only reorders them — it doesn't make one authoritative. I hit this with two plugins on a multi-domain install, and the only clean fix was to make one plugin the sole listener and have the other override a config value it reads. That works, but it's a pattern every plugin author has to reinvent, and it only holds while exactly one plugin is willing to own the tag.
There's no way for a plugin to say "replace the favicon set" rather than "append another one". A resolver with precedence layers solves this class of problem outright.
Winter.SEO renders
og:tags with the wrong attributeplugins/winter/seo/components/seotags/default.htmrenders a scalar value as:So the documented
Meta::set('og:image', $url)emitsname="og:image". The Open Graph protocol specifiesproperty, and Facebook, LinkedIn and Slack readproperty. Getting a conformant tag today requires knowing to pass an array so the other branch of that template fires — which is easy to miss and silently produces cards that don't render.laravel/headhandles exactly this, switching attribute by key and letting it be forced:Themes hand-roll the rest
Every theme ends up with its own
partials/meta/seo.htmassembling titles, canonicals, OG tags, favicons and JSON-LD by hand, with no shared notion of precedence between site defaults, page values and runtime overrides.Proposal
Winter.Headwrappinglaravel/head, providing:A
{% head %}Twig tag — the CMS equivalent of the@headBlade directive, rendering the resolved tags in a theme layout:A
Headfacade usable from plugins, components and page PHP sections, with the package's five-layer precedence (page defaults → route group → route → runtime → error). That model maps cleanly onto Winter: pluginboot()sets defaults, a CMS page'sonStart()or a component sets runtime values, and the later layer wins field by field rather than wholesale.Backend head management on the same API, replacing
backend.layout.extendHeadfor tag contributions. Favicons, theme colour, application name and manifest all have first-class methods (favicon(),appleTouchIcon(),manifest(),themeColor(),pwa()), so a plugin overriding an icon set replaces it rather than appending a competing one.First-party plugin integrations
Meta/Linkclasses overlap almost entirely withHeadBuilder. IdeallyWinter.SEObecomes a thin compatibility layer overWinter.Head, keepingMeta::set()/Link::set()working while fixing thepropertyvsnameissue underneath. ItsSeoableModelbehaviour maps onto route/runtime metadata.meta_title,meta_description,meta_image) resolved as a layer instead of each theme wiring them manually.Schema::blogPosting()andSchema::article()give posts JSON-LD for free.feed()for RSS/Atom discovery andalternates()for locale variants, which today are hand-written in theme partials.alternates()forhreflang, driven by the locale set rather than hand-maintained.canonical()so a redirect target's canonical URL is consistent with the rule set.Blocker: framework version
laravel/headv0.2.2 requires:Winter currently requires
laravel/framework ^12.30.1(winter/storm), so this can't be adopted until Winter moves to Laravel 13. Worth noting it's also pre-1.0, so the API may still move.That makes this a design-direction issue rather than something immediately actionable. Two things could still happen ahead of the framework bump:
og:attribute bug inWinter.SEOdirectly — it's a real, user-visible bug today and shouldn't wait on this.References