06 — Blog, taxonomy and SEO#
6.1 Posts are pages#
A post is a row in pages with type = 'post'. It uses the same editor, the same block tree, the
same version history and the same publish pipeline. There is no separate posts table and no separate
editor. What posts add:
| Field | Column | Purpose |
|---|---|---|
| Excerpt | pages.excerpt |
list summaries, meta description fallback, RSS |
| Featured image | pages.featured_media_id |
list cards, OG image fallback |
| Author | pages.author_id |
byline, author archive |
| Categories | category_page |
archives, menus, filtering |
| Primary category | pages.primary_category_id |
breadcrumbs, permalink if configured |
| Tags | page_tag |
archives, related posts |
| Publish date | pages.published_at |
ordering, scheduling |
The right panel in the editor gains a Post tab holding exactly these. Nothing about the content
editing experience changes.
6.2 Permalinks#
The pattern lives in sites.options.permalink_pattern, default blog/{slug}.
| Pattern | Produces |
|---|---|
blog/{slug} |
/blog/my-first-post (default) |
{slug} |
/my-first-post |
{category}/{slug} |
/news/my-first-post (uses primary_category_id, falls back to blog) |
blog/{year}/{month}/{slug} |
/blog/2026/10/my-first-post |
The resolved value is written to pages.path, so public lookup stays one indexed query regardless of
the pattern. Changing the pattern is an explicit action in Settings -> SEO that rewrites every post
path and writes a 301 for each one; the screen states how many URLs will change before it runs, and
it runs in chunks via cron if there are more than 200 posts.
6.3 Taxonomy#
- Categories nest (
categories.parent_id), are ordered (position), and have a name, slug, description, image and their own SEO fields. A post may have several;primary_category_iddecides breadcrumbs and{category}permalinks. - Tags are flat and created inline from the editor (type a name, press enter). Tag creation from
the editor requires the
taxonomy.managepermission; a Writer without it can only pick existing tags. - Deleting a category asks explicitly what happens to its children (
move to parent,move to another category,delete them too) and to the posts in it (posts are never deleted — they lose the assignment). Deleting a tag just detaches it. - Merging:
Merge into...on a category or tag moves assignments and writes a 301 from the old archive URL to the new one.
Archive URLs:
| Archive | URL | Notes |
|---|---|---|
| Blog index | /blog |
a real pages row of type page whose tree contains a post_list block, so the owner can design it |
| Category | /blog/category/{slug} |
nested categories use the full path: /blog/category/news/local |
| Tag | /blog/tag/{slug} |
|
| Author | /author/{user-slug} |
user slug derived from the name, stored in users as a computed unique slug |
| Search | /search?q=... |
never cached, noindex |
| RSS | /feed, /blog/category/{slug}/feed |
RSS 2.0, 20 latest items, full excerpt |
Archive pages are rendered by a designable layout: Design -> Archives holds a template part tree
for archive, so a non-technical owner can change the card design without touching Blade. The
default archive template is a single section containing one post_list block pre-filtered by the
archive context.
6.4 The post_list block#
| Setting | Values | Default |
|---|---|---|
| Source | latest, by category, by tag, by author, manual selection, related to the current post | latest |
| Categories / tags | multi-select | none |
| Layout | grid, list, carousel, headline list | grid |
| Columns | 1–4 | 3 |
| Count | 1–24 | 6 |
| Show | image, date, author, excerpt, category, read-more (toggles) | image, date, excerpt |
| Order | newest, oldest, title, random | newest |
| Pagination | none, numbers, load more | none |
| Empty state | translated message, editable | default text |
In an archive context the block inherits the archive filter automatically (Source: archive
context), which is how one block serves the index, category, tag and author pages.
Pagination uses ?page=N. A paginated URL is cached per page number and is noindex past page 1 by
default (configurable), with correct rel=prev/next.
6.5 Scheduled publishing#
- Status
scheduledwithpublished_atin the future. php artisan schedule:run(the single cron entry) runskodepress:publish-dueevery minute; it publishes every due page, clears each page from the cache, and marks the sitemap dirty.- Timezone: comparisons use the site timezone (
sites.timezone, defaultAsia/Dhaka) converted to UTC for storage. The admin UI always shows and accepts site-local time, labelled with the zone. Storage is UTC, display is site-local, and both go through the one shared date helper. - If the cron has not run for a while, the next run publishes everything overdue in one pass and the Dashboard shows a warning when the last cron tick is older than 5 minutes — a missing cron entry is the single most common shared-hosting mistake and it must be visible, not silent.
6.6 Per-page SEO#
pages.seo keys, all optional, all editable in an SEO tab in the editor:
| Key | Fallback when empty |
|---|---|
title |
page title + | + site name (template configurable) |
description |
excerpt, else the first text block, trimmed to 160 chars |
og_media_id |
featured image, else the site default OG image |
canonical |
the page URL |
noindex |
false |
nofollow |
false |
The SEO tab shows a live Google-style preview and a character counter with soft limits (60 / 160). It warns, it never blocks.
Emitted per page: <title>, description, canonical, og:*, twitter:card, hreflang
alternates for translations, and JSON-LD — WebSite + Organization on the homepage, Article on
posts (headline, image, datePublished, dateModified, author), BreadcrumbList where a hierarchy
exists.
6.7 sitemap.xml and robots.txt#
/sitemap.xmlis generated and written to disk; it is regenerated by cron when marked dirty, and at most once per minute. It includes published pages and posts, category, tag and author archives, withlastmodfromupdated_at. It excludesnoindexpages, the search page and anything behind auth.- More than 5,000 URLs switches to a sitemap index with per-type child sitemaps.
/robots.txtis generated from a template inSettings -> SEO, always containing the sitemap line. In maintenance mode it servesDisallow: /— and the Dashboard says so loudly, because shipping that to production by accident is a classic disaster.Settings -> SEOalso holds: default title template, default description, default OG image, verification meta tags, and the analytics snippets (head/body) which are injected outside the cached body so changing them does not require a cache rebuild.
6.8 Redirects#
| Source | Created when |
|---|---|
auto |
a page slug or parent changes (one row per changed path, including descendants); a category or tag slug changes; the permalink pattern changes |
manual |
the owner adds one in Settings -> Redirects |
Matching order on a 404-bound request: exact from_path match first, then regex rules in id order.
from_path is stored lower-cased without a leading slash; matching ignores the query string but
preserves it on the redirect target. Loops are refused at creation time (A -> B when B -> A
exists) and the resolver follows at most 3 hops before giving up with a 404.
The redirect screen shows hits and last-hit time, so dead rules can be pruned, and offers a CSV import/export.
6.9 Search#
Public search is MySQL-based: a LIKE-plus-relevance query over pages.title, pages.excerpt and
a plain-text projection of the published tree stored in page_versions at publish time (a
search_text column is not in the schema — the projection is stored in the file cache keyed by
page and rebuilt on publish; see ADR-0006).
Bengali matters here: MySQL full-text with the default parser does not tokenise Bengali usefully, so
the search normalises the query and the projection the same way (NFC, fold digits, strip
zero-width joiners) and matches on substrings. Results are ranked title > excerpt > body. The search
page is never cached and is noindex.
6.10 Tests for this document#
| Test | Asserts |
|---|---|
post_uses_same_editor |
creating a post and publishing stores a page_versions row exactly as a page does |
scheduled_post_publishes_on_cron |
travel past published_at, run the command, page becomes public and the cache is cleared |
slug_change_creates_301 |
renaming a page redirects the old path, including for a child page |
nested_category_archive_resolves |
/blog/category/news/local lists posts from the child category |
sitemap_excludes_noindex |
a noindex page is absent from sitemap.xml |
permalink_change_rewrites_all_posts |
pattern change updates paths and writes one redirect per post |
search_finds_bengali_title |
a Bengali title is found by a Bengali substring query |
rss_feed_is_valid |
/feed returns well-formed RSS with 20 items |
KodePress documentation · generated from the Markdown sources by
tools/build-docs-site.py · internal preview, not indexed.