Skip to main content

Documentation

No results found.
Features

SEO

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

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 CacheResponse middleware 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 by published_at desc.
  • 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 ContentTypeDefinition whose {slug}.show route 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.txt controls access — what crawlers can fetch.
  • sitemap.xml is a machine-readable URL inventory — every public page, for crawlers to discover.
  • llms.txt is 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') and business.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, and 404 so the section doesn't duplicate links already covered by their own dedicated sections.
  • Locations — when the locations.show route exists, every Location row 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 SearchAction potentialAction whenever 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 one FAQPage block describing whatever FAQ items its loop just rendered, built by StructuredData::faqPageFor() from the records the loop already resolved (no extra query). Automatic — the type's Question schema declaration is what asserts these are FAQs.
  • Hand-authored accordions (items in the field-items JSON, no content-type records): <x-dl.accordion> emits the same block via StructuredData::faqPageFromPairs(), but only when its "FAQ markup for search engines" toggle is on (editor design sidebar, or baked as field-toggle-{prefix}-faq-schema="1"). The opt-in is deliberate: the accordion is purpose-neutral — a course-curriculum or how-it-works accordion labelled FAQPage would be exactly the wrong-markup problem the schema-type sweep removed. The shipped faqs-* and pricing-with-faq row templates bake the toggle on, so new inserts emit by default; everything else starts off. It describes what the row actually rendered (the @dlItems expansion, 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" and decoding="async" by default.

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-text warnings 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 of x-dl.buttons, and an x-dl.wrapper rendered as tag="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 a div/h3/p dozens 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.

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:generate command, and there's no need for one.
  • Static page discovery is opt-in by virtue of being inside the CacheResponse middleware 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_noindex only affects the sitemap and the per-page <meta name="robots"> tag — it doesn't add a Disallow to 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 hreflang URL alternates. Each public page advertises every configured site.languages code (plus x-default) at /{code}/{path}; the localised content is served by the language-prefix route in routes/cms.php.