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 + Keyword positions tracking with milestones (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. The same goes for a link to a switched-off feature module (/shop with Ecommerce off): its routes stay registered and 404 at request time, so the resolver asks EnsureFeature::allows() too. 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 (their pages row via PageSettings, falling back to the file's @page marker / #[Title] only for a page with no row yet) and content items (og_title ?: meta_title ?: title), case-insensitive. A pinned item page (a promoted page whose head delegates to ContentItemDetail::resolveById) keeps no pages row: its title, description and robots are read from its record, the way its <head> renders them (SeoScanner::pageSettings()), and its meta findings link to that record.
Missing meta descriptions Warning Static pages with no description on their pages row; content items with no meta description, OG description, or excerpt. Noindex pages and the 404 page are exempt — every miss is served with a 404 status, so it never competes in search; its shipped copy isn't graded either.
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, and on any field the template marks field-final-copy="true" (an x-dl.heading / x-dl.subheadline attr, carried into the schema as final_copy) — a widget's state labels, a stock eyebrow, a search page's messages, a sidebar card's title ("Search", "Categories", "Recent Posts"), the FAQ rows' "Frequently Asked Questions" heading: text that is correct as written. A shared row is graded on its shared file (SharedRowFiles::savedBlade()), not on the library template its @include stub would otherwise fall back to — the same rule the accessibility scan and both editor audits follow. Same check the editor runs, now site-wide.
Orphan pages Warning Published static pages with zero inbound links from other pages, the navigation menus, or the default header and footer (their links count as inbound edges; they are not link-checked). A link to a neutral entry URL counts for the page its controller sends a signed-out visitor to — /account and /account/login for the member login (AccountRedirectController::signedOutLoginUrl()), /members/subscribe for sign-up — without being graded as a redirect (SiteInventory::entryRedirects()). 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's pages row (PageSettings::upsert() — the canonical store PlainPageController renders and the scan reads), plus the derived #[Layout(...)] 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, stacked full-width, with per-row clicks, impressions, CTR, and average position ("Avg. pos."). 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.

Each table ends in a short legend explaining its columns, because two of them are easy to misread:

  • Impressions are not visits. An impression is one time a link to the page appeared on a Google results page someone loaded. A page ranking around #40 can show thousands of impressions and no clicks, and that is accurate: almost nobody clicks that deep.
  • A page's position is a blend. On Top queries, the position is your best result for that one search. On Top pages, it is averaged over every search the page showed for, weighted by impressions. Click a Top pages row to open the searches behind it: each with its own clicks, impressions and position, sorted by impressions. A row opens only when the page is in the report as it stands (gscOpenPage is client-writable and clamped by gscOpenPageUrl()). The lookup (SearchConsole::pageSearches()) is two reads with a page equals filter: the page's own total and its query-dimension rows, cached together for six hours per page and retired by Refresh like the other filtered entries. A failed lookup shows its error inside the opened row and leaves the rest of the report alone. Google leaves rare searches out of the list, so the opened row notes the impressions no listed search accounts for (0 when the list hit REPORT_ROW_LIMIT). That note subtracts from the lookup's own total, never the Top pages row: the report entry can be hours older and cover a window a day earlier.

Views beside impressions (with Analytics on). When the Analytics feature is enabled, Top pages gains two sortable columns from its own page_views rollup, for the same dates as the report (SearchConsole::window() serves both):

  • Views is every page load Analytics counted.
  • From Google is the part whose referrer is a Google site: google.*, or the Google app on Android. Maps, Business Profile and Google Ads visits arrive with the same google.com referrer, so From Google can be higher than Clicks. A Google sign-in (accounts.google.com) or a link in Gmail (mail.google.com) is not counted.

PageViewTotals does one GROUP BY path over the window, cached for an hour (the window ends two days back, so its days take no new hits). Both sources get the same calendar dates; Google files a day in Pacific time and Analytics in the site's timezone, an offset of a few hours daily rollups can't remove. A page URL and a stored path meet on PageViewTotals::pathKey(): the path alone, percent-decoded, without query string, fragment or trailing slash.

A dash ("—") means not counted, never zero visits, and sorts last whichever way the column runs. Two cases:

  • Another host. A domain property also reports other hosts under the domain (a second install on a subdomain). A page counts as this install's when its host, www.-insensitive, is the app.url host or the host serving the dashboard (SearchConsole::installHosts()), so an install whose APP_URL still names a preview host still matches its live domain. Such a row shows its host before the path, since the path alone would read as this site's page.
  • A page Analytics never records: a sign-in page or an operator-excluded path, decided by TrackPageView::tracksPath(), the same check the tracker uses.

With Analytics off, the columns are absent, page_views is never read, and a hand-crafted sort by them falls back to clicks.

Filter by language. On a site with more than one language, a Language picker in the report header (here and on Analytics → Search) narrows every tile, the sparkline and both tables to one language's pages. Google does the filtering: the request carries a page filter. For a non-default language it is an includingRegex on the URL prefix (^[^/]*//[^/]+/(es)([/?#]|$), so any scheme and host, which covers a domain property's http:// and www. variants). For the base language it is an excludingRegex over every other language's prefix. It uses the same prefix rules as the Analytics filter (LanguagePathFilter). Each filter has its own six-hour cache entry. The entry is keyed by the filter itself, not the language code, so adding a language re-fetches the base language's report. Refresh (and connect/disconnect) retires every filtered entry by bumping a cache generation, a removed language's included. The all-languages entry keeps its original key, which the weekly digest reads. Once a page filter is applied, Google totals clicks and impressions per page rather than per property, so the languages' figures may not add up exactly to the All languages total. Keyword positions are not filtered: they are stored weekly per search, not per page. The choice lives in the URL (?language=es); an unknown code reads as All languages.

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.

Keyword positions

Below the Search Console report, Keyword positions tracks your average Google position for the searches you choose, week by week. Its main use is checking whether a change helped: new backlinks, a page rewrite, a redesign. It is a different measure from Local rank tracking: this one is the ordinary results list (organic), the one links and page content move. Local rankings are the map results.

  • Adding a search loads its history immediately. Search Console keeps 16 months, and the first sync backfills 68 complete weeks, so a search added today already has its "before". Searches are stored the way Google records them (lowercase, single-spaced) because Search Console's exact-match filter is case-sensitive. A pasted web address is refused. Up to 50 searches. Chips under the box offer your current top searches, so the exact wording is one click away. They come from the report above's cache, so the section never waits on Google to render.
  • The table shows each search's impression-weighted average position over the last 4 complete weeks, and the change against the 4 weeks before:
    • green up N when the position number fell
    • red down N when it rose
    • Now showing or Dropped out when one side had no impressions
    • impressions, clicks, and a 26-week sparkline
  • The chart draws the selected search's weekly position with position 1 at the top. A week with no impressions is a gap, not a zero. Each point's tooltip and the Weekly numbers table give the raw figures.
  • Milestones are dated notes, such as "Footer links added". Each shows as a dashed line on every chart. Picking one in Compare switches the table to Before / After / Change / Weeks:
    • The milestone's own week is left out of both sides.
    • Both sides use the same number of complete weeks, up to 12. "Before" is the weeks just before the milestone; "after" is the most recent weeks since it, stopping at the next milestone.
    • Fewer than 4 weeks is flagged early read. Until the first full week after a milestone is reported, the row says the date it lands. If the next milestone falls before any complete week separates the two, the row says it is too close to measure.

Weekly email. When the Analytics weekly digest is on, it carries a Keyword positions table: each search's position for the newest reported week against the week before, big moves (3+ places on at least 10 impressions each week, or dropping out of or into the results) first, plus before → after for the newest milestone. Details are in the Analytics digest.

Freshness. A week counts as complete 3 days after its Sunday, using Pacific-time dates as Search Console does. Each sync also re-fetches the newest 3 stored weeks, because Google revises recent days. The seo-scanner:keyword-sync LazyCron task runs hourly and re-syncs each search about once a day, a few searches per tick. Page views read only the database, never Google.

When it can't update. A failed sync (expired token, revoked access, Google outage) is recorded on the search as a red Not updated badge, with a Retry now button. The warning above the table shows Search Console's own message; any other failure shows a generic line and is written to the application log. The sweep stops at the first failure and retries a failed search hourly, so a broken connection costs one call an hour, not one per search. It also starts another search only while one slow call (20 seconds) would still finish before its time budget.

Gaps that are not bugs:

  • A search with very little volume shows No impressions. Google withholds rare queries, so pick an exact phrase from your top searches.
  • Weeks before your Search Console property was verified read as no impressions.
  • Reconnecting to a different property re-syncs every search from scratch, so two properties' numbers never mix. Until a search's re-sync runs, it shows Loading history instead of the old numbers. Disconnecting changes nothing stored.
  • php artisan seo-scanner:keyword-sync --all syncs every search now, whether or not it is due, including ones that failed recently.

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. It works for a business with several locations: each Google Business Profile listing is a tracked location, and every keyword is scanned around each one. Each scan is an n×n lattice (3×3, 5×5, or 7×7; ¼-mile to 5-mile point spacing) centered on one location; 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 numbered circles drawn over a street map at each probe point's real location (green 1–3, amber 4–10, orange 11–20, red not-found; the business's center point ringed; hover a circle for its position and coordinates), with aggregates beside it: average position, best position, top-3 share of the map, top-10 count, and overall visibility. The map is Leaflet over the keyless Esri light/dark canvas basemaps (rank-grid-map.js); if the map can't load, the same results show as a plain colored grid.

Setup needs 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 at least one location on the Locations card. The key, the default grid size and spacing for new keywords, and the weekly re-scan switch live in a Local Rankings section on SEO → Settings (shown only while SEO Pro is on; the page header's Settings button jumps to it). Until a key is saved, the same form also leads the Local Rankings page, then drops off it. The Locations card works the same way: it sits at the top, open, until some location can be scanned (active, with a Place ID), then moves to the bottom of the page, collapsed to a one-line summary (how many tracked, paused, and still needing their Google listing picked). Probes are field-masked to places.id — the ID-only Text Search SKU, which Google lists as free with no cap — so scanning many locations doesn't multiply probe cost. Each grid point is still one request against the install's key, so a 5×5 scan is 25 requests.

Locations

The Locations card is the list of Google listings the install tracks, shared with the Business listing audit. Each location has a name, its Place ID, the name and address Google returned when it was matched (shown under the name, so a wrong match is visible), and a pause switch that leaves it out of scans while keeping its history. Locations get there three ways:

  • Add location — type a search (or leave it blank to search the Business Info name + address), click Find, and pick from up to five matches; service-area businesses show as having no public address. A Place ID can also be pasted, and a places/… resource name is stored as the bare ID. Two locations can't share a Place ID. Correcting a location's Place ID clears the results recorded for its old listing, so the corrected one is scanned at the next weekly tick instead of reading as already up to date.
  • Import from Locations — shown when the site has published records in the locations content type that aren't tracked yet. Each record is matched by its Business listing name (business_name, else its title) plus its address, one billed Places lookup per record, about 20 seconds of lookups per click (the toast says how many are left). Re-running it skips records already imported. A record that matches nothing, or matches a listing another record already holds — an urgent care and the imaging suite at the same address — is still added, with no Place ID and a note to pick its listing by hand. It is never merged into the other location, and never looked up again on the next import. When the record has coordinates, Google's match must sit within 3 km of them: the first match that does wins, and a record whose matches are all farther away (a same-named business in another town) is added the same way, with a note giving the distance. A record with no address at all is matched by name alone and noted so someone checks it.
  • Adopted automatically — an install that was set up before locations existed (one Place ID in seo_scanner.place_id, or a connected Reviews Google source) gets that listing as its first location the first time the page or the weekly scan runs, with its earlier scans attached. Any other place an old scan measured becomes a paused "earlier listing" so its history stays reachable. This happens once (seo_scanner.locations_adopted); deleting every location does not bring the old one back.

A location's grid center is Google's pin for its listing (captured when it was matched, or fetched once with a Places details lookup and kept on the row), else the linked record's coordinates, else, for the main business only, a geocode of the Business Info address. Only Google's pin is stored. The record and the address are read fresh for every scan, so editing them moves the next grid. Deleting a location deletes its scan history.

The main business is the location whose Place ID is in seo_scanner.place_id (or the connected Reviews source's), else the first location in the list. Saving the first location on an install that never had a Place ID writes that setting, and correcting the main location's Place ID updates it. The main business is the one compared against Business Info in the listing audit, and the one the Service Area map centers on.

All locations at once

With two or more locations the page opens on All locations. Each keyword row combines every location's latest grid into one map — the average runs over every point where the business ranked, not over each location's average, so a location found at one point doesn't weigh the same as one found at all 49 — plus a Locations column (how many of the scannable locations have results). Below the table, a by location breakdown for the selected keyword lists each location's average, top-3 and found counts, when it was last scanned, and why its last scan failed if it did. View grid opens that location's map; the Showing select switches to any single location, where the page works the same as for a one-location business.

Scans

Scans reuse the site-scan engine pattern end to end: a seo_rank_scans row (one keyword at one location) 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. Re-scan in the All-locations view queues the keyword at every active location; the runner promotes queued scans one at a time (under a promote lock, so the dashboard and a LazyCron tick never run two at once), and a location that can't be placed fails its own scan with the reason and the next one starts. A location paused after the batch was queued is skipped when its turn comes. When the batch ends, the toast says how it went: finished everywhere, how many of the locations failed, or (when every one did) Google's error. Cancel clears the running scan and everything queued behind it. --full on the command drains everything in flight; if another process (an open dashboard) is already advancing the scan, it waits a second at a time and leaves it to them after 30 seconds without progress.

The LazyCron tick also starts the weekly re-scans (seo_scanner.rank_schedule_enabled, default on; pause individual keywords or locations from their tables): one keyword-at-a-location pair per tick, never-scanned pairs first, then the oldest. A pair is due once its last completed scan is a week old, and it is left alone for 24 hours after any attempt, so a bad key or an unplaceable location can't start a new scan every 5 minutes. A scan interrupted mid-grid resumes at the exact point it left off, and the last 10 scans per keyword per location 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. Fourteen weighted checks cover completeness, NAP consistency, and conversion: 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), open status, and the Book Online link (the profile's appointment link, read from the Place Actions API — Business Profile connection only): with Online Booking on it must point at the site's own /book page (missing fails, a third-party scheduler or the wrong page warns); with Online Booking off a missing button drops out rather than counting against the grade and an external one passes. 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.

Several locations. With two or more tracked locations a picker beside Run Audit chooses which listing to grade, and each location keeps its own audit history (audits from before locations file under the main business). Three things change for a location that isn't the main business:

  • NAP is compared against that location's own record — the linked Locations record's business listing name, phone and street — not Business Info, which describes head office. A location with no record only has its name, phone and address checked for presence, so a clinic is never flagged for not matching head office.
  • The Business Profile connection is used only for the listing it belongs to (the connection's recorded Place ID, else the main business). Every other location is audited through the Places API.
  • Review recency and owner replies count only that location's synced reviews (a Reviews Google source with its Place ID). A location with none has those checks marked not measurable instead of graded on another location's reviews.

A location with no Place ID yet shows a link to pick its listing on Local Rankings instead of a Run Audit button.

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.

The audit grades the listing; Analytics counts what it did. The same Business Profile connection also feeds the Google listing block on Analytics → Sources — daily calls, website clicks, and direction requests straight from the listing, which is the only place a business can see the customers who tapped Call from the Google panel and never loaded a page. See Google listing performance. Connect once under Reviews and both surfaces light up.

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).
  • Keyword positions: tables seo_search_keywords (normalized search, the property its weeks came from in site_url, sync stamps and sync_error), seo_search_keyword_weeks (one row per search per Monday week_start: clicks, impressions, impression-weighted position, null when there were no impressions; unique per search+week) and seo_search_milestones (date + label). KeywordPositionSync does one date-dimension Search Analytics query per search (an equals query filter through SearchConsole::queryRange(…, $filters)), upserts complete weeks under a per-search cache lock, and wipes the weeks when the connected property changed. KeywordTrend holds the pure week, window and milestone-comparison math; PositionChart holds the inverted-axis SVG geometry. The UI is the child component seo_scanner::dashboard.keyword-positions, embedded on the Search Console page; SeoKeywordSyncCommand (seo-scanner:keyword-sync, LazyCron every 3600s) drives the sweep.
  • Local rankings: tables seo_locations (the tracked listings: name, nullable unique place_id, Google's matched name/address, cached lat/lng, optional content_item_id link to a Locations record, an import note, is_active, sort_order), seo_rank_keywords (keyword + grid shape) and seo_rank_scans (one keyword at one location via seo_location_id; status queued → running → completed/failed/cancelled; probe grid json + cursor + aggregates). TrackedLocations owns the list: legacy adoption, import from the content type, grid centers, and each location's expected NAP for the audit. PlacesClient owns all Places API (New) HTTP (rank probes, Place ID lookup with pins, place details); RankGrid owns lattice geometry, aggregates, and combine() for the all-locations figures; BusinessPlace still resolves the install's main business (Place ID setting → Reviews source fallback; Places pin → geocode fallback), used by adoption, the main-business checks and Service Area; RankScanRunner is the queue + stepper (start(), queue(), advance(), cancelInFlight()), driven from the dashboard poll and SeoRankScanCommand (seo-scanner:rank-scan, LazyCron every 300s; --full drains everything in flight).
  • Listing audit: table seo_listing_audits (a location audit is filed under that location's place_id); ListingAuditor audit($userId, $location) gathers the snapshot (Business Profile OAuth via the Reviews connection's GoogleBusinessProvider::accessToken() when it covers that location, or Places details), runs the weighted checks against that location's expected NAP and review sources, scores with NA-weight redistribution, and letter-grades (A≥90 / B≥80 / C≥70 / D≥60 / F). With no location it audits the main business, as before. 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