Contents How-to Guide Public

ERPat CMS (Content) — Developer Guide

ERPat CMS (Content) — Developer Guide This guide covers the architecture and extension points of the CMS module: primary-DB pinning, the component package convention, the…

Guide version: r1 Module version: 1.0.0 Updated: 2026-07-22 Estimated time: 13 min 1 views

This guide covers the architecture and extension points of the CMS module: primary-DB pinning, the component package convention, the rendering pipeline, the module-owned dual API, the cron jobs, and how to extend the module safely.

Read alongside the authoritative spec (../specs/erpat-cms-module-implementation-plan.md) and the ERPat conventions (../specs/erpat-cms-ground-truth-patterns.md).


1. Module shape

The module is a self-contained HMVC package under modules/Content/, loaded at runtime by the ERPat compatibility layer (App_Router resolves bare controller class names to modules/Content/controllers/; erpat_register_module_package_paths() registers models/views/libraries/helpers/config as CI package paths every request).

modules/Content/
├── module.json                # manifest (discovered by module_compatibility_helper)
├── controllers/               # Content, Content_admin, Content_api, Content_euapi,
│                              #   Content_preview, Content_feed, Content_sitemap
├── models/                    # Cms_*_model — all extend Cms_base_model
├── libraries/                 # Cms* services (renderer, validator, sanitizer, ...)
├── components/                # component PACKAGES (component.json + view.php [+ renderer])
├── views/content/             # admin/, public/, partials/, components/ (fallback)
├── helpers/                   # content_helper, content_components_helper
├── language/english/          # content_lang.php
├── jobs/                      # cron Job classes (namespace Modules\Content\Jobs)
├── migrations/                # 23-table create + route-assignment seed
├── config/                    # routes, menu, default_menu, permissions, cms,
│                              #   module_config, api_routes, euapi_routes, OpenAPI sidecars
└── docs/                      # this guide + ADMIN_GUIDE + SECURITY

Controllers and audiences

Controller Base class Audience
Content Guest_Controller Public renderer (blog/page/post/archives, comment/reaction/view POST)
Content_admin App_Controller Staff admin hub + editor JSON AJAX
Content_preview Guest_Controller Signed-token draft preview
Content_feed Guest_Controller RSS / JSON feeds
Content_sitemap Guest_Controller sitemap.xml
Content_api ApiAuthController Read-only Client (integration) API
Content_euapi EndUserApiAuthController End-User API (read + write comments/reactions)

2. Primary-DB pin (no tenant_id)

ERPat is multi-tenant (one DB per tenant; the framework switches the active tenant connection per request). The CMS is deliberately exempt: all CMS content is global, public content that must be shared across the platform, so every cms_* table lives in the primary (default) database only and carries no tenant_id.

The enforcement mechanism is Cms_base_model:

class Cms_base_model extends Crud_model {
    function __construct($table = null) {
        $this->db = self::cms_primary_db();   // pin BEFORE parent runs
        parent::__construct($table);
    }
    protected static function cms_primary_db() {
        // $CI->load->database('default', TRUE) — cached once per request, sql_mode=''
    }
}

*Every `Cms__modelMUST extendCms_base_model, neverCrud_modeldirectly.** This guarantees a single canonical store whether the caller is a public visitor (no tenant session) or a staff user on/admin/cms(a tenant session). There is no primary_db_only` manifest flag in ERPat — the base-model pin is the contract.

Consequences for migrations: the create migration runs per-tenant via php erpat migrate:modules, but up()/down() are a no-op on tenant connections (guarded by is_tenant_loaded()), so empty cms_* tables that may appear in a tenant DB are unused. The canonical store is always the primary DB.


3. The component PACKAGE convention

A component has two layers (keep them distinct):

Layer What Where Owner Versioned by
Definition (package) The renderer + field schema + metadata that defines a component type components/{key}/component.json (+ view.php, optional renderer class, component.css) synced into cms_component_definitions Developers (code) per-component semantic version in component.json; history in cms_component_definition_versions
Instance A saved, reusable configuration (props) of a definition cms_components row (props_json) Content editors cms_component_revisions (per-instance prop history)

The filesystem component.json manifest is the source of truth; cms_component_definitions is the fast runtime lookup, kept in sync.

Package layout

components/hero/
├── component.json     # manifest: key, name, type, version, schema_version, fields[], assets, defaults, changelog[]
├── view.php           # the renderer view — receives $props (sanitized) + $context; html_escape()s scalars
├── component.css      # optional package styles
├── preview.json       # sample props for the picker preview
└── HeroComponent.php  # optional renderer class (dynamic/module components only)

Manifest essentials (see components/hero/component.json for a full example):

  • key (e.g. cms.hero), name, short_name, category, type (static | dynamic | module | locked), status (draft | active | beta | deprecated | hidden).
  • version (semantic) + schema_version (integer; bump when the field schema changes incompatibly).
  • allowed_contexts (e.g. ["page","homepage","module_index"]).
  • view, optional renderer_class, optional data_source.
  • fields[] — each { name, type, label, ... }. Allowed types come from config/cms.php field_types: text, textarea, rich_text, number, boolean, select, multi_select, url, media, color_token, repeater, module_source.
  • defaults{}, changelog[].

Static vs dynamic/module components

  • Static — props only (Hero, CTA Banner, FAQ, Rich Text, ...). No PHP class.
  • Dynamic / module — declare a renderer_class (e.g. BlogListComponent, CommunityPreviewComponent, ToolsPreviewComponent). At render time the engine requires the package class and merges prepare($props, $context)'s return into $props (prepared keys override author props). Each dynamic package documents its output key — blog_list$props['items'], community_preview$props['threads']. New dynamic packages must agree on their own key.

Add / upgrade / rollback a component

CLI (mirror the Tools-Hub tools:scan|validate|sync):

php erpat content:components:scan       # list discovered component.json packages
php erpat content:components:validate    # validate each manifest against the schema
php erpat content:components:sync        # upsert cms_component_definitions + versions

content:components:sync is idempotent: one cms_component_definitions row per key (storing the manifest blob + current version/schema_version), plus a new cms_component_definition_versions row whenever a (key, version) pair is new. Scan/validate never touch the DB (usable from console / CI).

To add a component: create components/{key}/component.json + view.php (+ renderer class if dynamic), validate, then sync. The picker/registry pick it up after a registry cache flush.

To upgrade: bump version (and schema_version if the field shape changed), add a changelog[] entry, sync. In the admin Library, Upgrade re-syncs and clears any pin. If schema_version increased, supply a prop migration routine on the renderer class named migrate_<from>_to_<to>(); the renderer calls it in memory when a block authored against an older schema_version renders (it never persists the migration). Block version pinning: a definition can be pinned (pinned_version) so the resolver renders that prior version for new inserts and global instances until re-upgraded.

To roll back: in the Library, Rollback pins the definition to an existing (key, version) history row. Rollback never deletes forward history.

The admin endpoints are Content_admin::components_sync / component_upgrade / component_rollback (all content_components_sync), each audit-logged (cms.component.synced / .upgraded / .rolledback).


4. The rendering pipeline

CmsComponentRenderer::render_content_json($json, $context) turns a content_json block document into a safe HTML string. Per component block:

  1. Resolve the definition by component_key via CmsComponentRegistry (preferred) or Cms_component_definition_model. Unknown/disabled (hidden/draft) keys render the admin-only placeholder (views/content/components/fallback_unknown.php) and nothing for public visitors. deprecated definitions still render existing blocks.
  2. Resolve props — inline props for mode=inline, or the global instance's props_json for mode=global_reference.
  3. Validate the block document shape (CmsBlockValidator).
  4. Migrate props if the block's authored schema_version is older than the installed one (calls migrate_<from>_to_<to>() on the renderer class when present; in-memory only).
  5. Sanitize via CmsBlockSanitizer::sanitize_props($definition, $props) — coerce + escape strictly by each field's declared type (unknown field types are dropped). rich_text runs the allowlist sanitizer (sanitize_rich_text): only the configured tags/attributes survive, scripts / styles / on* handlers / javascript:/vbscript:/data: URLs are stripped, external links get rel="noopener noreferrer".
  6. Prepare (dynamic/module only) — run the renderer class prepare() and merge its data into $props.
  7. Resolve mediatype=media fields (a bare int id post-sanitize) resolve to <base>_url / <base>_alt / <base>_srcset for the views via CmsMediaService.
  8. Capture the package view (components/{folder}/{view}) with ['props' => ..., 'context' => ...] and return the string.

The escaping contract

The renderer trusts sanitized props and never echoes raw input. The package view html_escape()s every scalar at output. rich_text props are the one exception — they are already allowlist-sanitized in step 5 and emitted as-is. There is no raw HTML source of truth and no $this->db->query() anywhere in the render path (all data access goes through models/services).

Rendered HTML is cached on the content row (rendered_html / rendered_hash); the public controllers re-render through the same path so preview, live and route-assigned renders are byte-identical chrome.


5. The dual API (module-owned)

The module ships two API surfaces, both auto-discovered by core. The controllers are bare class names (no subdirectory) so App_Router resolves them into the module; the route fragments are required by the host API config (after the core group, before Compiler::compile()), so they register into the same shared RouteRegistrar and OpenAPI spec.

5.1 Integration (Client) API — read-only

  • Controller: Content_api (extends ApiAuthController).
  • Prefix api/v1/cms; routes in config/api_routes.php.
  • Auth: bearer + X-Company-Key, scope cms:read (authorizeScope('cms:read') on every method).
  • Endpoints: GET cms/pages, cms/pages/{slug}, cms/posts, cms/posts/{slug}, cms/categories, cms/tags, cms/content/{id}/comments, cms/media/{id}.
  • Public projection only: published status + public visibility for lists (a single item by exact slug may also be unlisted); never private/noindex; only approved-on-both-axes comments; no PII (display names only, never the raw content_json block tree). A missing/non-public resource is 404 cms_content_not_found (never 403 — hides existence).

5.2 End-User API — read + write (comments/reactions)

  • Controller: Content_euapi (extends EndUserApiAuthController).
  • Prefix v1/api/cms; routes in config/euapi_routes.php.
  • Auth: per-end-user HS256 JWT (sub → self-only via selfUserId()); module gate requireModule('module_content') (404 module_unavailable when disabled — hides existence); authorization via the tenant permit model (requirePermit), NOT OAuth scopes. Reads of public content need no permit; writes require content_eu_comment / content_eu_react.
  • Reads: GET posts, posts/{slug}, pages/{slug}, content/{id}/comments (approved + the caller's own pending), recommendations (tag-overlap, seeded by the user's own comments/reactions; nothing stored).
  • Writes (the only EU write precedent in the codebase): POST content/{id}/comment, POST content/{id}/reaction, DELETE content/{id}/reaction. Each: method guard (405) → requireModulerequirePermit → explicit CMS-ban check (403 banned) → delegate to the CMS services (rate limit + AkbAI scan for comments / idempotent toggle for reactions). created_by is always selfUserId() — never read a user id from the body. Comments are saved moderation_status=pending and scanned before going public (response status: 'pending', HTTP 202).
  • EU writes are stateless bearer auth — session CSRF does NOT apply (unlike the web POST endpoints in Content, which require the native CI CSRF token).

5.3 OpenAPI generation

OpenAPI specs are generated from sidecar config:

php erpat api:openapi --postman     # -> public/api/v1/openapi.json   (Client API)
php erpat euapi:openapi --postman   # -> public/v1/api/openapi.json   (End-User API)

Sidecars: config/api_query_filters.php, config/api_response_examples.json (Client); config/euapi_response_schemas.php (End-User). Response shaping is centralized in helpers/content_helper.php (cms_api_shape_content, cms_api_shape_comment, cms_api_shape_category, cms_api_shape_tag, cms_api_shape_media, cms_api_is_public_content).


6. Cron jobs

Job classes live in modules/Content/jobs/ (namespace Modules\Content\Jobs, implementing JobContract), discovered by JobRegistry. All are primary-DB-pinned and run in the global scope (runsForGlobalScope() → true) — they operate on the shared primary DB, not per-tenant.

Slug Class Schedule Default Purpose
content_publish_scheduled PublishScheduledContentJob */5 * * * * on Promote scheduled content whose time has arrived; snapshot + audit
content_scan_comments ScanCommentJob */2 * * * * on AkbAI-scan held comments (when inline scan is off / AkbAI was down)
content_expire_bans ExpireBansJob 0 * * * * on Lift lapsed temporary bans
content_aggregate_analytics_daily AggregateAnalyticsDailyJob 15 0 * * * on Roll up events into cms_analytics_daily
content_regenerate_sitemap RegenerateSitemapJob 0 4 * * * off Regenerate the sitemap
content_run_lighthouse_queue RunLighthouseQueueJob */10 * * * * off Process queued Lighthouse/PageSpeed tests

Manage via php erpat cron:list | cron:run <slug> | cron:dry-run <slug>.


7. Services (libraries) reference

Loaded via the module package path with documented aliases, e.g. $this->load->library('CmsComponentRenderer', null, 'cms_renderer');.

Library Alias Role
CmsComponentRegistry cms_component_registry Definition catalog + version resolution + picker list (cached per request; flush_cache())
CmsComponentRenderer cms_renderer The render pipeline (Section 4)
CmsBlockValidator Validate block-document shape
CmsBlockSanitizer cms_block_sanitizer Coerce + escape props by field type; sanitize_rich_text allowlist
CmsMediaService cms_media_service Upload validation, variants, media resolution, delete
CmsRevisionService cms_revision Timeline, diff, forward-only rollback
CmsPreviewTokenService cms_preview_token Mint/verify signed, expiring preview tokens
CmsSeoService / CmsSeoAnalyzer cms_seo <head> SEO + breadcrumbs + structured data; scoring/checklist
CmsSitemapService / CmsFeedService sitemap.xml + RSS/JSON feeds
CmsRedirectService cms_redirect Resolve redirects (consulted on 404)
CmsCommentService cms_comment Submit (ban gate + rate limit + sanitize + held insert) + scan
CmsAkbaiModerationService cms_moderation Prompt-injection-safe classifier + optional reply
CmsReactionService cms_reaction Idempotent reaction toggle + denormalized counts
CmsBanService cms_ban Guest visitor hashing, ban checks, apply/lift, escalation
CmsRouteResolver cms_route_resolver Safe, fail-safe homepage/module override gate + render
CmsAnalyticsService cms_analytics Throttled view queueing + daily rollup
CmsLighthouseService PageSpeed/Lighthouse queue + result storage
CmsRecommendationService cms_recommendation Tag-overlap recommendations (EU API)
CmsAuditService cms_audit Wrapper over set_system_logs() for cms.* events

8. Route override resolver (integration point)

CmsRouteResolver is the single decision point for replacing / or a module landing with a CMS page. The integration is a tiny gated check at the top of a system controller's index():

if (load_module_library('content', 'CmsRouteResolver', null, 'cms_route_resolver')
    && $this->cms_route_resolver->can_override('site.home')) {
    $this->cms_route_resolver->render_assignment('site.home');
    return;
}
// ... existing system default render

can_override() returns true only when, re-checked every request: the master flag is on, the route-specific flag is on, an active assignment row exists with source_type='cms_page', and source_id points to a page that is published right now. Any miss → false → system default renders. No web-server or router hijacking; nothing is cached. See SECURITY.md §8 for the full gate.


9. Extending safely — rules

  • Never write raw HTML as a content source of truth. New blocks are components with a field schema + a view that html_escape()s output.
  • Never add $this->db->query() / Query Builder in controllers, libraries, helpers, or views — add a method on a Cms_*_model (which extends Cms_base_model).
  • New models extend Cms_base_model (primary-DB pin), filter deleted = 0, and end tables with the standard audit block.
  • All user-facing strings via lang() — use language/english/content_lang.php.
  • New field types must be added to config/cms.php field_types and handled in CmsBlockSanitizer — an unhandled type is dropped (fail-closed).
  • New EU API writes follow the recipe in Content_euapi: method guard → requireModulerequirePermit → ban check → delegate to a service; identity is always selfUserId().
  • Audit every sensitive action through CmsAuditService (publish, route assignment, component sync/upgrade/rollback, moderation, bans).
  • Keep the safe-by-default posture: new public-affecting capabilities ship behind a cms_settings flag defaulting to off, with a fallback.
  • After editing CSS under resources/assets/css/, run php erpat compile:all (the CMS public styles are component-scoped and theme-token-based — never hardcode colors).
Was this guide helpful?

Report a content problem