Skip to main content

Documentation

No results found.
Features

SEO — free SEO Lite surface + SEO Pro addon

The SEO Scanner audits the whole public site — every static page, blog post, and content item with a live URL — for the technical SEO problems that quietly erode search rankings: broken links, redirect chains, duplicate titles and meta desc...

SEO Pro — addon, included with WebProCMS membership. Feature key seo_scanner; on by default for active-member installs (member_default: true in the registry — it's included with the membership, so members shouldn't have to hunt for the toggle), off by default for non-members, toggleable per install at Dashboard → Settings → Features → SEO Pro. The SEO menu itself is free on every install — the addon deepens it in place. See the addon index for the full list of paid features.

The SEO Scanner audits the whole public site — every static page, blog post, and content item with a live URL — for the technical SEO problems that quietly erode search rankings: broken links, redirect chains, duplicate titles and meta descriptions, missing image alt text, and orphan pages. Findings roll up into a single 0–100 site health score, and a per-page SEO audit panel lives right inside the page editor so problems surface while you're editing, not months later in Search Console.

The free / paid split (SEO Lite vs. SEO Pro)

Every install gets a top-level SEO sidebar group (icon globe-alt, placed with the growth features) with five entries: SEO Scanner, Search Console, Local Rankings, Business Listing, and Settings. Each is a standalone page — there are no cross-page switcher tabs (the old Local Rankings ↔ Business Listing switcher was removed 2026-07). The group renders for everyone — this is a freemium funnel, not a hidden menu:

Free on every install (SEO Lite) Requires the seo_scanner addon (SEO Pro)
The SEO menu + every page shell (paid pages show a teaser) Full-site Site Health scans: score, findings, one-click fixes, regression alerts, scheduled weekly scans
SEO → Settings: title format, OG default image, Twitter handle, JSON-LD schema, sitemap toggle, extra robots.txt rules, llms.txt description, descriptive link text Bulk AI meta-description generation
Per-page SEO in the page editor (meta title/desc, OG, noindex) Search Console connect + 28-day performance view (its own SEO-group page; also the panel on Analytics → Search and the digest's Google-searches section)
sitemap.xml / robots.txt / llms.txt generation Local Rankings geo-grid + weekly re-scans
Business Listing (GBP) audit + AI action plan
The in-editor site-wide SEO audit panel

The split is enforced twice: the page blades render a teaser card in place of the paid tooling when the feature is off (shared partial upgrade-teaser.blade.php), and every paid Livewire action aborts 403 via an assertSeoProEnabled() guard (Search Console: assertSearchConsoleAvailable() in the shared trait) so a free user can't trigger paid work by invoking methods directly. The free page-shell routes carry no feature:seo_scanner middleware; the scan-report route and the Search Console OAuth pair stay behind it.

The teaser is member-aware. An active member already owns SEO Pro — it's included with the membership — so showing them "Upgrade to unlock" would be misleading. upgrade-teaser.blade.php checks Membership::isMember(): members get an "included with your membership — it's just switched off" nudge with a Turn on SEO Pro button (admins; others get "ask an administrator"), while non-members get the Upgrade to unlock pitch with the pricing link. In practice members rarely see the teaser at all, because of the member default:

On by default for members. The registry entry carries member_default: true: when no features.seo_scanner.enabled Setting row exists, Features::enabled() resolves the default to ON for active members and OFF for non-members (Features::resolvedDefault()). An explicit toggle always wins, and the registry default stays false so seo_scanner remains an opt-in key for the licensing telemetry — a member-default install never writes the Setting row telemetry reads, so the enabled-without-membership bypass signal is unchanged. A lapsed membership auto-reverts the feature to off (unless it was explicitly enabled, which the Features page refuses for non-members).

SEO → Settings (free)

The global SEO settings moved here from Settings → Business (2026-07, same Setting keys, no data migration): page title format (seo.title_format), default OG image (seo.og.default_image), Twitter handle (seo.twitter.handle), Schema.org structured data (seo.schema — type, logo, description, address; area_served continues to be written by the Business page's service-areas field and is preserved by a merge-on-save), the XML sitemap toggle (sitemap.enabled), additional robots.txt rules (seo.robots_extra, appended verbatim to the generated /robots.txt), and the AI/LLM description used by /llms.txt (business.llms_description). When an AI text provider is configured, a draft button fills the schema + llms descriptions from the saved business details. Business Info keeps a one-line pointer to the new page.

The page also carries Descriptive link text (seo.descriptive_link_text, on by default), which fixes the "Links do not have descriptive text" audit at render time by appending a card's own heading to a generic "Learn More" button as visually-hidden text. It's free because it's cheap and it improves the anchor text every crawler reads — full behaviour in seo.md → Link text.

The problem

Sites rot. Pages get renamed and old links keep pointing at the old slug; a partner site dies and your resources page 404s; two landing pages end up with the same title competing against each other in search results; a page loses its last inbound link in a nav redesign and becomes invisible to crawlers. None of this is visible while editing — you find out when rankings drop, or when a visitor emails about a dead link. External tools (Ahrefs, Screaming Frog, Semrush site audit) can find these, but they're paid, separate logins, and they hammer your site with a crawler to learn things the CMS already knows.

The fix

The CMS is the crawler's source of truth, so the scanner reads structure directly instead of crawling:

  • Internal links are checked without a single HTTP request to your own server. Every link on every page is resolved against the route table, the content database, and the data-derived redirect graph (renamed slugs, per-item redirects, page-redirect blocks). A scan of hundreds of pages adds zero load to the live site.
  • External links are fetched politely. Each unique outbound URL is checked once per scan (no matter how many pages carry it), in small concurrent batches with short timeouts, HEAD-first with a GET retry for method-hostile servers. Definitive results under 24 hours old (OK or 404/410) are reused from the previous scan so third-party hosts aren't re-hammered; an unreachable or bot-blocked verdict is never reused — that is exactly the result that deserves a fresh attempt, and carrying it forward re-reported a transient blip for a day.
  • No queue worker needed. A scan is a resumable state machine stored in the database; the dashboard advances it a few seconds at a time while the page is open, and the built-in LazyCron scheduler advances (or starts) scans in the background using ordinary visitor traffic.

What it checks

Check Severity Notes
Broken internal links Error Link targets resolved against routes + content DB + public files; renamed content slugs and editor-set redirects are followed, not flagged as broken. A link to a drafted page is broken — a draft keeps its route line but the page.draft middleware 404s it, so the resolver asks the route's middleware, not just the route table. Absolute self-links on the www. / bare-domain twin of the site host count as internal (SeoScanner::ownHosts()).
Broken external links Error / Warning / Notice 404/410 → error; timeout/5xx → warning ("unreachable"); 403/429/999 bot-blockers → notice ("blocked automated check" — LinkedIn-style hosts reject every bot while serving browsers fine).
Redirect chains Warning / Notice A link that reaches its destination through ≥2 redirects is a warning; a link pointing at a single redirect gets a notice with the direct target to use instead. External chains of ≥2 hops are also flagged.
Missing page titles Warning An indexable page whose rendered title is empty (static pages fall back to the page name, content items to their display title, so this is rare).
Page titles too long Notice Measured on the rendered <title> — the page title run through the site's title format (Setting::formatTitle, which appends the site name unless the page sets titleRaw) — over 60 characters. The raw title under-reports by the whole suffix. Content items measure og_title ?: meta_title ?: title the same way.
Duplicate page titles Warning Compared across every indexable page — static pages (@page marker / #[Title]) and content items (og_title ?: meta_title ?: title), case-insensitive.
Missing meta descriptions Warning Static pages with no description in their frontmatter; content items with no meta description, OG description, or excerpt. Noindex pages are exempt.
Meta descriptions too short or too long Notice Under 50 or over 160 characters (the range a search snippet shows).
Duplicate meta descriptions Warning Same description on two or more indexable URLs.
Missing image alt text Warning Every image a page renders with no media-library alt, no page-level alt override, and no decorative flag: top-level image fields, repeater sub-images, and inline <img> tags inside rich text (graded on the alt attribute the tag carries — the renderer re-emits it verbatim and never consults the library, so alt="" is an empty alt on the live page). Content items: the featured image, every declared image / gallery field (a gallery entry's own alt, else the library's), and inline images in their bodies. A content type's parameterized ⚡show template is audited too (images + links only, under the template's path), since its rows render on every record's page.
Placeholder template copy Warning A richtext / headingtext field still showing the exact demo prose its design-library template shipped with — compared against the LIBRARY default (SchemaCache), never the page blade's own default, which on a hand-authored page IS the real copy. Skipped on ⚡show templates, whose shipped headings ("Related articles") are correct as shipped. Same check the editor runs, now site-wide.
Orphan pages Warning Published static pages with zero inbound links from other pages or the navigation menus. Draft, unlisted, noindex, and redirecting pages, the home page, and the 404 page are exempt. Content items are exempt (their generated index page lists them).

Drafts are not live. A drafted / unpublished page is inventoried (with no URL) but graded for nothing — no meta findings, no duplicate or orphan participation — and links to it count as broken. Before this the scan trusted "has a route" as "is live", which stopped being true when the page-render migration moved draft enforcement onto route middleware.

Link fan-out is counted, not just listed. A URL's per-page source list is capped at 20 (SeoScanUrl::MAX_SOURCES, keeps the json bounded) but seo_scan_urls.sources_total counts every page carrying it, once per page (the same link in three fields of one page is one source). Findings carry pages_total / pages_listed in their details and the results list says "also on N more pages not listed here".

The health score

Each check scores its own category — 100 × (1 − affected / total) where the denominator is what the check measures against (pages for meta checks, checked links for link checks, images for alt) — and the overall score is the weighted mean:

Category Weight Denominator
Broken internal links 20 internal links checked
Broken external links 15 external links checked
Missing alt text 15 images rendered
Redirect chains 10 all links checked
Duplicate titles 10 indexable pages
Duplicate descriptions 10 indexable pages
Missing descriptions 10 indexable pages
Placeholder template copy 10 indexable static pages (a page counts once however many fields still show demo copy)
Orphan pages 10 indexable static pages
Missing titles 5 indexable pages
Titles too long 5 indexable pages
Descriptions too short / too long 5 indexable pages

Weights are relative (the mean divides by the sum of the weights that apply), so they need not total 100. Categories that don't apply (a site with no external links, or no images) drop out and their weight redistributes. Bands: 90+ Healthy (green), 50–89 Needs work (amber), below 50 Poor (red). Blocked and unreachable external links don't reduce the score — they're not proven breaks. The title, description-length, and placeholder-copy categories were added pre-launch (2026-09) along with the wider alt denominator (inline and gallery images now count), so scores are not comparable with scans taken before that.

Hidden from search engines. When the site-wide Search engine visibility switch (SEO → Settings, seo.discourage_search_engines) is off, every page is served noindex, nofollow and no finding can reach a ranking. The Site Health tab leads with a danger callout linking to the setting, each scan records the flag at scan time (issue_counts.search_engines_discouraged), and the report page and the regression-alert mail repeat it. Scans still run — a pre-launch site benefits from the findings — the flag just outranks them.

Running scans

  • Manual: Dashboard → SEO Scanner → Run Scan. Progress streams live (page inventory → internal link resolution → external link checks → scoring); keep the tab open and the scan drives itself. Closing the tab pauses the scan — the background scheduler picks it up and finishes it.
  • Scheduled: once a week, automatically, via LazyCron (seo-scanner:scan, registered by the module's service provider). Toggle under History & Settings → Scheduled weekly scan (on by default when the feature is enabled). Scheduled scans complete incrementally across LazyCron ticks — no cron entry, no queue worker.
  • The last 10 scans are kept as reports (History & Settings tab); each opens a print-ready report page (Download PDF uses the browser's print dialog, same as the Accessibility Scanner). A score trend sparkline above the list charts the completed scans' scores oldest→newest with the latest score and its delta vs the previous scan.

One-click fixes

Two finding types can be fixed straight from the results list; fixed findings show a green Fixed — next scan verifies chip (the next scan confirms for real):

  • Broken internal link → Create redirect. Every broken-internal finding has a Create redirect button that opens a small modal (dead path pre-filled, destination + 301/302 choice) and writes a Route::redirect() entry through the same seam the Redirects page uses. One redirect fixes every page carrying the dead link at once, and later scans follow it (the redirect graph includes route-level redirects), downgrading the old error to a "points at a redirect" notice until the links themselves are updated.
  • Missing meta descriptions → Fix with AI. When an AI text provider is configured (Settings → API Keys), the missing-descriptions group grows a Fix with AI (n) button. A background worker walks the findings one page at a time: it reads the page's own content (row text for static pages, field data for content items), asks the model for a ≤150-character plain-text description in the content's language, and applies it where the page actually stores it — meta_description on content items; for static pages the @page frontmatter marker (the canonical store PlainPageController renders and the scan reads), plus the derived #[Layout('layouts.public', [...])] data array on component pages, whose Volt render still reads it. Pages that predate the marker get only the legacy Layout patch. (Writing the Layout array alone left every plain page unfixable and every component page re-flagged on the next scan.) The response cache is cleared once at the end of the run. Cancelling keeps what was already saved.

New since last scan + regression alerts

Every finding carries a stable fingerprint (check + page + target), so each completed scan diffs itself against the previous one:

  • Findings absent from the previous scan get a violet New badge in the results and a "n new since last scan" chip on the score card. The first scan ever has no baseline and flags nothing.
  • Score regression alerts (History & Settings toggle, on by default, seo_scanner.alerts_enabled) email every admin/super user when a scan lands meaningfully below the previous one — a drop of 5+ points, or any drop that comes with a brand-new error-severity finding. The email lists the new findings worst-first and links to the dashboard. At most one alert goes out per ISO week (seo_scanner.alert_last_sent_week marker), so a flapping external host can't spam the owner; the same digest-style recipient rules as the Analytics weekly digest apply.

Google Search Console

The Search Console page (its own SEO-group sidebar entry, icon presentation-chart-line) puts real Google Search performance next to the scan that fixes what holds it back. Like the Reviews feature's Google Business Profile connect, each install brings its own Google Cloud OAuth client (client ID + secret with the Search Console API enabled, stored in seo_scanner.gsc_client_id / gsc_client_secret); the setup card shows the exact redirect URI to register. Connecting picks the matching verified property automatically — exact URL-prefix match first, then the sc-domain: property covering the host, then the account's only property — and falls back to a picker when several candidates qualify.

Once connected the page shows the last 28 days (ending two days back — Search Console data lags): clicks, impressions, average CTR, average position tiles, a clicks-per-day sparkline, and Top queries / Top pages tables with per-row clicks, impressions, CTR, and position. Data is cached for six hours (Refresh forces a re-fetch); tokens auto-refresh through the stored refresh token, and Disconnect just clears the stored connection — nothing changes in the Google account.

The connection is shared with the Analytics feature: the same panel (setup form included) also lives on Analytics → Search, and connecting in either place lights up both — plus the Analytics weekly digest, which gains a "Top Google searches" section with the week's top 5 query terms.

Search Console is a paid SEO Pro capability (moved from the free tier 2026-07, when it also moved from a scanner-page tab to its own page). With the addon off: the Search Console page and the Analytics → Search panel render the SEO Pro teaser instead, the OAuth connect/callback routes 404 (feature:seo_scanner middleware), the connection-establishing trait actions (saveGscCredentials / connectGscSite / refreshGscData) abort 403, and the digest's Google-searches section drops out even when a connection lingers from before a lapse. Disconnect stays ungated — a lapsed install must always be able to sever the Google connection. The page shell stays free like the other SEO pages (teaser funnel); route('dashboard.seo-scanner.gsc.callback') also stays registered so the redirect-URI display on the setup card always resolves.

Local rank tracking grid

The Local Rankings page (its own standalone SEO-group sidebar entry) tracks where the business appears in Google local results across a grid of map points — the "am I visible two miles east of my shop?" view single-position rank checkers can't answer. Each tracked keyword gets an n×n lattice (3×3, 5×5, or 7×7; ¼-mile to 5-mile point spacing) centered on the business; every point runs a Places API (New) Text Search biased to that location, and the business's index in the returned top-20 place IDs is its rank there. The grid renders as color-coded circles (green 1–3, amber 4–10, orange 11–20, red not-found; the center point ringed), with aggregates beside it: average position, best position, top-3 share of the map, top-10 count, and overall visibility.

Setup needs two things, both on the page's collapsible Setup card: a Google Places API key (seo_scanner.places_api_key, falling back to the Maps embed key maps.google_api_key when that one permits server-side Places calls) and the business's Place ID (seo_scanner.place_id — a Detect button resolves it from the Business Info name + address, and the connected Reviews Google source's Place ID is used automatically when the field is blank). The grid center prefers the actual Places pin for the Place ID and falls back to geocoding the Business Info address. Probes are field-masked to places.id — the cheapest Text Search SKU — but each grid point is still one Places request against the install's own key, so a 5×5 scan is 25 requests.

Scans reuse the site-scan engine pattern end to end: a seo_rank_scans row advances point-by-point in bounded time slices (RankScanRunner step() under a cache lock), driven by the dashboard's rankScanWorker poll or the seo-scanner:rank-scan LazyCron tick — which also starts the next due keyword's weekly re-scan (seo_scanner.rank_schedule_enabled, default on; pause individual keywords from the table). One rank scan advances at a time, keywords re-scan one per tick, a scan interrupted mid-grid resumes at the exact point it left off, and the last 10 scans per keyword are kept for the history picker. A probe failure marks that point unranked, but three consecutive failures at the start of a scan fail it with Google's error message (bad key, quota) instead of rendering an all-red grid.

Business listing audit

The Business Listing page (its own SEO-group sidebar entry) audits the Google Business Profile itself and grades it A–F. It reads the listing through the best available source: the Reviews feature's Business Profile OAuth connection when one exists (the only source that exposes the owner description, the full category list, and the true photo count — the audit reuses the connection's token refresh), else the Places API with the same key + Place ID the rank grid uses. Thirteen weighted checks cover completeness and NAP consistency: name (and mismatch vs. Business Info), primary + additional categories, description presence/length, phone (digit-matched against business.phone), website (host-matched against the site), address (street-matched, with service-area businesses exempt), hours, photo count, review volume, average rating, review recency and owner-reply rate (measured from the Reviews feature's synced mirror), and open status. Checks the current source can't answer drop out and redistribute their weight — the health-score convention — and are listed in a collapsed "not measurable" group so the grade never punishes a thinner connection.

Every failing or improvable check ships a concrete static suggestion, and when an AI text provider is configured a Generate Plan button turns the findings into a short prioritized action plan (stored on the audit row). Audits are manual, run synchronously, and persist to seo_listing_audits (snapshot + per-check results + score + grade) with a history picker on the grade card, so grade movement over time is visible.

Ignores and trusted URLs

Every finding has an Ignore link (fingerprinted per check + page + target, stored in the seo_scanner.ignored_issues Setting) — ignored findings are hidden immediately and skipped by future scans. External-link findings additionally offer Trust URL (seo_scanner.ignored_urls), which hides every finding about that URL and stops checking it entirely — the right tool for bot-hostile hosts you've verified by hand. Both are managed (and reversible) from the Manage ignored toolbar link.

The editor audit panel

With the feature enabled, every page in the page editor gets an SEO badge in the toolbar (next to the A11y badge): green SEO Passed, sky SEO Notices, amber SEO Warnings, red SEO Failed. Clicking it opens the SEO Audit modal. The per-page audit runs silently on every editor load and after every save, and checks:

  • Title presence and length — measured on the rendered title, site name appended per the title format unless the page sets Use title as-is (>60 characters gets a notice)
  • Meta description presence and length (50–160 characters recommended)
  • Title/description uniqueness against the whole site (the cached site index is dropped on every Page Settings save and every content item save, so a rename shows up immediately)
  • Missing image alt text on this page, inline rich-text images included (draft-aware — unsaved edits count)
  • Placeholder template copy — a prose field still showing its library template's demo text
  • Broken internal links and links pointing at redirects (resolved without HTTP, so the audit stays instant)

Issues tied to a row are click-to-fix: the editor scrolls to the offending field and highlights it. Page-settings issues (title/description) point you to Page Settings → SEO. External link checks, orphan detection, and the health score need whole-site context and live in the dashboard scanner only.

Which override tier each audit reads. The site-wide scan resolves a row's content_overrides the way the RENDERER does (SeoScanner::renderedOverride(), mirroring PageDataCompiler): for a page's own rows the global tier wins and a page-scoped copy is only a fallback; for shared rows the page-scoped record wins. The editor audit deliberately reads the editor's own hydration instead (page-scoped first) — it audits what the editor shows, which is the right frame for click-to-fix. Where both tiers hold a value for one key, the two can disagree, and the scan is the one telling the truth about the live page.

Internals

  • Module: app/Features/SeoScanner/ — registry key seo_scanner, display name SEO Pro, member_default: true. The page-shell routes (scanner index, search console, local rankings, business listing, SEO settings) are free (web + auth + verified + role:manager, no feature middleware); the scan-report route and the gsc/connect + gsc/callback OAuth pair keep feature:seo_scanner. Paid capability is gated in-blade (teasers) and in-action (assertSeoProEnabled() / assertSearchConsoleAvailable() 403 guards).
  • Tables: seo_scans (one row per scan; phase machine + progress + score + frozen page inventory), seo_scan_urls (one row per unique URL per scan, with per-source context capped at 20 entries plus a sources_total page count), seo_scan_issues (findings; fingerprint-indexed for ignores, plus is_new from the last-scan diff and resolved_at from one-click fixes). Disabling the feature preserves data.
  • Engine: SeoScanRunner advances a scan in bounded time slices (step($scan, $budgetSeconds)) under a cache lock, so the dashboard's Web-Worker-driven scanStep() polls and the LazyCron command can share one scan safely. SiteInventory owns the known-URL set, redirect graph (content slugs, page-redirect blocks, and Route::redirect() entries), and menu-link edges; LinkChecker owns external HTTP; SeoScanner owns page-source extraction (rows + overrides resolution mirroring the Accessibility Scanner) and is shared by the dashboard scan and the editor audit trait EditorSeoAuditActions.
  • v2 pieces: SeoRemediation (one-click fixes), SeoScanAlerts + SeoScanAlertMail (regression emails, sent from finalize()), SearchConsole + SearchConsoleController (GSC OAuth + Search Analytics reads).
  • Old scans are pruned automatically (last 10 kept).
  • Local rankings: tables seo_rank_keywords (keyword + grid shape + weekly-schedule state) and seo_rank_scans (probe grid json + cursor + aggregates). PlacesClient owns all Places API (New) HTTP (rank probes, Place ID detection, place details); RankGrid owns lattice geometry + aggregates; BusinessPlace resolves the audited business (Place ID setting → Reviews source fallback; Places pin → geocode fallback for the center); RankScanRunner is the stepper, driven from the dashboard poll and SeoRankScanCommand (seo-scanner:rank-scan, LazyCron every 300s).
  • Listing audit: table seo_listing_audits; ListingAuditor gathers the snapshot (Business Profile OAuth via the Reviews connection's GoogleBusinessProvider::accessToken(), or Places details), runs the weighted checks, scores with NA-weight redistribution, and letter-grades (A≥90 / B≥80 / C≥70 / D≥60 / F). AI action plans go through the shared AiTextService.

Comparison

SEO Scanner External crawler (Screaming Frog etc.)
Load on your server None for internal checks Full crawl of every page
Knows about unpublished/unlisted pages Yes — grades them accordingly No — sees only what's linked
Redirect chains from renamed content Read from the database Discovered only if the old URL is still linked somewhere
Runs on shared hosting Yes — no worker, no cron entry Separate tool + license
In-editor feedback while writing Yes No