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 rubricRankbeam\Seo\Pro\Scanning\ScoreRubric. Two things define it: an explicit allowlist of issue codes that count, and a fixed penalty per severity.

SeverityPenaltyMeaning
critical−40Blocks indexing or breaks the page.
warning−15A real defect to fix 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

Every code below is a deterministic, objective defect of the page itself. A critical costs 40, a warning 15, a notice 5.

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 on an apparently important page" contradiction 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). The hreflang validator surfaces in the scan + free audit but does not yet move the score — adding it would require a VERSION bump.
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, never-gameable 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 says noindex and signals it is meant to ship (be indexed). The signal is metadata-resolvable: a self-canonical page (its canonical names its own URL) declares itself the indexable version, so noindex contradicts it. A page with a cross-domain canonical is deliberately delegating indexation elsewhere — not a contradiction, so it does not fire. The emitted issue carries context.shipping_signal (e.g. self_canonical), plus the canonical and page_url it compared.

Both scanners apply this: the model scan (PageScanner) compares the stored canonical to the model's URL, and the rendered URL scan (UrlScanner) escalates a self-canonical noindex page from the informational noindex_page to the scored noindex_warning. That symmetry is why noindex_page itself is excluded from the score — the contradiction is always caught by noindex_warning, on either scan path.

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.