Skip to content

The SEO score — transparent, versioned, Pro-owned ​

The Pro scan gives every page a 0-100 SEO score — the single number a RankMath or Yoast migrant looks for. Unlike a black-box grade, this one is fully auditable: every point it deducts traces to exactly one scan issue, and the same set of issues always produces the same number.

score = 100 − Σ penalty(issue) for each scored issue   (floored at 0)

One number, one owner

The numeric score is a Pro feature. It lives on the Pro seo_scan_results record, never back in core's seo_meta (the old seo_score column was removed in Core 3). The free core seo:audit reports per-page pass / warn / fail with no number — the score is the paid value-add.

The rubric ​

The score is computed from a published, versioned rubric — Rankbeam\Seo\Pro\Scanning\ScoreRubric. Two things define it: an explicit allowlist of issue codes that count, and a fixed penalty per severity.

SeverityPenaltyMeaning
critical−40High-impact finding in this rubric.
warning−15Finding to investigate soon.
notice−5A nice-to-have.

Each code's severity is read straight from the issue registry (the single source of truth) — the rubric never re-derives it. Severity is 1:1 per code precisely so the score stays deterministic.

What the score counts ​

These are deterministic checks selected by Rankbeam’s product rubric, including heuristics that need editorial interpretation. A critical costs 40, a warning 15, a notice 5; the score is not a prediction of search performance.

CodeSeverityPenalty
missing_titlecritical−40
missing_descriptionwarning−15
missing_og_imagenotice−5
duplicate_titlewarning−15
duplicate_descriptionwarning−15
title_too_longwarning−15
title_too_shortnotice−5
description_too_longwarning−15
description_too_shortnotice−5
robots_conflict_indexingcritical−40
robots_conflict_followingwarning−15
noindex_warningwarning−15
invalid_canonicalcritical−40
cross_domain_canonicalwarning−15
shared_canonicalnotice−5
insecure_canonicalwarning−15
http_errorcritical−40
empty_responsecritical−40
missing_canonicalnotice−5
missing_h1notice−5
missing_image_altwarning−15
thin_contentnotice−5
mixed_contentwarning−15
canonical_target_brokencritical−40
canonical_target_redirectwarning−15
canonical_target_noindexwarning−15

Because the metadata codes are detected on a model scan and the rendered/network codes only on a URL scan (see the execution classes), a model target's score reflects the metadata checks and a URL target's score reflects the rendered page. A model scan scoring 100 means "no metadata defects", not "the rendered page is perfect" — scan the URL for that.

What the score deliberately does NOT count ​

These registry codes are excluded on purpose. The exclusions are part of the contract (a test asserts every registry code is either scored or listed here):

CodeWhy it's excluded
missing_focus_keywordAdvisory. Gated behind the opt-in seo.keywords.enabled workflow — a page must not score lower for not adopting focus keywords, and the score must not depend on a config flag.
noindex_pageInformational. Being noindex is a deliberate state, not a meta-quality defect. The "noindex with a self-canonical" heuristic is scored via noindex_warning instead.
multiple_h1Informational. Google tolerates multiple H1s — no multi-H1 penalty.
blocked_urlAbsence of evidence. The SsrfGuard refused the fetch, so the page was never checked — not a defect of the page.
canonical_target_blockedAbsence of evidence. The canonical target could not be verified — not a defect of the page.
hreflang_invalid_code, hreflang_missing_self_reference, hreflang_duplicate_code, hreflang_missing_x_defaultAdvisory (for now). These Pro hreflang codes surface in the scan; the free audit has its own hreflang codes but does not yet move the score — adding it would require a VERSION bump.
html_lang_missing, html_lang_invalid, html_lang_mismatchAdvisory. Language checks are held out of this rubric.
hreflang_not_reciprocalAdvisory. Optional reciprocity check, not scored.
hreflang_target_unverifiedAbsence of evidence. Reciprocity could not be verified.
aeo_missing_author, aeo_article_missing_dateAdvisory. Answer-readiness (AEO) signals — they flag an article missing an author entity or a publish date in the scan + free audit, but do not move the score (would require a VERSION bump).

Keyword density, power words, and the rest of the on-page checklist never enter the score at all — they are advisory checks (a separate pass/warn/fail list), not registry codes.

Versioning — historical scores never silently change ​

Every persisted score is stamped with the ScoreRubric::VERSION that produced it (rubric_version). Two consequences:

  • A new issue code scores nothing until it is deliberately added to the allowlist — so shipping a new check can never retroactively change a stored score. (An allowlist or weight change is itself a rubric change and bumps the version.)
  • The score is stored, not recomputed on read. The number you saw last week is the number you see today, alongside the rubric that explains it.

Where it's stored ​

Each scan upserts one row per target into seo_scan_results:

ColumnWhat it holds
scannable_type / scannable_idThe scored model (null for URL targets).
urlThe scored URL.
scoreThe 0-100 number.
rubric_versionThe rubric that produced it.
penalty_totalRaw penalty sum before the 0-floor.
scored_issuesHow many issues moved the number.
breakdown[{code, severity, penalty}, …] — the full trace.
keywords_enabledThe seo.keywords.enabled state at scan time (recorded for transparency; the score does not depend on it).
scan_run_idThe run that scored it (set null, not deleted, when a run is pruned — scores are current state, not run history).
scored_atWhen it was scored.

Reading the score ​

Headless — the latest score for a model:

php
use Rankbeam\Seo\Pro\Facades\SeoPro;

$result = SeoPro::resultFor($post);

$result?->score;     // e.g. 85
$result?->grade();   // 'A'..'F'
$result?->breakdown; // [['code' => 'cross_domain_canonical', 'severity' => 'warning', 'penalty' => 15]]

php artisan seo-pro:scan-status prints the average site score in its summary. The Filament dashboard shows it as the headline "Avg. SEO score" stat, coloured by grade.

Grade bands ​

A presentational letter grade derived from the number (the number is the contract):

ScoreGrade
90-100A
75-89B
50-74C
25-49D
0-24F

The shipping signal (noindex_warning) ​

noindex_warning fires when a page combines noindex with a self-canonical (a canonical naming its own URL). Rankbeam treats this as a shipping signal to review. A self-canonical does not prove that indexing is intended: the combination can be deliberate. A cross-domain canonical does not trigger this heuristic. The issue carries context.shipping_signal (for example self_canonical), plus the compared canonical and page_url.

Both scanners apply the check. The model scan (PageScanner) compares the stored canonical to the model URL. The rendered URL scan (UrlScanner) escalates a self-canonical noindex page from informational noindex_page to scored noindex_warning. That is why noindex_page itself is excluded: the potential conflict is handled by noindex_warning on either path. Review the page's actual intent before changing its indexing directive.

Configuration ​

php
// config/seo-pro.php → 'scan'
'score' => [
    'enabled' => true, // turn the scoring pass + its persistence on/off
],

The allowlist and the weights are not configurable: the score must be deterministic for a given rubric_version across every install, so changing how it is computed is a code-level rubric change, not a setting.

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