Skip to content

Head and SEO internals ​

How the <head> is assembled and where the sitemap and security.txt come from. The webmaster view is on SEO and Head.

Head DTOs ​

core/src/DTO/Head/: Head is an ordered collection of tags with defaults(), merge(), mergeWhen(), when(), add(), has() and toHtml(). Tag classes TitleTag, MetaTag (name(), property(), charset(), responsiveViewport(), csrfToken(), robots()), LinkTag (canonical(), alternate(), stylesheet(), icon(), preconnect(), preload()), ScriptTag and JsonLdTag (2.18) extend GenericTag. MetaTag::isEmpty() drops a tag whose content is empty, which is what lets a missing description render nothing. Attribute values and text are escaped (2.10).

Head::defaults() adds the title, charset, viewport, CSRF token, a canonical of the current URL, and robots noindex, nofollow when Evolve::environmentType()->mustProtect() (staging, testing). The tag is in the HTML on purpose: page-cache hits never reach PHP, so an X-Robots-Tag header would not reach them.

A model can implement the HeadTags contract to contribute tags; Head::merge() accepts arrays, Arrayables, closures and HeadTags.

Per route ​

core/src/Models/Route.php builds the route's tags (title, description without fallback, Open Graph, Twitter card, canonical from the route's canonical or the current URL, share image at 1200 by 630 and 1200 by 628, robots when the routeable is not public) and the hreflang set in alternateTags(): one alternate per route of the same record whose language is active and whose locale the region allows, plus x-default from defaultAlternate() (the fallback locale of the region or app), only when at least two exist.

core/src/Concerns/HasFrontendRoute.php is the trait every routeable record uses: noIndex (cached a day; true when the record does not pass scopePublic()), shareImage (the record's image), isPageCachable(), clearPageCache(), getPageViews() (GA4 Data API). Models define scopePublic() to say what public means for them.

The theme's head.blade.php adds author (from app.name), generator and the three verification metas from services.*.site-verification, merges the route, and then the Organization tag when Support\Organization::isKnown().

Sitemap ​

core/src/Http/Controllers/Frontend/SitemapController.php builds from Route::publicRouteable(withHidden: false, forceGuard: true) with an active language, adds lastmod from the record and hreflang alternates for sibling routes, omits changefreq and priority, and caches for a day under evolve.sitemap only when Services\SiteUrl::canonicalHost() is known. Without a canonical host it rebuilds per request so a preview host cannot poison the cache. Uses spatie/laravel-sitemap.

security.txt ​

SecurityTxtController serves /.well-known/security.txt from evolve.security.contact and policy_url, falls back to mail.from.address once it differs from the seeded one, and aborts with 404 otherwise. The reasoning is in the class docblock: a wrong contact is worse than none.

Structured data ​

JsonLdTag 2.18 ​

Racerfish\Evolve\DTO\Head\JsonLdTag (alias Head\JsonLd) renders one <script type="application/ld+json" id="jsonld-{identifier}">:

php
Head\JsonLd::make([
    '@type' => 'Event',
    'name' => $event->title,
    'location' => ['@type' => 'Place', 'name' => $event->venue],
], 'event');
  • @context: https://schema.org is added when missing.
  • null, '' and [] are pruned recursively, lists are re-indexed, and nodes left with nothing but @type are dropped. A block that prunes to nothing is isEmpty() and not rendered.
  • Encoding uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG, so </script> inside a value cannot close the tag.
  • getId() is jsonld-{identifier}: merging a tag with the same identifier into Head replaces it, different identifiers coexist. Modules should use this instead of a hand-written <script> block.

Organization ​

Racerfish\Evolve\Support\Organization builds the schema.org Organization from evolve.company.* (the CompanySettings overlay): name (brand name, else legal name), legalName, url and @id ({base}/#organization via SiteUrl::base()), logo from evolve.branding.logo-media-id, foundingDate, telephone, email, a PostalAddress and sameAs. isKnown() is false without a legal name, and then nothing is emitted. tag() returns the JsonLdTag with identifier organization; the theme head merges it once per page.

Per record ​

Contracts\StructuredData (getStructuredData(): array) is implemented by Job and Member (company), Post (blog), Project and Faq. theme/views/structuredData.blade.php adds mainEntityOfPage and, for CreativeWork types only (Article, BlogPosting, NewsArticle, WebPage and similar), author and publisher as the compact Organization node (@id, name, url, logo). Since 2.18 it renders through JsonLdTag (identifier routeable), so @context comes from PHP and the Blade @context directive trap is gone, and it no longer reads a company.name translation. Escaping is tested by core/tests/Feature/StructuredDataEscapingTest.php, the Organization by OrganizationStructuredDataTest.php. Job::hiringOrganization still uses its own config (company.jobs.organization_logo, app.name).

Open ​

Issue #175 in evolve-cms proposes a contract for generated metadata on routeables where curated values win over generated ones; until then a per-page noindex does not exist and the description has no generated fallback by design.