Skip to content

Proposal: Winter.Head — wrap laravel/head for centralised document head management #1537

Description

@LukeTowers

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:

  1. 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>
  2. 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.

  3. 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.Sitemapfeed() for RSS/Atom discovery and alternates() for locale variants, which today are hand-written in theme partials.
  • Winter.Translatealternates() for hreflang, driven by the locale set rather than hand-maintained.
  • Winter.Redirectcanonical() 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions