Docs · SEO

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:

ColumnTypeMeaning
idINT (PK)Generated by random_id_int()
page_keyVARCHAR(60) UNIQUEhome, pricing, blog_index
meta_titleVARCHAR(160)Title (60 chars optimal)
meta_descriptionVARCHAR(320)Description (160 chars optimal)
meta_keywordsVARCHAR(500)Comma-separated
og_imageVARCHAR(500)Relative path or absolute URL
og_typeVARCHAR(40)website / article / product / profile
robotsVARCHAR(80)index, follow / noindex, follow / …
canonical_urlVARCHAR(500)Override; empty = auto-computed
priorityDECIMAL(2,1)0.0–1.0 for the sitemap
changefreqENUMalwaysnever
in_sitemapTINYINT(1)Exclude from sitemap if 0
updated_atDATETIMEAuto

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:

SettingDefaultMeaning
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_nameAPP_NAMEUsed in JSON-LD.

4. Per-post SEO (blog)

blog_posts has 5 columns that act as per-post overrides:

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:

  1. $overrides[$field] — dynamic data passed by the page (typically blog post row data).
  2. seo_pages[$page_key].$field — what the admin saved in /admin/seo.
  3. Global settings (seo_default_og_image, seo_default_description, etc.).
  4. Config (APP_NAME, LOGO_DARK, favicon).
  5. Lang file (t('seo_<page>_*_fallback')).

Emits:

The og:locale is derived from the active LANG_CODE (eses_ES, enen_US, etc.) so each language announces its proper locale.

6. Dynamic sitemap

/sitemap.xmlsitemap_build($con) walks:

  1. Every seo_pages row with in_sitemap=1.
  2. Every blog_posts row with status='published' and published_at <= NOW(). Posts with seo_robots containing noindex are 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.txtrobots_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.phpseo_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:

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"

"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.

FortPass · © 2026 Medel Platforms · medel.es