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, optionalrenderer_class, optionaldata_source.fields[]— each{ name, type, label, ... }. Allowedtypes come fromconfig/cms.phpfield_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 enginerequires the package class and mergesprepare($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:
- Resolve the definition by
component_keyviaCmsComponentRegistry(preferred) orCms_component_definition_model. Unknown/disabled (hidden/draft) keys render the admin-only placeholder (views/content/components/fallback_unknown.php) and nothing for public visitors.deprecateddefinitions still render existing blocks. - Resolve props — inline props for
mode=inline, or the global instance'sprops_jsonformode=global_reference. - Validate the block document shape (
CmsBlockValidator). - Migrate props if the block's authored
schema_versionis older than the installed one (callsmigrate_<from>_to_<to>()on the renderer class when present; in-memory only). - Sanitize via
CmsBlockSanitizer::sanitize_props($definition, $props)— coerce + escape strictly by each field's declared type (unknown field types are dropped).rich_textruns the allowlist sanitizer (sanitize_rich_text): only the configured tags/attributes survive, scripts / styles /on*handlers /javascript:/vbscript:/data:URLs are stripped, external links getrel="noopener noreferrer". - Prepare (dynamic/module only) — run the renderer class
prepare()and merge its data into$props. - Resolve media —
type=mediafields (a bare int id post-sanitize) resolve to<base>_url/<base>_alt/<base>_srcsetfor the views viaCmsMediaService. - 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(extendsApiAuthController). - Prefix
api/v1/cms; routes inconfig/api_routes.php. - Auth: bearer +
X-Company-Key, scopecms: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 +
publicvisibility for lists (a single item by exact slug may also beunlisted); never private/noindex; only approved-on-both-axes comments; no PII (display names only, never the rawcontent_jsonblock tree). A missing/non-public resource is 404cms_content_not_found(never 403 — hides existence).
5.2 End-User API — read + write (comments/reactions)
- Controller:
Content_euapi(extendsEndUserApiAuthController). - Prefix
v1/api/cms; routes inconfig/euapi_routes.php. - Auth: per-end-user HS256 JWT (
sub→ self-only viaselfUserId()); module gaterequireModule('module_content')(404module_unavailablewhen disabled — hides existence); authorization via the tenant permit model (requirePermit), NOT OAuth scopes. Reads of public content need no permit; writes requirecontent_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) →requireModule→requirePermit→ explicit CMS-ban check (403banned) → delegate to the CMS services (rate limit + AkbAI scan for comments / idempotent toggle for reactions).created_byis alwaysselfUserId()— never read a user id from the body. Comments are savedmoderation_status=pendingand scanned before going public (responsestatus: '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 aCms_*_model(which extendsCms_base_model). - New models extend
Cms_base_model(primary-DB pin), filterdeleted = 0, and end tables with the standard audit block. - All user-facing strings via
lang()— uselanguage/english/content_lang.php. - New field types must be added to
config/cms.phpfield_typesand handled inCmsBlockSanitizer— an unhandled type is dropped (fail-closed). - New EU API writes follow the recipe in
Content_euapi: method guard →requireModule→requirePermit→ ban check → delegate to a service; identity is alwaysselfUserId(). - 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_settingsflag defaulting to off, with a fallback. - After editing CSS under
resources/assets/css/, runphp erpat compile:all(the CMS public styles are component-scoped and theme-token-based — never hardcode colors).