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.
| Severity | Penalty | Meaning |
|---|---|---|
critical | −40 | High-impact finding in this rubric. |
warning | −15 | Finding to investigate soon. |
notice | −5 | A 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.
| Code | Severity | Penalty |
|---|---|---|
missing_title | critical | −40 |
missing_description | warning | −15 |
missing_og_image | notice | −5 |
duplicate_title | warning | −15 |
duplicate_description | warning | −15 |
title_too_long | warning | −15 |
title_too_short | notice | −5 |
description_too_long | warning | −15 |
description_too_short | notice | −5 |
robots_conflict_indexing | critical | −40 |
robots_conflict_following | warning | −15 |
noindex_warning | warning | −15 |
invalid_canonical | critical | −40 |
cross_domain_canonical | warning | −15 |
shared_canonical | notice | −5 |
insecure_canonical | warning | −15 |
http_error | critical | −40 |
empty_response | critical | −40 |
missing_canonical | notice | −5 |
missing_h1 | notice | −5 |
missing_image_alt | warning | −15 |
thin_content | notice | −5 |
mixed_content | warning | −15 |
canonical_target_broken | critical | −40 |
canonical_target_redirect | warning | −15 |
canonical_target_noindex | warning | −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):
| Code | Why it's excluded |
|---|---|
missing_focus_keyword | Advisory. 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_page | Informational. 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_h1 | Informational. Google tolerates multiple H1s — no multi-H1 penalty. |
blocked_url | Absence of evidence. The SsrfGuard refused the fetch, so the page was never checked — not a defect of the page. |
canonical_target_blocked | Absence 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_default | Advisory (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_mismatch | Advisory. Language checks are held out of this rubric. |
hreflang_not_reciprocal | Advisory. Optional reciprocity check, not scored. |
hreflang_target_unverified | Absence of evidence. Reciprocity could not be verified. |
aeo_missing_author, aeo_article_missing_date | Advisory. 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:
| Column | What it holds |
|---|---|
scannable_type / scannable_id | The scored model (null for URL targets). |
url | The scored URL. |
score | The 0-100 number. |
rubric_version | The rubric that produced it. |
penalty_total | Raw penalty sum before the 0-floor. |
scored_issues | How many issues moved the number. |
breakdown | [{code, severity, penalty}, …] — the full trace. |
keywords_enabled | The seo.keywords.enabled state at scan time (recorded for transparency; the score does not depend on it). |
scan_run_id | The run that scored it (set null, not deleted, when a run is pruned — scores are current state, not run history). |
scored_at | When it was scored. |
Reading the score
Headless — the latest score for a model:
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):
| Score | Grade |
|---|---|
| 90-100 | A |
| 75-89 | B |
| 50-74 | C |
| 25-49 | D |
| 0-24 | F |
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
// 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.