This page is the complete reference for Phoca CMS. It starts with the simple, everyday tasks - adding a page, a menu entry, a language - and gets progressively more technical further down. If you just want to add content, you only need the Quick Start section below. If you're extending the system itself (or you're an AI assistant being asked to build a new project on top of it), read the whole page - the last section is written specifically for that.
Installation
- Unpack and copy all files to a directory on your localhost or web server.
- Adjust your settings in
config/config.php. - Optionally, change the folder name (phocacms) on your server in the .htaccess file (ajax/.htaccess):
RewriteRule ^ /phocacms/index.php
The system is now fully operational.
Quick Start
Add a page
Create a Markdown file in content/en/your-page.md:
---
title: Pricing
description: SEO description for this page.
---
Whatever you want, in Markdown.
That's the whole process. It's immediately live at /your-page, and it automatically appears in the main menu - there's no separate list of pages to update anywhere.
Add a link to the menu
You don't add menu links directly - the menu is always just the list of pages that exist. So:
- To add a menu link: add a page (see above). It appears automatically, labeled with its
title. - To use a different menu label than the page title: add
nav_label: Shopto that page's front matter. - To keep a page out of the menu (e.g. a page only linked from elsewhere): add
nav: hideto its front matter. The page still works at its URL, it just won't be listed.
Add a language
- Open
config/config.phpand add the language code toSITE_SUPPORTED_LANGS(and a display label toSITE_LANG_LABELS). - Copy
lang/en.phptolang/{code}.phpand translate the values inside it. - That's enough to turn the language on. Pages will automatically fall back to the default language's content until you translate them one by one (see next).
Translate a page
Create the same filename under the new language's content folder, e.g. content/de/pricing.md, with its own title and body. Two extra front-matter fields matter here:
slug- this language's own URL segment. Englishabout.mdmight use/about-us; Germanabout.mdcan setslug: ueber-unsto become/de/ueber-uns. Each language controls its own URL independently.- If you don't create the file at all, visitors in that language simply see the default-language version instead of a broken link - translate pages in whatever order you like, nothing breaks in the meantime.
Add site-wide text (tagline, footer blurb)
This isn't a page - it's content/{lang}/site.md, a reserved filename read by the header/footer:
---
tagline: A short line used in the header area.
footer_about: A sentence or two shown in the footer.
---
Under the hood
The rest of this page is for anyone modifying the system itself, not just adding content.
Philosophy
Phoca CMS is not a framework and not an admin-panel CMS. It's a small, flat set of PHP files you copy into a new project and shape. Three rules hold the whole thing together:
- Pages are files, not config. Demonstrated above - there is no central list of routes for static pages anywhere.
- System strings and content are different things.
lang/{lang}.phpholds reusable UI chrome (button labels, aria-labels). Everything a visitor actually reads as "the site's content" lives incontent/. - Everything is one level deep.
/system,/layouts,/templates,/lang,/ajax,/content,/configall sit directly under the project root. Nothing is nested inside a generic wrapper folder.
Directory structure
index.php front controller - the only entry point
.htaccess rewrite to index.php + security/caching
assets/ css, js, images - the only folder served directly
system/ engine classes (rarely touched once a project is running)
layouts/ reusable chrome: head, header, menu, footer, sidebars
templates/ full-page templates that assemble layouts together
ajax/ handlers for /ajax/{name}
lang/ SYSTEM/UI strings only (en.php, de.php, fr.php...)
content/ the actual pages, per language, as Markdown
config/ config.php + dynamic-routes.php
Request lifecycle
Every request (other than a static asset) goes through index.php. In order:
config/config.phpis required - defines constants (SITE_NAME,BASE_PATH,SITE_SUPPORTED_LANGS...), registers the autoloader for/system, and loadssystem/Helpers.php(the global functions every other file uses).Pages::load()scanscontent/{default_lang}/*.mdto build the page list, and loadsconfig/dynamic-routes.phpfor pattern-based (usually database-backed) routes.currentPath()computes the request path with the deployment's base path already stripped off (see "Deployment" below).Pages::detectLanguage($path)splits that into[$lang, $contentPath].- AJAX requests (
/ajax/{name}, or any request with anX-Requested-With: XMLHttpRequestheader) are handled separately and short-circuit before any page rendering happens. Pages::match($contentPath, $lang)finds either a static page or a dynamic route.Content::get($source)loads the actual content (Markdown or database).- An
SEOobject is built from config defaults + route overrides + content front matter. tpl($route['template'], [...])renders the matching file in/templates.
Front-matter reference
| Field | Effect |
|---|---|
title |
Page <h1> and default SEO title |
slug |
Custom URL segment for this language (defaults to the filename) |
nav_label |
Menu label, if different from title |
nav: hide |
Keep the page routable but out of the main menu |
nav_order |
Sort order in menu (integer, lower is first, e.g. nav_order: 2) |
template |
home or page (defaults to page, or home for home.md) |
description, schema_type, og_image |
SEO overrides, merged on top of the config defaults |
Any other front-matter field you invent is simply available to the template as $content['meta']['your_field'] - see how content/en/home.md uses hero_title, feature1_title, etc. to feed the homepage template without any of that copy living in PHP.
The Markdown body supports full GitHub Flavored Markdown (GFM) via Parsedown Extra. This includes headings (# through ######), bold, italic, [links](url), bullet and ordered lists, inline `code`, fenced code blocks with language classes, blockquotes, and tables:
```php
echo "like this";
```
Don't repeat the page title as a # Heading at the top of the body - the template already renders title as the page's own <h1>, and the parser strips a leading matching heading automatically to avoid a duplicate.
Content from the database
For content that can't reasonably be one file per page (e.g. one row per product, potentially thousands), use config/dynamic-routes.php instead:
[
'key' => 'product',
'pattern' => '#^/product/([a-z0-9\-]+)/?$#',
'template' => 'page',
'source' => ['type' => 'db', 'table' => 'products', 'param' => 0],
],
param is the index of the regex capture group used as the lookup slug. Content::fromDatabase() expects the table to have at minimum slug, title, body (or body_html), published; optionally seo_title, seo_description, og_image, schema_type, published_at, updated_at. It runs SELECT * FROM table WHERE slug = ? AND published = 1 LIMIT 1 - adjust that query directly in system/Content.php if you need per-language rows (e.g. add a lang column and a WHERE lang = ? fallback) or different lookup logic entirely.
Dynamic routes can also point at a completely custom source type. system/Content.php's get() method is a simple match() on $source['type'] - add your own case when content isn't Markdown or a simple database row at all (see the Numerly example near the end of this page).
Database access goes through system/Database.php, a thin PDO wrapper (Database::fetchOne(), fetchAll(), execute(), lastInsertId()). Credentials come from DB_HOST / DB_NAME / DB_USER / DB_PASS environment variables (see config/config.php).
Layouts and templates
A layout is a reusable chrome piece: layouts/header.php, layouts/footer.php, layouts/menu.php, layouts/sidebar-left.php, layouts/sidebar-right.php, layouts/lang-switch.php, layouts/head.php. A template is a full page that assembles layouts together: templates/home.php, templates/page.php.
Both are rendered with one function call - no indirection beyond that:
<?= layout('header', ['currentKey' => $currentKey, 'langLinks' => $langLinks]) ?>
echo tpl('home', ['seo' => $seo, 'content' => $content]);
Every layout has sane defaults and works even if called with no variables - open layouts/sidebar-right.php for the simplest example of that pattern. To add a new module (say, a newsletter signup box), create layouts/newsletter.php with its own defaults, and call layout('newsletter') from wherever it should appear.
To add a new full-page template (a different structural layout from home/page), create templates/your-template.php and set template: your-template in a page's front matter (or in a dynamic route's 'template' key).
Menu & footer internals
The main menu is built automatically by layouts/menu.php calling Pages::navItems(), which reads every discovered page's nav_label/title and nav front-matter fields. The footer (layouts/footer.php) reuses the same list for its links column, and pulls site-wide copy from content/{lang}/site.md via Content::site().
Multilingual internals
system/Pages.phphandles detecting the language from the URL and matching the remainder of the path.system/Lang.phpis the lookup for SYSTEM strings:t('theme.toggle_aria'). A missing key falls back to the default language, then to the key itself, so a missing translation is visible rather than silently blank. Useth('key')instead oft('key')when a string is allowed to contain simple formatting (<br>,<b>,<em>,<a>...) - it strips anything not on that small allow-list. Only useth()for strings you control in/lang, never for user input.- For front-matter fields that may contain inline HTML, use the
safe_html()helper instead ofhtmlspecialchars(). - The language switcher (
layouts/lang-switch.php) always links to the same page in another language viaPages::alternateUrl(), not just that language's homepage.
AJAX
Any request to /ajax/{name}, or any request carrying an X-Requested-With: XMLHttpRequest header, is routed straight to ajax/{name}.php with no layout wrapped around it - just write JSON (or any other body) directly:
<?php
if (!defined('ROUTED_THROUGH_FRONT_CONTROLLER')) { http_response_code(404); exit; }
$input = json_decode(file_get_contents('php://input'), true) ?? [];
echo json_encode(['ok' => true]);
Why that guard line matters: /ajax/* files must never be protected with a Require/Deny directive in ajax/.htaccess. Apache resolves per-directory config based on the directories the requested URL walks through - since ajax/ physically exists on disk, Apache merges ajax/.htaccess for a request to /ajax/example even though the root .htaccess's rewrite ultimately sends it to index.php instead. A "Require all denied" there would block the legitimate, rewritten request too - not just direct file access (confirmed on real Apache; this is a genuine, easy-to-hit gotcha, not a theoretical one). index.php defines ROUTED_THROUGH_FRONT_CONTROLLER immediately before requiring a handler, and every handler checks for it - this blocks direct access to the file at the PHP level instead, which works identically regardless of web server and needs no hardcoded path anywhere.
From the frontend, use the built-in helper (assets/js/script.js), which already sends the right header and respects the deployment's base path:
CMS.ajax.get('example').then(data => console.log(data));
CMS.ajax.post('example', { foo: 'bar' }).then(data => console.log(data));
SEO
system/SEO.php builds <title>, meta description, canonical URL, hreflang alternates, Open Graph, Twitter Card, and JSON-LD (schema.org) tags from one merged array: config defaults, overridden by whatever the matched route passes in, overridden by the content's own front matter. Call $seo->set('key', $value) from a template if a specific page needs to override something no front-matter field already covers.
Theme (light/dark)
Design tokens are CSS custom properties in assets/css/theme.css, split between :root, [data-theme="light"] and [data-theme="dark"]. assets/js/theme.js applies the theme as early as possible (inline in <head>, before the stylesheet even loads) to avoid a flash of the wrong color, persists the choice to localStorage, and respects prefers-color-scheme until the visitor picks explicitly.
Advertising (Google AdSense)
Set the ADSENSE_CLIENT_ID environment variable (looks like ca-pub-XXXXXXXXXXXXXXXX) to turn ads on site-wide - layouts/head.php then automatically includes Google's auto-ads script. Leave it unset and nothing related to ads is rendered anywhere, not even an empty script tag.
For a specific placement (rather than relying on auto-ads), call layout('ad-slot', ['slot' => 'your-ad-unit-id']) wherever you want it - it renders nothing at all until ADSENSE_CLIENT_ID is configured, so it's always safe to leave in a template.
Deployment
Copy the entire project folder anywhere - the domain root, or any subdirectory - with zero config changes. BASE_PATH in config/config.php is computed from where index.php actually sits on disk (ROOT_PATH) relative to the server's DOCUMENT_ROOT, not from the request URL. This is deliberate: the built-in PHP development server (php -S) rewrites $_SERVER['SCRIPT_NAME'] to match the requested URL on every request when a router script is used, which would silently break any path-based detection for a URL deeper than one segment (e.g. /ajax/example). Comparing real filesystem paths avoids that entirely, and works identically on Apache.
Every internal link, form action, and asset reference must go through url() (relative to the deployment) or asset_url() (absolute, with scheme+host - required for OG/Twitter/JSON-LD). Never hardcode a leading-slash path directly in a template or layout.
Configuration reference (config/config.php)
| Constant | Purpose |
|---|---|
APP_ENV |
'local' enables verbose PHP errors and the php -S testing convenience below; anything else behaves as production (errors suppressed) |
DB_HOST, DB_NAME, DB_USER, DB_PASS |
MariaDB connection, read from environment variables - only needed if a project uses Content::fromDatabase() or its own tables |
SITE_NAME, SITE_DEFAULT_DESCRIPTION, SITE_TWITTER_HANDLE |
SEO/branding defaults |
SITE_DEFAULT_LANG, SITE_SUPPORTED_LANGS, SITE_LANG_LABELS |
multilingual setup - see "Multilingual internals" above |
ADSENSE_CLIENT_ID |
see "Advertising" below - empty by default, renders nothing |
All of these are read via getenv('SOME_VAR') ?: 'fallback', so the real values for a live deployment belong in the server's environment (or a .env-loading mechanism you add), never hardcoded in config.php itself for anything sensitive. A project built on this skeleton typically adds its own constants the same way - see how Numerly added NUMEROLOGY_SEED right next to these.
Testing locally: run APP_ENV=local php -S localhost:8000 index.php from the project root. APP_ENV=local turns on PHP's own error display, which is off by default (production-safe) otherwise.
Extending this for a new project
The recommended workflow, in order:
- Copy the whole skeleton, rename
SITE_NAMEand related constants inconfig/config.php. - Replace the placeholder
content/en/*.mdfiles with real pages; delete what you don't need. - Add any project-specific engine classes to
/system(they autoload automatically - class name must match filename). - Add project-specific AJAX handlers to
/ajax. - Add database-backed routes to
config/dynamic-routes.phponly for content that genuinely can't be a file (thousands of rows). Don't add static pages there. - Extend
assets/css/theme.cssandstyle.cssfor the new project's visual identity - the layout/template PHP files rarely need to change for a redesign.
Worked example: Numerly
Numerly (a numerology-flavored number generator, built as this skeleton's own demo project) is a good reference for what "a real project on top of Phoca CMS" looks like:
- Added
system/NumerologyEngine.php(pure calculation, no CMS dependency),system/ResultStore.php(a thin data layer over a project-specificresultstable), andsystem/RateLimiter.php- all autoloaded automatically because they live in/system. - Added a new
Content::get()source type ('result') for a kind of content that's neither Markdown nor a simple published-row database table - a smallmatch()arm was all that was needed insystem/Content.php. - Added
ajax/generate.phpandajax/vote.phpusing the same one-file-per-handler pattern as the skeleton's example handler. - Used
config/dynamic-routes.phpfor/share/{key}and/result/{key}- pages that exist per generated result, not as files. - Left
layouts/andtemplates/page.phpalmost untouched; the homepage template (templates/home.php) was extended with the interactive tool, whilelayouts/header.php,footer.php,menu.phpkept working exactly as shipped. - Added
content/en/documentation.md(this very page) to its own copy of the skeleton withnav: hide, plus a small "Built on Phoca CMS" credit link in the footer - a pattern worth copying for any project built from this skeleton that stays public.
Notes for AI assistants
If you're an AI being asked to extend this codebase, the short version:
- Read this whole page before touching any files. The patterns above exist on purpose; follow them rather than inventing new ones.
- Content and translation work goes in
/contentand/lang- never hardcode copy in a template or layout file when a front-matter field or at()key would do. - Safe HTML and escaping:
- Always escape plain text with
htmlspecialchars(). - For system strings (in
/lang), uset('key')for plain text (remember to escape it:htmlspecialchars(t('key'))). Useth('key')if the translation contains safe HTML tags (it uses an allowlist, so you don't escape it). - If a front-matter field contains inline HTML (like
<br>or<strong>), usesafe_html($content['meta']['field'])instead ofhtmlspecialchars().
- Always escape plain text with
- Anything genuinely new about a project's logic (a calculation, a new kind of data, a new integration) belongs in a new, small, single-purpose class in
/system, wired up from/ajaxorconfig/dynamic-routes.php. - Don't rewrite the routing, templating, or language systems (
system/Pages.php,system/Template.php,system/Lang.php,system/Helpers.php) - they're meant to stay stable across every project built on this skeleton. If something genuinely doesn't fit, extend it narrowly (as Numerly did by adding one newContent::get()source type) rather than replacing it. - Static pages are never listed in code. If you're tempted to add a page to a config array, stop - create a Markdown file in
/contentinstead. - Always route new links and assets through
url()/asset_url(), never a hardcoded leading-slash path, so the project keeps working if it's ever moved to a subdirectory.