WebProCMS ships with a complete SEO stack — sitemap, robots, structured data, per-page meta, redirects, and translatable SEO fields — wired up so editors don't have to think about any of it. The defaults produce a technically sound page out of the box; the dashboard surfaces only the things an editor would actually want to override.
Sitemap
Every WebProCMS site exposes a dynamic /sitemap.xml. There is no static file to regenerate — the sitemap is rendered on demand from the live database and route table.
What's included
SitemapController::sitemap walks the application and emits one <url> entry for each:
- Static public pages — auto-discovered from the route table. Any GET route that is named, has no URI parameters, is not auth-gated, and lives inside the
CacheResponsemiddleware group is included automatically. Adding a new public page therefore adds it to the sitemap with no extra work. - Blog posts —
Post::published()->where('is_noindex', false), ordered bypublished_atdesc. - Events — when the Events feature is enabled,
Event::published()->where('is_noindex', false)->whereNull('parent_event_id')(so child instances of a repeating event don't appear separately). - Locations — when the locations route exists.
- Custom content type items — every
ContentTypeDefinitionwhose{slug}.showroute is registered contributes its published items.
Each entry carries <lastmod> (the model's updated_at in atom format) plus a sensible <changefreq> and <priority> per type. The home route gets priority=1.0, changefreq=daily; other static pages get 0.7/monthly; posts get 0.7/monthly; events get 0.6/weekly; content items get 0.6/monthly.
Refresh cadence
The sitemap is cached by Spatie response-cache for 24 hours by default (configurable via RESPONSE_CACHE_LIFETIME). It refreshes immediately whenever any cache-clearing model (Post, Event, Location, MediaItem, Form, Snippet) is saved or deleted — those models call ResponseCache::clear() in their Eloquent hooks. So in practice the sitemap is fresh within seconds of any content change, and at most 24 hours stale otherwise.
Disable
A site owner can flip the sitemap off entirely by toggling Setting::get('sitemap.enabled') (Dashboard → Settings → Business). When disabled, both /sitemap.xml returns a 404 and the Sitemap: line is dropped from /robots.txt.
robots.txt
Served from /robots.txt by the same controller. The default body is User-agent: *\nDisallow:\n followed by a Sitemap: line pointing at the live sitemap URL (only when the sitemap toggle is on). It's regenerated per request, so it always points at the current host.
llms.txt
Every WebProCMS site exposes a dynamic /llms.txt — a plain-text/markdown summary of the site for AI assistants and large language models. It complements (rather than overlaps with) the sitemap and robots files:
robots.txtcontrols access — what crawlers can fetch.sitemap.xmlis a machine-readable URL inventory — every public page, for crawlers to discover.llms.txtis a curated comprehension layer — a short, human-readable overview an LLM should read first when asked about the site, instead of scraping every URL. See llmstxt.org for the proposed convention.
What's included
SitemapController::llms assembles the file at request time from:
- Title and tagline —
config('app.name')andbusiness.tagline. - Long-form description —
business.llms_description, edited from Dashboard → Settings → Business Information → "AI / LLM description". This is the only field added specifically for llms.txt — everything else is reused from existing settings. The field shows a clickable?icon next to its label that opens an example modal. - Contact block —
business.url,business.email,business.phone,business.address_*,business.hours. Each line is emitted only when its source is non-empty. - Key pages — curated static-page links discovered the same way as the sitemap (cached, no URI params, not auth-gated), excluding
home,blog.index,events.index,locations,sitemap,robots,llms, and404so the section doesn't duplicate links already covered by their own dedicated sections. - Locations — when the
locations.showroute exists, everyLocationrow is listed with name, address, city/state, and phone. - Recent blog posts —
Post::published()->where('is_noindex', false), latest 10. - Upcoming events — when the Events feature is enabled,
Event::published()->where('is_noindex', false)->whereNull('parent_event_id'), latest 10.
Why the description field exists
The other public files (sitemap, robots, JSON-LD) describe what content exists but say nothing about what the business actually does. An LLM answering "tell me about Acme Co." from sitemap data alone has to guess from page titles. The business.llms_description field is the editor's chance to write that 2–5 paragraph summary directly — covering what the business does, who it serves, pricing or engagement model, and any nuance the owner wants emphasised. Everything else (contact info, locations, pages) is appended automatically.
Refresh cadence
Same as the sitemap — cached for 24 hours by default via Spatie response-cache, invalidated immediately when any cache-clearing model is saved or deleted.
Per-page meta
Every public response runs through partials.head, which renders title, description, robots, canonical, OG, and Twitter Card tags from a small set of variables ($title, $titleRaw, $description, $noindex, $ogImage). Each public page populates those variables in its PHP block.
What pages can edit
| Surface | Title | Description | OG image | noindex |
Notes |
|---|---|---|---|---|---|
| Static pages (page editor) | ✓ | ✓ | ✓ | ✓ | $seoTitleRaw toggle skips the global title format for landing pages where the editor wants full control over the title bar. |
| Blog posts | ✓ (meta_title) |
✓ (meta_description) |
✓ (og_image) |
✓ (is_noindex) |
All four are translatable per language. |
| Events | ✓ | ✓ | ✓ | ✓ | Same fields, same translatability. |
| Locations | ✓ | ✓ | ✓ | n/a | Renders <link rel="canonical"> and og:type=place. |
Title formatting
Setting::formatTitle() applies the title-format template (seo.title_format, default {page} — {site}) to every page title that doesn't pass $titleRaw=true. So an editor types "About Us" once and the rendered title becomes About Us — Acme Co. everywhere — change the brand name once in Settings and every page picks it up.
Structured data (JSON-LD)
The head partial emits two JSON-LD blocks on every page automatically:
- Organization / LocalBusiness — driven by Settings → Business → SEO → Schema fields (type, name, logo, phone, email, address, hours). Renders only the keys the editor has filled in, so a fresh install with just a name gets a clean minimal block rather than empty
nulls. - WebSite — name + URL, plus a
SearchActionpotentialActionwhenever the search route is registered, so search engines can offer a sitelinks search box pointing at/search?q={search_term_string}.
These are rendered via Blade includes rather than inline <script> tags inside Livewire components — Livewire/Volt strips inline scripts during render, so the @include pattern is the only reliable way to surface JSON-LD on Livewire-rendered pages. (See the "Livewire strips inline script tags" lesson in CLAUDE.md.)
A reusable partials/breadcrumb-jsonld.blade.php is available for templates that want to emit BreadcrumbList schema.
Item-level schema on content detail pages
On top of the two site-wide blocks, a content item's detail page emits its own JSON-LD describing that item. One emitter covers every content type: StructuredData::forItem(), included by every generated ⚡show.blade.php (and every promoted one-off item page) via partials/content-detail-head — which also emits the page's canonical, OpenGraph and Twitter tags.
Each content type declares one schema.org type in Content Types → edit → Structured data, and the property values are auto-detected from that type's fields — by field type (address → address, hours → openingHours, featured image → image) and by name convention (phone → telephone, role → jobTitle, rating → reviewRating). Any role can be overridden per type; only the divergent ones are stored.
| Schema type | Typical content type | Auto-filled beyond the mapped fields |
|---|---|---|
Article / BlogPosting |
Blog, meeting minutes | datePublished, dateModified, publisher, mainEntityOfPage |
CreativeWork |
Portfolio, projects | — |
Service |
Services | provider (the site) |
Product |
Shop items | additionalProperty from specs fields |
Event |
Events | startDate/endDate from a datetime field |
JobPosting |
Job listings | datePosted, hiringOrganization; a text location becomes a Place locality |
Person |
Team | — |
Organization / LocalBusiness |
Clients; locations | LocalBusiness adds opening hours + additionalProperty |
Review |
Testimonials | itemReviewed (the site), datePublished; loose ratings parse, out-of-range are dropped |
Question |
FAQs | acceptedAnswer from the answer field |
A type that declares no schema emits none. There is deliberately no fallback. An unset type used to emit Article, which told search engines that every FAQ, staff bio, customer review and service page was a news article — and it was invisible in the dashboard, because "unset" and "None" both render as None in the select. Every seeded type now names its own type; a new custom type starts at None until you pick one. Full reference: custom content types → structured data.
Features with their own detail pages emit their own JSON-LD independently of this path — Ecommerce products (Product), Directory listings (LocalBusiness), Courses (Course), Automotive (Vehicle + Offer), Real Estate open houses (Event).
Embedded FAQs → FAQPage on the host page
Some types are never a page of their own — the seeded faqs type ships generate_pages => false and exists only to be embedded, as an accordion on a location or service page. There is no detail page to carry per-item markup, so the schema belongs to the host page: a set of questions is exactly a schema.org FAQPage.
Two paths cover the two ways an FAQ section gets authored:
- Collection rows (
preset="content_type:faqs", e.g.location-faqs):<x-dl.collection-grid>emits oneFAQPageblock describing whatever FAQ items its loop just rendered, built byStructuredData::faqPageFor()from the records the loop already resolved (no extra query). Automatic — the type'sQuestionschema declaration is what asserts these are FAQs. - Hand-authored accordions (items in the
field-itemsJSON, no content-type records):<x-dl.accordion>emits the same block viaStructuredData::faqPageFromPairs(), but only when its "FAQ markup for search engines" toggle is on (editor design sidebar, or baked asfield-toggle-{prefix}-faq-schema="1"). The opt-in is deliberate: the accordion is purpose-neutral — a course-curriculum or how-it-works accordion labelledFAQPagewould be exactly the wrong-markup problem the schema-type sweep removed. The shippedfaqs-*andpricing-with-faqrow templates bake the toggle on, so new inserts emit by default; everything else starts off. It describes what the row actually rendered (the@dlItemsexpansion, including data-source records and override edits), and only when the accordion itself is visible.
(The faqs-simple-list / faqs-two-column / faqs-categories templates render through <x-dl.repeater>, not the accordion, and don't emit yet — extend the same toggle to the repeater if that ever matters.)
It lives in the shared component rather than in the row templates on purpose: a row's markup is copied into the page blade at insert time, so a template-only change would leave every already-materialized page without it. The component resolves at render time, so existing pages pick it up on their next render.
Only FAQs aggregate this way. Two reasons the behaviour isn't generalised to every collection: a type that does have detail pages (services, blog) already emits its schema there, so aggregating embedded cards would duplicate it; and reviews need their itemReviewed to be the record the host page is about, which makes them a nesting problem rather than an aggregation one — first-party review markup stays a deliberate opt-in under ReviewSettings::schemaEnabled() in the Reviews feature.
Redirects
Dashboard → Redirects (resources/views/pages/dashboard/⚡redirects.blade.php) is a full CRUD UI for managing 301 (permanent) and 302 (temporary) redirects. Entries are stored via VoltFileService::getRedirects — file-based, no database table — so a fresh clone or backup restore brings the redirect map with it.
Slug-rename redirect
When an editor renames a page slug in the page editor, the form offers a "Create 301 from old slug" toggle. Flipping it on writes a redirect entry from the previous URL to the new one as part of the save, so existing inbound links and search-engine references don't 404.
Translatable SEO
WebProCMS supports multi-language sites, and the SEO fields participate. On Posts and Events, meta_title, meta_description, og_title, and og_description are declared translatable; the model's localized() accessor returns the active-language variant with fallback to the base column.
The "Auto Translate" feature (Settings → Languages → Auto Translate) generates per-language translations of every translatable field — including the SEO fields — using the configured AI provider, so adding a new language doesn't leave the meta tags in the original language.
The active language is selected via cms.current_language config, which the localized accessor reads on every render.
hreflang alternates
Every public page emits <link rel="alternate" hreflang="…"> tags pointing at the language-prefixed URL of the same page — one per code in site.languages, plus an x-default pointing at the un-prefixed (English) URL. Built by Hreflang::alternatesForRequest() and injected from partials/head.blade.php.
The default language (en) is served from the un-prefixed URL (e.g. /about); every other code is served from /{code}/{path} via the language-prefix route in routes/cms.php. So a page like /about on a site configured with English + Spanish + French emits:
<link rel="alternate" hreflang="en" href="https://example.com/about" />
<link rel="alternate" hreflang="es" href="https://example.com/es/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />
When the request itself is already localised (e.g. /es/about), the leading prefix is stripped before recomposing the alternates so the same set is emitted regardless of which variant the visitor is on. Tags are skipped entirely when fewer than two languages are active — there's nothing to advertise.
Image SEO
The responsive image pipeline (see responsive-image-pipeline.md) handles three SEO-relevant concerns automatically:
- Alt text — every media library upload runs through the configured AI vision provider and gets a concise alt text generated from the actual image content. Editors can override it in the picker; if they don't, the image still ships with a real description.
- Responsive
srcset/sizes— every image-bearing component emits a precise srcset matching the row's actual layout, so mobile clients aren't downloading desktop-sized images. This directly improves LCP and CLS on Google's Core Web Vitals. - Lazy loading — design library images render with
loading="lazy"anddecoding="async"by default.
Link text
Screen readers can present a page's links as a standalone list, stripped of the cards they sit in — and a grid whose buttons all read "Learn More" collapses into a column of identical, unusable entries. Sighted visitors get the meaning from the card heading; that meaning never reaches the anchor itself. Search engines read link text the same way, which is why Lighthouse fails the page under Content Best Practices → "Links do not have descriptive text" — one of ten weighted audits in its SEO category, roughly 8 points off the score.
Clearer link labels for screen readers (seo.descriptive_link_text, on by default, SEO → Settings → Search Engines & AI) fixes this at render time. When a link's visible label is one the audit treats as meaningless, the link borrows its own card's heading, appended as text that is available to assistive technology but clipped visually:
<a href="/services/pricing" class="btn-primary">Learn More<span class="sr-only"> about X</span></a>
The button looks exactly as designed, and the announced name becomes "Learn More about X", where X is the card's heading. This is the standard sr-only pattern, not a cloaking trick: the appended text is the card's own visible heading, it describes the destination truthfully, and it is announced to anyone whose reading order includes it. aria-label would be the obvious alternative but doesn't clear the audit — Lighthouse reads the anchor's rendered text and ignores the label — so the fix has to be real text in the DOM.
Properties worth knowing:
- It only ever fires on a label that is already failing. Matching is exact against a blocklist mirroring Lighthouse's own (
learn more,read more,click here,más información, …), after trimming a decorative trailing arrow. Anything written descriptively is passed through untouched, so turning this on can't alter copy you deliberately wrote. - The heading is found without any template changes. A sibling tag can't read another tag's baked blade attrs — and an un-edited card title exists only as that attr — so the heading components announce their own resolved text into a per-request registry as they render, and the button reads it back. This works on pages that were materialised long before the feature existed. A card with no heading, or a layout that renders its button first, simply gets no suffix.
- It matches the card, never a neighbour. The link's field prefix determines the lookup (
card_1_button→card_1_title), and in a repeater each iteration overwrites the registry before its own CTA renders, so card 2 can't inherit card 1's title. - The connector word is translated (
__('about :name')), and the heading it borrows is the localised one, so/es/reads "Más información sobre Atención de Urgencias". - Suppressed in the editor preview, so the hidden text never shows up in inline editing or drill-in as though an author had typed it.
- The accessibility scanner defers to it.
generic-link-textwarnings are suppressed for links this repairs, so the dashboard doesn't report a defect the published page doesn't have — see accessibility-scanner.md.
Heroes and standalone buttons: the "Describes" field
The automatic path needs a card heading, which a hero CTA doesn't have — x-dl.buttons uses fixed primary_button / secondary_button prefixes with no card to pair against. Those links carry a per-button field instead, Describes (screen readers) ({prefix}_sr_label), on the button's Content tab in the page editor, beside the label and URL it describes, behind a clickable "?" that explains what it's for. The editor names the destination ("our pricing page") and the link announces as "Learn More about our pricing page".
Rules, which differ from the automatic path on purpose:
- A typed value always wins over a card heading, and applies even when the visible label is already descriptive — someone who filled the field in was being explicit.
- It applies even when the site-wide setting is off. Switching off the automatic behaviour must not silently discard text somebody typed.
- The scanner's suppression becomes exact. A typed value is a stored override, so unlike the automatic path the scanner can see it directly rather than inferring.
- Offered by
x-dl.button,x-dl.link, both halves ofx-dl.buttons, and anx-dl.wrapperrendered astag="a"— the last covers a repeater's own CTA, which would otherwise be stuck with the heading it borrows. The wrapper registers it only when it is a link: it renders as adiv/h3/pdozens of times per page, and an unconditional field would land on hundreds of non-links.
Toggling the setting clears the response cache, since the suffix is baked into cached HTML.
Performance-related SEO
Core Web Vitals are part of Google's ranking signals. Three pipelines that exist primarily for performance also count as SEO infrastructure:
- Response cache — Spatie response-cache serves cached HTML for every public GET request from a guest (24h TTL by default). Page changes invalidate the cache on save via Eloquent hooks. TTFB on a warm cache is single-digit milliseconds.
- Public CSS bundle — resources/css/public.css is scoped strictly to public-facing markup and excludes Flux + dashboard sources. Pages ship a smaller stylesheet than they would on a single-bundle setup.
- On-save asset rebuild — the editor rebuilds CSS as soon as a class change is saved (see on-save-asset-rebuilds.md), so the live site never serves a stale bundle that's missing the just-edited classes.
Settings reference
| Setting | Default | Where |
|---|---|---|
sitemap.enabled |
true |
Settings → Business |
business.llms_description |
empty | Settings → Business → Business Information |
seo.title_format |
{page} — {site} |
Settings → Business → SEO |
seo.schema.type |
Organization |
Settings → Business → SEO → Schema |
seo.schema.{name,url,description,logo,phone,email,address,hours} |
empty | Settings → Business → SEO → Schema |
seo.og.default_image |
empty | Settings → Business → SEO |
seo.twitter.handle |
empty | Settings → Business → SEO |
seo.descriptive_link_text |
true |
SEO → Settings → Search Engines & AI |
RESPONSE_CACHE_LIFETIME (env) |
86400 (24h) |
.env |
What lives where
| Path | Purpose |
|---|---|
app/Http/Controllers/SitemapController.php |
Dynamic sitemap, robots, and llms.txt generation. |
resources/views/sitemap.blade.php |
Sitemap XML template. |
resources/views/partials/head.blade.php |
All head-tag rendering: title, description, OG, Twitter, JSON-LD. |
resources/views/partials/jsonld.blade.php |
Renders one JSON-LD block from an array. |
app/Support/StructuredData.php |
The type-driven item schema emitter: catalogue, field-role auto-detection, JSON-LD build. |
resources/views/partials/content-detail-head.blade.php |
Content-item detail <head>: canonical, OG/Twitter, item JSON-LD. |
resources/views/partials/breadcrumb-jsonld.blade.php |
Reusable BreadcrumbList schema. |
app/Support/Hreflang.php |
Builds the hreflang alternate set from site.languages. |
app/Support/DesignLibrary/DescriptiveLinkText.php |
The generic-label blocklist, the card-heading registry, and the sr-only suffix. Consumed by x-dl.button / x-dl.link / x-dl.wrapper and the accessibility scanner. |
resources/views/pages/dashboard/⚡redirects.blade.php |
Redirects dashboard. |
app/Support/VoltFileService.php |
File-based redirect storage. |
app/Models/Setting.php |
formatTitle() and SEO setting reads. |
Notes
- The sitemap is dynamic, not a stored file — there is no
php artisan sitemap:generatecommand, and there's no need for one. - Static page discovery is opt-in by virtue of being inside the
CacheResponsemiddleware group. A new uncached public route (e.g. an interactive tool) is intentionally excluded from the sitemap; add it back via the explicit URL list if it should be indexed. is_noindexonly affects the sitemap and the per-page<meta name="robots">tag — it doesn't add aDisallowto robots.txt. Indexed-but-noindex pages will still be crawled; they just won't appear in search results.- Multi-language SEO is field-level translation plus
hreflangURL alternates. Each public page advertises every configuredsite.languagescode (plusx-default) at/{code}/{path}; the localised content is served by the language-prefix route inroutes/cms.php.