WP Custom SEO
Modern SEO management for WordPress with an AI assistant.
by Manpreet Singh · github.com/manpreetdev21/wp-custom-seo · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/manpreetdev21/wp-custom-seo/archive/refs/heads/main.zipReadme
WP Custom SEO
Modern SEO management for WordPress. Developed by Manpreet Singh.
Foundation, on-page SEO, the entity and schema graph layer, the aggregated schema API, sitemaps, breadcrumbs, social metadata, the redirect manager with its 404 monitor, the internal link graph, the bulk editor, Local SEO, the brand entity, WooCommerce, the AI assistant, the site audit, AI internal linking and FAQ generation, CSV import/export, migration from four other SEO plugins, WP-CLI commands, a configurable score model, Search Console, Analytics, email reports, AI crawler controls and the Abilities API.
Nothing here fabricates results it cannot produce. Where an external service is not connected, the feature says so and shows nothing, rather than showing zeroes or a placeholder.
Recommendations are recommendations. Nothing in this plugin is presented as a guaranteed ranking factor, and no score here represents any search engine's algorithm.
Requirements
- PHP 8.1+ (tested on 8.4)
- WordPress 6.5+
Install
Drop the folder in wp-content/plugins/ and activate. Composer is optional —
without vendor/, a small PSR-4 fallback autoloader takes over.
For development:
composer install
composer test # php tests/self-check.php
composer cs # phpcs against WordPress standards
composer i18n # regenerate languages/wp-custom-seo.pot (needs WP-CLI)
Lifecycle
Activation creates the tables, adds the capability to administrators and editors, and records the version. Deactivation is non-destructive: it deletes nothing, and clears every scheduled event, because an event for a hook nothing listens to any more is a row that sits in the cron option for good.
The list of those events is built from the constants the modules themselves declare rather than repeated as literal strings — it had already drifted once, and a hook that is renamed in one place and left scheduled here is invisible until someone reads the cron option.
Uninstall clears the scheduled events whatever your settings say — they are instructions to run code that is about to stop existing, not data. Everything else is removed only if you ticked Delete data on uninstall: the tables, the prefixed post, term and user meta, the prefixed options and transients, and the capability. Data belonging to other plugins is never touched, including anything this plugin imported from — a Yoast install is left exactly as it was.
Translations
Text domain wp-custom-seo, template at languages/wp-custom-seo.pot, loaded
on init — WordPress warns when a domain is loaded earlier, and nothing here
needs a translated string before then.
Architecture
| Path | Role |
|---|---|
wp-custom-seo.php |
Header, constants, autoloader selection, lifecycle hooks |
src/Core/Plugin.php |
Boots modules on plugins_loaded, applies pending migrations |
src/Core/Autoloader.php |
PSR-4 fallback for WPCustomSeo\ when Composer is absent |
src/Core/Activator.php |
Activation (multisite-aware), non-destructive deactivation |
src/Core/Capabilities.php |
The wpcseo_manage_seo capability |
src/Core/Settings.php |
Schema-driven settings on one option, via the Settings API |
src/Database/Migrator.php |
Versioned, filterable migrations |
src/Admin/Menu.php |
SEO menu, filterable page registry, page-scoped assets |
src/Admin/MetaBox.php |
Editor SEO panel: fields, nonce, save |
src/SEO/Meta.php |
Meta key registration and access |
src/SEO/Templates.php |
%%variable%% expansion and truncation |
src/SEO/Frontend.php |
Title, description, canonical, robots output |
src/SEO/Analyzer.php |
On-page analysis and scoring |
src/SEO/Weights.php |
The score model: what each check is worth |
src/Entities/Registry.php |
Site entities and their stable @ids |
src/Entities/Authors.php |
Author profile fields |
src/Schema/Graph/Graph.php |
Node collection, merge-by-@id |
src/Schema/Graph/Pieces.php |
Request and post graph builders |
src/Schema/Validator.php |
Structural and referential validation |
src/Schema/Faq.php |
Detection of a visible FAQ, and its nodes |
src/Schema/Conflicts.php |
Detection of other schema emitters |
src/Schema/Output.php |
Front-end JSON-LD |
src/Schema/Aggregator.php |
Paginated site-wide schema |
src/Schema/Cache.php |
Version-keyed aggregation cache |
src/Admin/SchemaPage.php |
SEO → Schema |
src/Admin/ToolsPage.php |
SEO → Tools |
src/Database/Tables.php |
The two custom tables |
src/Redirects/Redirects.php |
Rule storage, validation, loop detection |
src/Redirects/Engine.php |
Request matching and dispatch |
src/Redirects/NotFound.php |
404 logging and pruning |
src/Audit/Auditor.php |
The site audit |
src/Audit/Finding.php |
One finding, with its evidence |
src/Audit/Cannibalization.php |
Pages competing for one keyphrase |
src/Audit/Decay.php |
Pages worth re-reading |
src/Admin/AuditPage.php |
SEO → Site Audit |
src/Transfer/Sources.php |
Other SEO plugins' field maps and dialects |
src/Transfer/Import.php |
Batched copy from another plugin |
src/Transfer/Csv.php |
Spreadsheet export and import |
src/CLI/Command.php |
The wp seo commands |
src/Crawlers/AiCrawlers.php |
AI crawler controls in robots.txt |
src/Abilities/Abilities.php |
The wp-custom-seo/ abilities |
src/Reports/Report.php |
What a periodic report contains |
src/Reports/Mailer.php |
Subject, body and delivery |
src/Reports/Schedule.php |
Cron reconciliation |
src/Analytics/Client.php |
GA4 Data API client |
src/Analytics/Engagement.php |
Organic landing pages and totals |
src/SearchConsole/Account.php |
Service account key validation and storage |
src/SearchConsole/Token.php |
Signed assertion and access tokens |
src/SearchConsole/Client.php |
The API client, cached and error-translated |
src/SearchConsole/Performance.php |
Report shaping |
src/Admin/SearchConsolePage.php |
SEO → Search Performance |
src/AI/ProviderInterface.php |
The provider contract |
src/AI/AbstractProvider.php |
HTTP transport and error translation |
src/AI/{Anthropic,OpenAI,Gemini}Provider.php |
The three shipped providers |
src/AI/Credentials.php |
Encrypted API key storage |
src/AI/Manager.php |
Provider selection, throttling, logging |
src/AI/Prompts/ |
One class per prompt |
src/AI/Json.php |
Tolerant reader for structured model output |
src/Admin/BriefPage.php |
SEO → Content Brief |
src/AI/UsageLog.php |
Metadata-only usage log |
src/Admin/AIPage.php |
SEO → AI |
src/API/AIRoutes.php |
Generation endpoints |
src/Local/Locations.php |
Location post type, fields, shortcode |
src/Local/Schema.php |
LocalBusiness structured data |
src/WooCommerce/Integration.php |
Guard and WooCommerce hooks |
src/WooCommerce/Product.php |
Product structured data |
src/Links/Links.php |
Link graph storage and queries |
src/Links/Scanner.php |
Link extraction and batched rebuild |
src/Links/Candidates.php |
Real pages a post could link to |
src/Admin/RedirectsPage.php |
SEO → Redirects |
src/Admin/NotFoundPage.php |
SEO → 404 Monitor |
src/Admin/LinksPage.php |
SEO → Internal Links |
src/Admin/BulkEditorPage.php |
SEO → Bulk Editor |
src/Sitemap/Sitemap.php |
Shapes the core XML sitemap |
src/SEO/Breadcrumbs.php |
Trail builder, renderer, shortcode, block |
src/Social/Social.php |
Open Graph and X/Twitter tags |
src/functions.php |
Template functions for theme authors |
src/API/Routes.php |
Authenticated REST endpoints |
src/API/SchemaRoutes.php |
Public schema aggregation endpoints |
templates/admin/ |
Admin screen markup |
On-page SEO
Every public post type gets a SEO panel on its edit screen: focus keyphrase, SEO title, meta description, a search-result preview, and an Advanced tab with canonical URL and noindex/nofollow. It is a classic meta box, so it renders in the block editor, the classic editor and page builders alike, with no build step.
Meta keys, all registered through register_post_meta and therefore available
over the REST API with the same sanitization and capability checks:
_wpcseo_title, _wpcseo_description, _wpcseo_focus_keyword,
_wpcseo_canonical, _wpcseo_noindex, _wpcseo_nofollow.
Front-end output goes through native hooks — pre_get_document_title,
wp_robots, get_canonical_url and wp_head — so themes and other plugins
keep their usual chance to filter it. Everything is gated behind
Settings → General → Enable SEO output.
The optimization score
Analyzer scores 16 on-page checks: title length and keyphrase placement,
description length and keyphrase, content length, keyphrase distribution and
placement in the introduction, subheadings, heading hierarchy, images and their
alt text, internal links, external references, and the URL slug.
Each check reports what was detected, why it matters and what to do about it. The score is this plugin's own measure of how completely a page follows established on-page practice. It is not Google's algorithm and does not predict rankings.
The model is editable
Because the score is a checklist and not a measurement, you get to disagree
with it. Settings → Score model gives every check a weight from 0 to 5, and
Weights::defaults() is the single table those weights and labels come from —
they used to sit inline at each check, which made the model a fact about the
code rather than a decision anyone could see.
A weight of zero excludes a check from the score without hiding its advice. The check still runs and still says what it found, marked not counted in the editor. Silencing a recommendation and disagreeing with its importance are different things, and a site whose pages are short by design should not be told forever that its content is too short.
The fields are ordinary settings, so they are registered, sanitized, saved and
capability-checked by the same code as everything else. A check added through
wpcseo_analysis_checks that is not in the model is worth 1 rather than
nothing, so a third-party check is never silently ignored; add it to
wpcseo_score_model to give it a weight and a settings field of its own.
Entities and the schema graph
The plugin describes a site as entities with stable identifiers, and emits one
connected @graph per page rather than isolated JSON-LD blocks. An entity is
stated once and referenced by @id everywhere else, so a consumer can resolve
author, publisher and page into a single description.
Organization ──publisher── WebSite
│ │
│ isPartOf
│ │
└──publisher──── Article ──author──► Person
│
mainEntityOfPage
▼
WebPage ──primaryImageOfPage──► ImageObject
Identifiers are derived from the site URL — …/#/schema/organization,
…/#/schema/person/12, <permalink>#webpage, <permalink>#article — so they
stay stable across requests without a table to keep in sync.
Nothing is invented. Every builder omits a property it cannot establish
from real data, and returns nothing at all when the entity does not exist. The
organization trust properties (publishingPrinciples, correctionsPolicy,
ownershipFundingInfo, actionableFeedbackPolicy, diversityPolicy) are
emitted only when you supply a URL for a page that genuinely exists. Invalid
URLs in any list are discarded rather than published.
Validation
SEO → Schema validates the graph for a chosen post or the front page:
unique @ids, resolvable references, absolute URLs, present @type, and the
properties each type needs to be usable. Errors, warnings and notices are
distinguished — and a graph with errors is withheld from the front end
rather than published, because wrong structured data is worse than none.
The screen also lists other active structured-data emitters (Yoast, Rank Math, AIOSEO, SEOPress, Schema Pro, WooCommerce). Nothing is ever disabled automatically; you choose which source to turn off.
Per-post schema type lives on the editor's Schema tab. HowTo is
deliberately absent: it requires the visible page to actually contain that
content, and will be offered once the module can verify it.
FAQPage is derived, never chosen
FAQPage is not on the type list either, for the same reason — but it does not
need to be, because the plugin can verify it. Content is read for a real
question-and-answer structure: a heading ending in a question mark followed by
its answer, or a <details>/<summary> disclosure block. Find two or more and
the page node gains FAQPage alongside whatever it already was, carrying the
questions as mainEntity. Find fewer and nothing is emitted.
A heading that merely starts with an interrogative word ("How we work") is a statement, not a question, so the question mark is required rather than inferred. Generating FAQ text with the AI assistant does not switch this on: the text has to be on the page first.