Skip to content

Scan issues — the issue-code registry

Every problem the Pro scan reports is a stable issue code drawn from a single registry, Rankbeam\Seo\Pro\Scanning\IssueRegistry. The scanners never invent a code inline — they build each issue through IssueRegistry::make(), which stamps the severity and field from the registry and rejects any code that isn't defined. That makes the catalogue below a contract you can build on: dashboards, exports, the free seo:audit command, and the Pro score all read these codes rather than parsing messages.

Each code carries:

  • id — the stable string stored as seo_scan_issues.issue_type.
  • severitycritical, warning, or notice; fixed per code (we split a code rather than vary its severity).
  • field — the seo_meta field it concerns, or page for page-level findings.
  • execution class — what a consumer needs to detect it (see below).
  • evidence — the keys the issue's context array carries.

Execution classes

A check falls into exactly one of three classes, by what it needs to run:

ClassNeedsWho can run it
metadatathe model + the core resolver — no page fetchthe model scan (PageScanner); the free seo:audit command
renderedthe page's served HTML (in-process kernel request, or an external fetch)the URL scan (UrlScanner)
networkan outbound fetch to validate a separate target (a canonical pointing elsewhere)the URL scan, always through the SsrfGuard

This is why a free, in-process audit can never equal the full Pro scan: only the metadata codes are computable without rendering a page, and only the Pro pipeline fetches rendered HTML and validates canonical targets over the network. Filter the registry by class with IssueRegistry::byClass(IssueRegistry::EXEC_METADATA).

Metadata codes

Detected from the model + resolver by PageScanner. (missing_title, missing_description and the length codes are also emitted by the rendered URL scan, measuring the served <head> — same codes, same meaning.)

CodeSeverityFieldEvidenceMeaning
missing_titlecriticaltitleNo title and no computable fallback.
missing_descriptionwarningdescriptionNo meta description and no computable fallback.
missing_og_imagenoticeog_imageNo Open Graph image and no computable fallback.
missing_focus_keywordnoticefocus_keywordsNo focus keyword set.
duplicate_titlewarningtitletitle, duplicate_urlsTitle reused on other pages in the same locale.
duplicate_descriptionwarningdescriptiondescription, duplicate_urlsDescription reused on other pages in the same locale.
title_too_longwarningtitlelength, maxResolved title over the 60-char recommendation.
title_too_shortnoticetitlelength, minResolved title under the 30-char floor.
description_too_longwarningdescriptionlength, maxResolved description over the 160-char recommendation.
description_too_shortnoticedescriptionlength, minResolved description under the 70-char floor.
robots_conflict_indexingcriticalrobotsrobotsRobots directive has both index and noindex.
robots_conflict_followingwarningrobotsrobotsRobots directive has both follow and nofollow.
noindex_warningwarningrobotsrobots, canonical, page_url, shipping_signalA self-canonical (apparently important) page is noindex. Emitted on both model and rendered URL scans.
invalid_canonicalcriticalcanonicalcanonicalCanonical value is not a valid URL.
cross_domain_canonicalwarningcanonicalcanonical, page_urlCanonical points to a different host than the page.
shared_canonicalnoticecanonicalcanonicalSeveral pages declare the same canonical.
insecure_canonicalwarningcanonicalcanonicalhttp:// canonical on an https site (mixed content).
hreflang_invalid_codewarningalternatesinvalid_codesAn hreflang alternate uses a value that is not x-default nor a valid BCP-47 language code.
hreflang_missing_self_referencewarningalternateslocale, page_urlAlternates are declared but none references the page's own locale (a self-referencing hreflang).
hreflang_duplicate_codewarningalternatesduplicate_codesThe same hreflang code maps to more than one URL (an ambiguous cluster).
hreflang_missing_x_defaultnoticealternateslanguagesA multi-language hreflang cluster has no x-default fallback.
aeo_missing_authornoticeschemaAn article in the page's structured data has no author entity (no attribution for AI answer engines).
aeo_article_missing_datenoticeschemaAn article in the page's structured data has no published/modified date (a recency signal for AI answer engines).

The length thresholds reuse the core SEOWarningEvaluator constants (60/160), so a scan never contradicts the editor's character counters; the lower bounds (title 30, description 70) are the Pro scan's under-optimised floor and live on IssueRegistry::TITLE_MIN_LENGTH / DESCRIPTION_MIN_LENGTH. Length is measured against the resolved title/description — the value that actually renders, including any fallback and title suffix.

The hreflang_* codes validate a page's declared hreflang alternates (read from the resolver's alternates): invalid/duplicate codes, a missing self-reference, and a missing x-default on a multi-language cluster. They run only when the page declares alternates. Cross-page reciprocity ("return tags") is not yet validated.

The aeo_* codes are answer-readiness (AEO) signals — is the page's article content extractable and attributable by AI answer engines? They read the resolved JSON-LD graph and fire only when it declares article-type structured data (Article, BlogPosting, NewsArticle, …) missing an author entity (attribution / E-E-A-T) or a datePublished / dateModified (recency). A page without an article is never flagged. They are gated by seo-pro.scan.checks.aeo (on by default) and mirror the free seo:audit.

missing_focus_keyword is gated

The focus-keyword notice only fires when the core focus-keyword workflow is enabled (seo.keywords.enabled, default false). While off, the scan does not flag a page for having no focus keyword. The free seo:audit command and the Filament editor read the same core flag, so the scan, the audit, and the editor nag always agree — there is only ever one gate.

Rendered codes

Detected by UrlScanner from the served HTML — for same-host targets via an in-process kernel request (no outbound traffic), for external targets via a guarded fetch.

CodeSeverityFieldEvidenceMeaning
http_errorcriticalpagestatusURL responded with a 4xx/5xx status.
empty_responsecriticalpageURL returned an empty body.
missing_canonicalnoticecanonicalNo <link rel="canonical"> in the rendered head.
noindex_pagenoticerobotsrobotsRendered page is noindex (informational). A noindex page that is also self-canonical is escalated to the scored noindex_warning instead.
missing_h1noticepageNo <h1> heading.
multiple_h1noticepagecountMore than one <h1> (informational).
missing_image_altwarningpagecount, total, sampleContent images missing an alt attribute (an explicit alt="" is treated as decorative, not flagged).
thin_contentnoticepageword_count, thresholdBody text below the configured word count.
mixed_contentwarningpagecount, samplehttp:// sub-resources on an https page.

Network codes

Detected by UrlScanner only when seo-pro.scan.url_checks.check_canonical_target is enabled. The canonical target is fetched through the SsrfGuard (scheme allowlist, host scope, private-IP rejection, redirect/time/size budgets) and not followed through redirects, so a redirecting canonical is visible. A self-referencing canonical is skipped — the page itself was just fetched.

CodeSeverityFieldEvidenceMeaning
blocked_urlnoticepagereasonA target was refused by the SsrfGuard before any HTTP happened.
canonical_target_brokencriticalcanonicalcanonical, statusCanonical points to a page returning an HTTP error.
canonical_target_redirectwarningcanonicalcanonical, status, locationCanonical points to a page that redirects; point it at the final URL.
canonical_target_noindexwarningcanonicalcanonicalCanonical points to a page that is itself noindex.
canonical_target_blockednoticecanonicalcanonical, reasonCanonical target could not be verified (guard refusal / unresolvable).

Every network path here reuses the shared SsrfGuard; see SECURITY.md for the threat model and the residual TOCTOU note.

How codes feed the score

The Pro SEO score is 100 − a fixed penalty per scored issue, weighted by the severities above. Most codes count; a few are deliberately excluded — missing_focus_keyword (advisory), noindex_page and multiple_h1 (informational), blocked_url / canonical_target_blocked ("we couldn't check" ≠ a defect), and the hreflang_* and aeo_* codes (advisory signals held out of the score for now). The scoring page has the full allowlist and the penalty for every code.

Configuration

php
// config/seo-pro.php → 'scan'
'url_checks' => [
    'enabled' => true,
    'crawl_external' => false,          // fetch external URL targets (guarded)
    'check_canonical_target' => false,  // EXEC_NETWORK canonical validation (guarded)
],
'checks' => [
    'length' => true,            // title/description length (metadata + rendered)
    'rendered_content' => true,  // H1 / alt / thin content / mixed content
],
'content' => [
    'min_word_count' => 200,     // thin_content threshold
    'evidence_sample' => 5,      // max example URLs stored per issue
],

The response-size budget for guarded fetches is seo-pro.http.max_response_bytes (default 2 MB); the in-process same-host scan is not capped.

Compatibility note (issue-code rename)

The previous single robots_conflict code (which carried two severities) was split so every code maps to exactly one severity:

Old codeNew codeSeverity
robots_conflict (index + noindex)robots_conflict_indexingcritical
robots_conflict (follow + nofollow)robots_conflict_followingwarning

If you stored or filtered on robots_conflict, update to the two new codes.

rankbeam/laravel-seo is released under the MIT License.