SEO system
FortPass includes an end-to-end SEO system: full meta tags on every public page (title, description, keywords, canonical, robots, Open Graph, Twitter Card), an admin editor with Google-style preview, a configurable Twitter handle applied to every page, a dynamic sitemap.xml that includes static pages plus all published blog posts with real lastmod / priority / changefreq, a dynamic robots.txt that references the sitemap and blocks authenticated routes, and per-post SEO inside the blog editor (per-item overrides).
Overview
/admin/seo (admin)
- Twitter handle, OG image, keywords
- Per-page editor (Google preview)
- Sitemap status
|
v
settings + seo_pages tables (admin source of truth)
|
+-------------+-------------+
| |
v v
seo_render($con, $config, sitemap_build($con)
$page_key) -> /sitemap.xml
robots_txt_build()
-> /robots.txt
|
v
Public pages (home, pricing, blog…)
Pieces
1. Table seo_pages
One row per known public page in the catalog:
| Column | Type | Meaning |
|---|---|---|
id | INT (PK) | Generated by random_id_int() |
page_key | VARCHAR(60) UNIQUE | home, pricing, blog_index… |
meta_title | VARCHAR(160) | Title (60 chars optimal) |
meta_description | VARCHAR(320) | Description (160 chars optimal) |
meta_keywords | VARCHAR(500) | Comma-separated |
og_image | VARCHAR(500) | Relative path or absolute URL |
og_type | VARCHAR(40) | website / article / product / profile |
robots | VARCHAR(80) | index, follow / noindex, follow / … |
canonical_url | VARCHAR(500) | Override; empty = auto-computed |
priority | DECIMAL(2,1) | 0.0–1.0 for the sitemap |
changefreq | ENUM | always…never |
in_sitemap | TINYINT(1) | Exclude from sitemap if 0 |
updated_at | DATETIME | Auto |
Auto-seed: the first time seo_get_page($con, 'home') is called and the row doesn't exist, it inserts the catalog defaults. The admin doesn't need to create anything manually — pages already appear pre-populated.
2. Catalog seo_pages_catalog()
Lists the known pages (page key → public URL, label, default priority and changefreq). Used both by the admin (to render the editable page list) and by the sitemap (which URLs to include).
[
'home' => ['url' => '/', 'priority' => '1.0', …],
'pricing' => ['url' => '/pricing', 'priority' => '0.9', …],
'blog_index' => ['url' => '/blog', 'priority' => '0.8', …],
'pwgen' => ['url' => '/password-generator','priority' => '0.8', …],
'auth' => ['url' => '/auth', 'priority' => '0.3', …],
'legal_terms' => ['url' => '/terms-of-service', …],
'legal_privacy' => ['url' => '/privacy-policy', …],
'legal_cookies' => ['url' => '/cookies-policy', …],
]
3. Global settings
Editable from /admin/seo → Global tab:
| Setting | Default | Meaning |
|---|---|---|
seo_twitter_handle | '' | Without @. Shows up as twitter:site and twitter:creator. |
seo_default_og_image | '' | Fallback when a page has no OG image. |
seo_default_keywords | '' | Appended to each page's keywords (deduplicated). |
seo_default_description | '' | Used when a page has no meta description. Auto-translated. |
seo_publisher_name | APP_NAME | Used in JSON-LD. |
4. Per-post SEO (blog)
blog_posts has 5 columns that act as per-post overrides:
meta_titlemeta_descriptionmeta_keywordsog_imageseo_robots
When empty, blog-post.php falls back to the post's own title / excerpt / cover_image. The blog editor (/admin/blog) has a "Configure SEO" button that opens a dedicated modal with live counters and a translations sub-modal.
5. Helper seo_render($con, $config, $page_key, $overrides=[])
The single place that emits meta tags. Called by _home_head.php. The fallback ladder per field is:
$overrides[$field]— dynamic data passed by the page (typically blog post row data).seo_pages[$page_key].$field— what the admin saved in/admin/seo.- Global settings (
seo_default_og_image,seo_default_description, etc.). - Config (
APP_NAME,LOGO_DARK, favicon). - Lang file (
t('seo_<page>_*_fallback')).
Emits:
<title><meta name="description"><meta name="keywords"><link rel="canonical"><meta name="robots">- Open Graph:
og:title,og:description,og:url,og:type,og:site_name,og:image,og:image:width,og:image:height,og:image:alt,og:locale,og:locale:alternate. - Twitter Card:
twitter:card,twitter:site,twitter:creator,twitter:title,twitter:description,twitter:image,twitter:image:alt. - If
og_type='article':article:published_time,article:modified_time,article:author.
The og:locale is derived from the active LANG_CODE (es → es_ES, en → en_US, etc.) so each language announces its proper locale.
6. Dynamic sitemap
/sitemap.xml → sitemap_build($con) walks:
- Every
seo_pagesrow within_sitemap=1. - Every
blog_postsrow withstatus='published'andpublished_at <= NOW(). Posts withseo_robotscontainingnoindexare excluded.
Valid XML per sitemaps.org/schemas/sitemap/0.9 with <loc>, <lastmod> (ISO 8601), <changefreq>, <priority> and image extensions. Served with Content-Type: application/xml and Cache-Control: public, max-age=3600.
7. Dynamic robots.txt
/robots.txt → robots_txt_build($con). Allows /, disallows authenticated routes (/accounts, /credit-cards, /admin, …) and appends Sitemap: <APP_URL>/sitemap.xml. Dynamic build — the install subfolder is respected automatically.
Adding a new public page to SEO
Imagine you add /changelog and want full SEO.
1. Add to the catalog
In app/functions.php → seo_pages_catalog():
'changelog' => ['url' => '/changelog', 'label' => 'Changelog', 'priority' => '0.5', 'changefreq' => 'weekly'],
2. Wire in the page
In pags/changelog.php:
$seo_page_key = 'changelog';
$meta_title = seo_resolve_text($con, 'changelog', 'meta_title', 'Changelog · ' . $app_name);
$meta_desc = seo_resolve_text($con, 'changelog', 'meta_description', 'Release history.');
// then in <head>:
<title><?php echo htmlspecialchars($meta_title, ENT_QUOTES); ?></title>
<?php include ROOTPATH . '/pags/partials/_home_head.php'; ?>
That's it. The next load of /admin/seo shows the new editable entry, the sitemap includes it, and meta tags are emitted.
3. (Optional) Add lang keys for fallback
In lang/es.php and lang/en.php:
'seo_changelog_title_fallback' => 'Changelog',
'seo_changelog_desc_fallback' => 'Release history…',
Audit tab
/admin/seo → Audit tab runs seo_audit_run() over every catalog page and every published blog post. Each row is scored against six checks: title length, description length, OG image presence and dimensions, canonical sanity, translation coverage and robots/keywords presence. Results are summarised at the top (ok / warn / fail / average score) and listed with traffic-light pills.
JSON-LD (Schema.org)
The helper vault_jsonld_script($data) emits a <script type="application/ld+json">. Pages that already use it:
home.php:Organization,WebSite,SoftwareApplication(withoffersderived from plans),FAQPage(with real FAQs).pricing.php:Product+AggregateOffer.blog.php+legal.php:BreadcrumbList.blog-post.php:BlogPosting+BreadcrumbList.
Diagnostics
"My changes don't show in the HTML"
seo_get_page() caches per request. Reload the page after saving — on the next request the new values load.
"The sitemap doesn't include a page"
Check that in_sitemap=1 for that page key:
SELECT page_key, in_sitemap FROM seo_pages;
For a blog post, check that status='published' and published_at <= NOW() and that seo_robots doesn't contain noindex.
"OG images don't show on social"
- Verify the OG image is absolute (
https://…). If relative,seo_render()prependsAPP_URLautomatically — but only ifAPP_URLis the correct public URL. In local dev (http://localhost/vault) Facebook/Twitter can't reach it. - Recommended size: 1200 × 630. Smaller looks grainy on Twitter and LinkedIn.
- Use Twitter's card validator or Facebook's Sharing Debugger to force re-fetch (network caches last for days).
"Google shows a different description"
Google sometimes ignores your meta description and generates its own from the content. This is normal behaviour when it decides a content snippet better answers the user's query. The meta is still important for snippet CTR and for social shares.
Design decisions
Auto-seed of seo_pages: the first read creates the row if the key is in the catalog. Avoids seed-data migrations and lets you add pages without touching SQL.
Catalog in code (not in DB): the catalog is the source of truth for the SEO-editable set. Keeping it in code means a new deploy is not broken by a stale DB. Catalog URLs match the routes registered in index.php.
Dynamic vs static sitemap: generated on the fly because (1) content changes (posts, edits), (2) volumes are small (<5k URLs typical), (3) leaves the buyer free of scheduling cron jobs. The 1-hour HTTP cache absorbs any crawler burst.
noindex as a sitemap filter: a page with noindex shouldn't be in the sitemap (contradictory to send Google URLs you ask it not to index). The builder respects this automatically.