---
url: /internals/head-and-seo.md
---
# 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](/site/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, `Arrayable`s, 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`&#x20;

`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.
