A lightweight, database-free CMS engine for PHP websites. Content is stored as plain Markdown files on disk, and editing happens from the live site with a draft-first workflow: a logged-in editor browses the pages exactly as visitors see them, clicks an editable fragment, saves a draft, previews it, then publishes when ready. Anonymous visitors are served plain rendered HTML with zero editing overhead.
Pagecore is designed for small, single-editor sites (the bundled configuration targets a Polish-language site) where a full CMS such as WordPress would be overkill: no database, no admin dashboard, no build step — just PHP, Markdown files, and a folder of uploads.
| Perspective | What Pagecore gives you |
|---|---|
| Site visitor | A normal, fast PHP website. The CMS is invisible: no scripts, no styles, no cookies related to editing are delivered to anonymous users. |
| Content editor | In-place editing of any marked fragment of the site, draft previews, one-click backup restore, plus simple management of dated posts (news, rulings, events…) — all from the browser, after logging in at a private URL. |
| Developer / integrator | A drop-in cms/ directory and a handful of template functions (cms_editable(), cms_posts(), cms_post(), cms_assets()) that add editing to any existing PHP site without touching its markup structure. |
- Log in at
/cms/login.php(the URL is deliberately not linked anywhere on the site). A toolbar appears confirming you are logged in, with a logout link. - Browse the site normally. Every editable fragment is outlined. Click its content to edit the rendered text directly, then choose Save to publish immediately or Cancel to restore the text from before editing. Inline mode accepts text only. Hovering still reveals the existing Edit button, and empty fragments show a placeholder so they can still be found and filled in.
- Use Edit for the full panel that opens over the page:
- Content is written in Markdown, including tables.
- A server-side preview shows exactly how the fragment will render.
- Save draft stores work under
content/.drafts/without changing what visitors see. Preview draft opens a standalone draft preview link. Publish copies the current editor state to the live Markdown file. - Images and PDFs can be pasted or dragged straight into the editor — they are uploaded automatically and the correct Markdown snippet is inserted. PDFs render inline on the page when the browser is set to display PDFs, with a download fallback link.
- The Media link opens
/cms/media.php, a searchable library of existing uploads. Editors can reuse an asset in the current editor, update alt text and captions stored in sidecar metadata files, preview the original file, and delete files that are not referenced by content. - The Content link opens
/cms/content.php, an inventory of configured pages, editable regions, posts, categories, missing Markdown files, and the editable navigation JSON. Ctrl+Ssaves a draft,Esccancels (with a confirmation if there are unsaved changes).
- Manage posts on eligible listing pages: an + Add post button creates
a new post in that category. Each post has a title, date, category and optional excerpt
(editable as post metadata), a featured-image drop area (JPEG/PNG only,
using the configured upload limit, uploaded and saved to the draft automatically), plus a
Markdown body edited the same way as any other fragment. Post URLs are generated automatically from the title
(with Polish-character transliteration, e.g. "Uchwała nr 5" →
uchwala-nr-5).
- Drafts — editor work is saved under
content/.drafts/and remains invisible to anonymous visitors until it is published. - Backups — before a fragment or post is overwritten, the previous version
is copied to
content/.backups/(the newest 20 versions are kept per fragment). Logged-in editors can restore a backup directly from the editor. - Search index —
search-index.jsonis regenerated from configured pages and all posts, powering the site's search page. - Sitemap —
sitemap.xmlis regenerated with all pages, posts and category listings. - Post excerpts — if no excerpt is provided, one is derived automatically from the first ~28 words of the body.
All content lives under content/ as Markdown files — the engine never
modifies PHP templates:
content/
├── pages/<page>/<region>.md # editable page fragments
├── posts/<slug>.md # dated posts with front matter
├── nav.json # optional editable navigation tree
├── .drafts/ # unpublished editor drafts
└── .backups/ # automatic per-save version history
uploads/
├── YYYY/MM/<name>-<random>.ext # editor-uploaded images and PDFs
└── YYYY/MM/<name>-<random>.ext.meta.json # optional alt text and caption sidecar files
Posts carry simple front matter:
---
title: Uchwała nr 5/2026
date: 2026-06-15
category: uchwaly
excerpt: Optional hand-written summary.
---
Body of the post in Markdown…Because everything is plain files, the whole site can be backed up, versioned
in git, migrated, or edited over FTP/SSH with any text editor. Deleting a post
is simply deleting its .md file.
Markdown is rendered server-side with Parsedown, with a few site-friendly extras:
- Raw HTML is always escaped and unsafe Markdown URL schemes are neutralized. The WordPress importer converts its maintained safe-tag allowlist to Markdown, turns supported embeds into ordinary links, and discards active markup.
pdf:/uploads/path/file.pdf "Label"on its own line becomes an inline PDF viewer when the browser is set to display PDFs, with a labelled download fallback.- Standalone images are wrapped in
<figure>for styling. - Tables get a
cms-tableclass hook. - Dates display in long form (15 June 2026). Set
date_monthsto twelve localized month names for a non-English site — in Polish, the genitive forms render 15 czerwca 2026.
Logged-in editors can open /cms/media.php directly or use Media library
inside an active edit panel. The library lists files under the configured
uploads_dir, searches by path, alt text and caption, and shows image
thumbnails or a PDF tile. In picker mode it inserts the same Markdown snippet
used by uploads, so existing assets can be reused without re-uploading.
Alt text and captions are stored beside the asset as
<filename>.meta.json. Deleting is intentionally conservative: the CMS scans
published pages, posts and drafts for the asset URL and refuses to delete a
file that is still referenced.
Logged-in editors can open /cms/content.php directly or use Content in
the editor toolbar. The screen shows:
- the installed Pagecore version;
- configured
search_pages, including whether each linked Markdown region exists; - editable regions found from Markdown files, configured page regions, and
cms_editable()calls scanned from PHP templates; - missing Markdown region files, with a Create file action for safe template-backed placeholders;
- all posts and configured post categories with counts;
- editable navigation JSON stored at
content/nav.jsonby default.
Navigation items use a small JSON tree:
[
{ "label": "Home", "url": "/", "children": [] },
{ "label": "News", "url": "/news/", "children": [] }
]Templates can render that file with cms_nav_items() or cms_nav_html().
If nav.json does not exist or is invalid, Pagecore falls back to the
configured search_pages.
Add require 'cms/engine.php'; to the site bootstrap, then:
| Function | Purpose |
|---|---|
cms_editable('page/region') |
Render a fragment; wraps it in an editable element only for logged-in editors. |
cms_posts('category') |
List posts (newest first) for a listing page — title, date, excerpt, URL. |
cms_post($slug) |
Fetch one post with rendered body for a post template. |
cms_post_url($slug) |
Build a post URL from post_url; defensively restores a missing {slug} segment. |
cms_post_social_meta($post) |
Emit escaped description, canonical, and Open Graph tags using the post excerpt/body summary and featured image. |
cms_listing_controls('category') |
"Add post" button on listing pages (editors only). |
cms_nav_items() / cms_nav_html() |
Read or render the editable navigation tree from content/nav.json. |
cms_assets() |
Emit editor CSS/JS before </body> (empty for visitors). |
Site-specific settings (credentials, categories, searchable pages, site URL,
upload limits) live in a private configuration file outside the document root,
selected with PAGECORE_CONFIG or CMS_CONFIG_FILE. See
cms/README.md for the full install and operations guide.
In the standard public/cms plus sibling pagecore-private layout, direct CMS
entry points also discover pagecore-private/config.php when neither explicit
configuration source is set; set PAGECORE_CONFIG whenever the private path
differs from that layout.
Pagecore is meant to be added around an existing PHP site, not to replace the site's templates. Keep your current routes, layout, CSS, JavaScript and server-side PHP logic. Convert only the content that editors should control.
Put only the public site and cms/ engine under the document root. Place the
configuration, source Markdown, drafts, backups, and uploads in a sibling
private directory:
site/
├── public/
│ ├── index.php
│ ├── post.php
│ └── cms/
└── pagecore-private/
├── config.php
├── content/
├── backups/
└── uploads/
Copy deployment/pagecore-config.php.example
to the private directory, configure it, and set PAGECORE_CONFIG to its
absolute path in the PHP process environment. If the site has a shared
bootstrap or layout include, load the engine there:
<?php require __DIR__ . '/cms/engine.php'; ?>If there is no shared include, add the same require near the top of every PHP
page that will render editable content or listings.
Then emit editor assets once before </body> in the shared footer:
<?= cms_assets() ?>cms_assets() returns an empty string for visitors, so anonymous traffic keeps
receiving the normal site without editor CSS or JavaScript.
Find pieces of hard-coded HTML that should be editable: hero text, body copy,
contact information, callouts, FAQ answers, table content and similar regions.
Replace each region with cms_editable().
Before:
<section class="hero">
<h1>About our company</h1>
<p>Long hand-written text...</p>
</section>After:
<section class="hero">
<?= cms_editable('about/hero') ?>
</section>Create the matching Markdown file:
content/pages/about/hero.md
With content such as:
# About our company
Long hand-written text...Use stable, lowercase keys made from letters, numbers and hyphens:
<?= cms_editable('home/intro') ?>
<?= cms_editable('services/pricing-table') ?>
<?= cms_editable('contact/opening-hours') ?>Keys map directly to Markdown files under content/pages/. The engine accepts
up to three path segments, for example services/websites/intro maps to
content/pages/services/websites/intro.md.
Pagecore renders Markdown into HTML. Put design-critical classes and layout containers in the PHP template, then let the editor manage the content inside.
Good:
<section class="section section--narrow">
<div class="prose">
<?= cms_editable('privacy/body') ?>
</div>
</section>Avoid moving required layout wrappers, JavaScript hooks, forms or PHP business logic into Markdown. Pagecore content is best used for editorial HTML generated from Markdown, not for application code.
If you need the editable wrapper to be a specific element, pass the tag name:
<?= cms_editable('home/sidebar-note', 'aside') ?>Visitors still receive only the rendered Markdown. Logged-in editors receive the same content wrapped with editor attributes.
Use Pagecore posts when editors need to add dated items such as news, events, articles, rulings or announcements.
First configure categories in cms/config.php. Each category entry is used by
the editor UI, listing URLs and sitemap generation:
'categories' => array(
'news' => array('News', '/news/'),
'events' => array('Events', '/events/'),
),
'post_url' => '/post/{slug}/',On the listing page, replace hard-coded repeated items with cms_posts():
<?php require __DIR__ . '/cms/engine.php'; ?>
<main>
<h1>News</h1>
<?= cms_listing_controls('news') ?>
<div class="news-list">
<?php foreach (cms_posts('news') as $post): ?>
<article class="news-card">
<time datetime="<?= htmlspecialchars($post['date']) ?>">
<?= htmlspecialchars($post['date_display']) ?>
</time>
<h2>
<a href="<?= htmlspecialchars($post['url']) ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</h2>
<p><?= htmlspecialchars($post['excerpt']) ?></p>
</article>
<?php endforeach; ?>
</div>
</main>
<?= cms_assets() ?>cms_listing_controls('news') renders the "Add post" button only for logged-in
editors. It is invisible to visitors.
Create or adapt a detail route such as post.php. Fetch the slug from your
router or query string, load the post with cms_post(), and render its
metadata plus editable body.
<?php
require __DIR__ . '/cms/engine.php';
$slug = isset($_GET['slug']) ? $_GET['slug'] : '';
$post = cms_post($slug, cms_is_logged_in());
if (!$post) {
http_response_code(404);
echo 'Post not found';
exit;
}
?>
<main>
<article>
<p class="eyebrow">
<?= htmlspecialchars($post['category_label']) ?>
· <?= htmlspecialchars($post['date_display']) ?>
</p>
<h1><?= htmlspecialchars($post['title']) ?></h1>
<div class="prose">
<?php if (cms_is_logged_in()): ?>
<div class="cms-editable" data-cms-key="post:<?= htmlspecialchars($post['slug'], ENT_QUOTES, 'UTF-8') ?>">
<?= $post['body_html'] ?>
</div>
<?php else: ?>
<?= $post['body_html'] ?>
<?php endif; ?>
</div>
</article>
</main>
<?= cms_assets() ?>The special key post:<slug> edits the Markdown body inside
content/posts/<slug>.md. Post detail templates render body_html for
visitors, and add the cms-editable wrapper only for logged-in editors. Passing
cms_is_logged_in() as the second argument also gives an authenticated editor
an intentional review route for imported non-public posts. Anonymous requests
receive no post for any front-matter status other than publish. The
editor panel also exposes post metadata: title, date, category and optional
excerpt.
If your server supports pretty URLs, route /post/my-title/ to
post.php?slug=my-title. Otherwise set post_url to a query-string pattern,
for example /post.php?slug={slug}.
Keep the literal {slug} placeholder in the configured pattern. Pagecore
recomputes cached listing URLs from each post slug and defensively appends the
placeholder if a migrated configuration accidentally omitted it.
Each existing list item becomes one file in content/posts/:
---
title: Existing announcement
date: 2026-06-15
category: news
excerpt: Optional summary shown on listing pages.
status: publish
---
Full post body in Markdown.The filename is the slug used in URLs:
content/posts/existing-announcement.md
The listing page is sorted newest first by date. If excerpt is omitted,
Pagecore derives one from the post body.
Missing status is treated as publish for existing content. private,
draft, and other values are excluded from listings, tags, search indexes,
sitemaps, and anonymous detail routes. The WordPress importer defaults to
--status=publish; requesting additional states requires the explicit
--include-non-public=1 acknowledgement. Non-public imported posts remain in
content/posts/ for authenticated review, while non-public imported pages are
staged under content/.drafts/imported-pages/ and never added to public search
configuration or navigation.
For pages that should appear in search-index.json and sitemap.xml, add
entries to search_pages in cms/config.php:
'search_pages' => array(
'/' => array('Home', 'Page', 'home/intro'),
'/about/' => array('About', 'Page', 'about/hero'),
'/news/' => array('News', 'Listing', null),
),The third value is an optional editable fragment key used to generate a search
excerpt for that page. Posts and category listing URLs are added automatically
from content/posts/ and categories.
Configured pages expose Page title and Featured image in their editor.
Those values are stored as front matter in the configured region's Markdown.
Changing a page title creates a Polish-aware URL slug beneath base_url and
updates the content inventory, generated search index, sitemap, and default
navigation. A front controller can map that new URL back to its existing
template with cms_page_source_url($path); the sample site's router shows the
small integration point.
For the mechanics of getting these right on a control-panel host — directory layout, FTP, permissions, PHP version and diagnosing a 500 — see Deploying to shared hosting.
- The domain runs PHP 8.3+; the engine refuses to boot on anything older.
- The private configuration exists outside
DOCUMENT_ROOTand has the production password hash,site_url,site_root,content_dir,uploads_dir, categories and search pages. - The PHP worker can write to the private
content/,content/.drafts/, backups, and uploads,search-index.jsonandsitemap.xml. - Direct HTTP access to
cms/engine.php,cms/auth.php, andcms/lib/is denied; private storage has no HTTP route. - Media is delivered only by
/cms/media-file.php; the private upload directory is not executable or HTTP-addressable. - The private
login_rate_limit_diris shared by all PHP workers so account and source attempt budgets survive cookie rotation. PAGECORE_DEVELOPMENTis absent in production; the engine rejects any configuration, content, backup, or upload path belowDOCUMENT_ROOT.- Post URL rewrites match the configured
post_url. - Every page that calls
cms_editable(),cms_posts(),cms_post()orcms_listing_controls()has loadedcms/engine.php. <?= cms_assets() ?>appears once before</body>on pages where editors should edit content.
Keep structure in PHP. Move words, tables, images, PDFs and post bodies into Markdown. This keeps the existing site design intact while giving editors the Pagecore in-place editing workflow.
This section covers a typical control-panel host (DirectAdmin, cPanel and similar) reached over FTP, with no shell access. The failure modes below are the ones that actually occur; each is cheap to avoid and expensive to diagnose after the fact.
Nothing here can be verified by the bundled development server. php -S
ignores .htaccess entirely and runs with PAGECORE_DEVELOPMENT=1, so a site
that works perfectly in development can still fail every request in
production. Treat the checks in this section as a separate lane.
Run the layout validator against a built deployment before uploading it. It
boots the engine with no PAGECORE_DEVELOPMENT, exactly as a host would, and
catches most of what follows without a round trip to the server:
scripts\Test-ProductionLayout.ps1 -PublicRoot .\deploy\public_html -ConfigFile .\deploy\pagecore-private\config.phpPagecore fails closed in production if the configuration, content,
backups, uploads or rate-limit directory resolve anywhere below
DOCUMENT_ROOT:
Pagecore private storage must be outside DOCUMENT_ROOT: content_dir, uploads_dir
This is not advisory. A single-folder layout that mixes templates and content cannot be deployed, whatever else is configured. Split the site in two, with the private directory a sibling of the document root:
/home/<user>/domains/<domain>/
├── public_html/ <- DOCUMENT_ROOT: templates, assets/, cms/, .htaccess
├── private_html/ <- usually a symlink to public_html (see below)
├── logs/
└── pagecore-private/ <- outside the document root
├── config.php
├── content/
├── uploads/
└── state/
The domain folder is therefore the smallest single directory that can hold a complete deployment — useful when building an upload artifact, because it maps one local folder onto one remote folder.
cms/config.php must not ship to the public root. When no explicit config
source is configured, direct CMS entry points discover the sibling
pagecore-private/config.php in this standard layout; deployments with a
different private path must set PAGECORE_CONFIG.
Note that content/.backups is created by the engine on first publish rather
than shipped, which is one reason content/ itself has to be writable.
Panels that predate universal SNI serve http:// from public_html and
https:// from private_html. Two real directories mean maintaining two
copies of every template. Prefer the panel's "use a symbolic link from
private_html to public_html" option, then upload once. pagecore-private is
a sibling of both, so the choice does not affect it, and the engine boots
correctly with either as DOCUMENT_ROOT.
- Enable hidden files in your FTP client. Many clients skip dot-files
silently. A missing
.htaccessproduces a working homepage with 404s on every other route, and the private configuration is never found at all. - Merge, do not mirror. A mirroring client pointed at the domain folder
can delete
logs/and other panel-managed siblings. - Clear the previous application out of the document root first. Merging
leaves the old
index.php,.htaccessand framework directories in place, where they compete with the new rewrite rules. - Upload
pagecore-privatein the same pass, as a sibling — not inside the document root.
Under PHP-FPM or suPHP the PHP worker runs as the domain user, so owner-permissions are what matter:
| Path | Mode | Why |
|---|---|---|
pagecore-private/config.php |
600 |
Contains the bcrypt password hash; 644 is world-readable on a shared server |
pagecore-private/content/ |
755 |
Engine writes drafts, backups, posts-index.json |
pagecore-private/uploads/ |
755 |
Editor uploads land here |
pagecore-private/state/ |
755 |
Rate-limit counters and the audit log |
Files inside content/ |
644 |
Read and rewritten by the engine |
755 is what a control panel creates by default and what these directories
should keep. They sit outside the document root, so the web server has no
route to them regardless of mode, and the owner bits are the ones the PHP
worker uses. Tightening them further tends to cause more trouble than it
prevents: some panels run backup, quota and file-manager tasks under a
different account, which loses access at 700.
Never use 777. Beyond the obvious exposure, suPHP and some PHP-FPM
configurations refuse to execute anything under a group- or world-writable
directory and return 500.
Permissions alone do not prove writability — that depends on which user PHP runs as. Confirm it with the diagnostics file below rather than assuming.
Pagecore requires PHP 8.3 or newer and refuses to boot below it:
Pagecore requires PHP 8.3.0 or newer; running 7.4.33
Hosts frequently default a domain to an old branch, and a domain migrated from a legacy application often keeps whatever version that application needed. Set the version per domain before uploading.
Two things regularly obscure this:
- The panel's global PHP settings page may be restricted to the account's
default domain, reporting that PHP settings cannot be controlled for this
domain. That page governs
php.inivalues, not the version. - The version selector usually lives on the domain's own settings screen (Domains → the domain → select PHP version), which remains available even when the global page is locked.
Choose a branch still receiving security fixes, comfortably above the 8.3 minimum.
The configuration lives outside the document root, so the engine has to be
told where it is. Add one line to the document root's .htaccess:
SetEnv PAGECORE_CONFIG /home/<user>/domains/<domain>/pagecore-private/config.phpSetEnv reaches getenv() under mod_php and CGI, but only $_SERVER under
PHP-FPM; the engine reads both, so this works across SAPIs. If mod_env is
unavailable, set the variable in the panel's PHP-FPM configuration for the
domain instead (env[PAGECORE_CONFIG] = …).
PAGECORE_DEVELOPMENT is deliberately read from getenv() only. It is a
security switch, and $_SERVER holds a start-up snapshot that putenv()
cannot clear, so honouring it there would let a stale value pin a production
site in development mode.
PAGECORE_DISPLAY_ERRORS is the diagnostic counterpart: it turns on
display_errors before configuration is parsed and puts the validator's
rejected keys on the page, without skipping production checks. The engine
reads it from getenv() and from $_SERVER, so SetEnv PAGECORE_DISPLAY_ERRORS 1
works under PHP-FPM. Remove it once the fault is found.
Derive the two postures from that one variable so the engine and the configuration can never disagree:
$production = getenv('PAGECORE_DEVELOPMENT') !== '1';A configuration that invents its own flag — PAGECORE_PRODUCTION or similar —
will report development values on a host where nothing is set, while the
engine treats the same request as production. The result is a 500 on every
request with production cannot use development or demo credentials.
Because uploads/ is private, map the public URL onto the engine's media
endpoint. Content then keeps ordinary /uploads/... URLs and needs no
rewriting:
RewriteRule ^uploads/(.+)$ cms/media-file.php?path=$1 [L,QSA]With require_https enabled the engine answers plain HTTP with a bare
400 HTTPS is required. Redirect before PHP is reached:
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]The X-Forwarded-Proto condition prevents a redirect loop where TLS
terminates at a proxy. If PHP still fails to see HTTPS in that setup, add the
proxy to trusted_proxies; leave it empty otherwise, since trusting forwarded
headers unconditionally lets a client claim any scheme or address.
Two constructs fail hard and take the whole site with them, because Apache rejects the entire file and returns 500 for every request — including static assets:
<Directory>sections are not valid in.htaccess. They belong in the server configuration. Per-directory rules go in that directory's own.htaccess.php_flag/php_valueonly exist under mod_php. On PHP-FPM, LiteSpeed or CGI they are unknown directives. Guard them, and rely on a<FilesMatch>denial as the enforcing rule:
<FilesMatch "\.(php|phtml|phar|cgi|pl)$">
Require all denied
</FilesMatch>
<IfModule mod_php.c>
php_flag engine off
</IfModule>First, find out whether PHP is involved at all. Request a static asset and a PHP entry point.
POSIX shell:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/assets/style.csscurl -s -o /dev/null -w '%{http_code}\n' https://example.com/index.phpPowerShell:
'/assets/style.css', '/index.php' | ForEach-Object {
$r = Invoke-WebRequest "https://example.com$_" -SkipHttpErrorCheck -MaximumRedirection 0
'{0,-20} {1}' -f $_, $r.StatusCode
}-SkipHttpErrorCheck requires PowerShell 7+; without it a 500 raises a
terminating error instead of reporting the status. Note also that Windows
curl.exe has no /dev/null — use NUL if you prefer the curl form.
- Static 500 → Apache is rejecting
.htaccess. Look for the constructs above; renaming.htaccessaside confirms it in one request. - Static 200, PHP 500 → the failure is inside PHP. Continue below.
Do not expect the domain error log to help. An uncaught PHP exception
under PHP-FPM is not an Apache error, so a panel's per-domain
<domain>.error.log can sit at 0 bytes through a completely broken site while
the access log fills normally. An empty error log is not a broken log; it is
the wrong log.
Set PAGECORE_DISPLAY_ERRORS first. A SetEnv PAGECORE_DISPLAY_ERRORS 1
line in the document-root .htaccess (or the PHP-FPM env[] equivalent)
turns on display_errors before configuration is parsed and names the
settings the validator rejected. PAGECORE_DEVELOPMENT=1 implies it, but
that skip of production checks is usually the wrong tool for a blank 500.
Remove the flag once the fault is found — the output carries absolute
paths, stack traces, and configuration key names.
If the flag cannot be set, create a diagnostics file in the document root — this reports in one request everything the engine needs and prints the real exception:
<?php
ini_set('display_errors', '1');
error_reporting(E_ALL);
header('Content-Type: text/plain; charset=utf-8');
echo 'php=', PHP_VERSION, ' sapi=', PHP_SAPI, "\n";
echo 'docroot=', $_SERVER['DOCUMENT_ROOT'], "\n";
echo 'getenv=', var_export(getenv('PAGECORE_CONFIG'), true), "\n";
echo 'server=', var_export($_SERVER['PAGECORE_CONFIG'] ?? null, true), "\n";
$private = dirname($_SERVER['DOCUMENT_ROOT']) . '/pagecore-private';
echo 'config readable=', var_export(is_readable($private . '/config.php'), true), "\n";
foreach (array('content', 'content/.backups', 'uploads', 'state') as $dir) {
printf("%-18s exists=%-3s writable=%s\n", $dir,
is_dir("$private/$dir") ? 'yes' : 'NO',
is_writable("$private/$dir") ? 'yes' : 'NO');
}
echo "--- boot ---\n";
require __DIR__ . '/cms/engine.php';
echo "BOOTED OK\n";Expect a PHP version of 8.3+, both getenv and server naming the private
config, writable=yes throughout, and BOOTED OK. content/.backups reports
exists=NO until the first publish, which is normal.
Delete the file as soon as you have read it. It discloses absolute paths
and PHP internals. Avoid posix_* calls in it — hosts commonly disable them,
and the resulting fatal masks the answer you are looking for.
Walk the routes rather than only the homepage — a wrong DOCUMENT_ROOT,
missing .htaccess or bad post_url shows up on the second page, not the
first. Expect 200 on every line; a 500 on the media path alone points at
the /uploads/ rewrite, and a 404 on a post at post_url.
POSIX shell:
for p in / /about/ /blog/ /blog/some-post/ /cms/login.php /uploads/2026/01/photo.jpg; do
printf '%-34s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "https://example.com$p")"
donePowerShell:
$base = 'https://example.com'
'/', '/about/', '/blog/', '/blog/some-post/', '/cms/login.php', '/uploads/2026/01/photo.jpg' | ForEach-Object {
$r = Invoke-WebRequest "$base$_" -SkipHttpErrorCheck -MaximumRedirection 0 -TimeoutSec 25
'{0,-34} {1}' -f $_, $r.StatusCode
}Keep -MaximumRedirection 0 in both: it makes the HTTP-to-HTTPS redirect and
any post_url redirect visible as 301 rather than being silently followed.
Then log in and publish one small edit. That is the only way to exercise the write path — drafts, backup creation, search index and sitemap regeneration — and it is the step most likely to reveal a permissions problem that read-only browsing hides.
Upgrade the Pagecore engine as a unit. Do not copy individual files from a
checkout or overwrite a live cms/ directory file by file: a release can add,
remove, or rename files, and a browser request that arrives mid-upload could
load a mismatched engine. The release archive is the supported manual upgrade
source; it contains a stamped, checksummed cms/ tree for one published build.
Before any upgrade, read the release notes, confirm the target release supports
the PHP version selected for the domain, and make a recoverable copy of the
current cms/ directory. Your private config.php, Markdown content, drafts,
backups, uploads, public PHP templates, and generated search files are not
part of the engine upgrade. Back them up under the site's normal backup policy
as well, but do not replace them with the archive's sample content/ or
uploads/ directories.
Use this procedure when automatic application is disabled, when the host does not let PHP replace the engine, or when you want to choose when the change is made. It works through SSH, a hosting-file manager, or FTP; download and unpack the archive locally first if the host does not provide shell access.
-
Sign in to Pagecore and note the installed version from Content or Updates. Obtain the target archive and its SHA-256 value from the published GitHub release, rather than from a source-code download.
-
Put the archive in a temporary directory outside the document root. Verify its SHA-256 checksum before extracting it. For example, on a Unix host:
sha256sum pagecore-X.Y.Z-<build>.zip # Compare the displayed hash with the one published for that exact archive. unzip -q pagecore-X.Y.Z-<build>.zip -d pagecore-new
On Windows, use
Get-FileHash pagecore-X.Y.Z-<build>.zip -Algorithm SHA256. Stop if the hash differs or if the extracted tree does not containpagecore-new/cms/engine.php. -
Check the release notes for configuration changes. In the standard production layout,
pagecore-private/config.phpis outside the public root and remains in place. Older installations that still usecms/config.phpmust copy that file aside and restore it into the newcms/directory; never replace it with the archive's example configuration. -
Make a named rollback copy of the complete live engine, then replace the directory with the extracted
cms/directory. With SSH, from the public root, the essential swap is:mv cms cms.before-X.Y.Z mv pagecore-new/cms cms
Do not delete
cms.before-X.Y.Zuntil the checks below succeed. On FTP or a file manager, upload the extractedcms/directory under a new temporary name, then use the host's rename operation to move the old directory aside and the new one into the exact namecms. Renames are preferable to deleting the old engine before the upload has finished. -
Load the public homepage,
/cms/login.php, and/cms/content.php; then log in and publish a small test edit. Confirm the reported version and that the expected content, media, drafts, and backup history remain available. If Pagecore fails to boot, renamecms.before-X.Y.Zback tocmsand inspect the PHP error log before retrying.
Once verified, retain the previous engine for as long as your deployment policy
requires, then remove it. Never leave a second copy with a public web route or
an exposed config.php.
Automatic upgrades are deliberately opt-in. In the private configuration copied
from deployment/pagecore-config.php.example, leave update checking enabled
and set the application switches only after the PHP worker has permission to
rename the public cms/ directory and write the configured private state and
work directories:
'update_channel' => 'main',
'update_state_dir' => $privateRoot . '/state',
'update_work_dir' => $privateRoot . '/updates',
'update_apply' => true,
'update_auto_apply' => true,
'update_keep' => 3,update_apply is the master safety switch. Its default is false: Pagecore
may check for and display an available version, but it cannot change cms/.
With it set to true, a logged-in administrator can apply an offered update
from /cms/update.php. update_auto_apply controls only unattended runs;
set it to false if cron should check and report availability but never apply
the update itself. Set update_channel to off to disable all checks.
Schedule a daily (or similarly infrequent) cron job after enabling it. The CLI form is preferred because it needs no secret in the network request:
37 3 * * * /usr/local/bin/php /home/USER/public_html/cms/update-cli.php >/dev/null 2>&1Make sure the cron environment supplies PAGECORE_CONFIG and, where needed,
PAGECORE_DOCUMENT_ROOT, just as the web process does. To prove the job can
check without applying, run it once with --check:
/usr/local/bin/php /home/USER/public_html/cms/update-cli.php --checkIf the control panel can call only a URL, generate a random 32-byte-or-longer
update_cron_key, add it to the private configuration, and send it in the
X-Pagecore-Update-Key header to /cms/update-cron.php. The CLI remains the
safer choice because it does not send or log a credential. An empty or invalid
key disables the HTTP endpoint and it responds with 404.
Pagecore never checks the network while rendering a site page. A scheduled run
(or a same-origin administrative refresh) downloads the published update feed
over HTTPS, caches its result in update_state_dir, and compares the stamped
installed build with the published build. It refuses an unsupported PHP
version, a different channel, a malformed feed or archive, and a build that is
not newer; an unstamped hand-copied installation also requires an interactive
first update rather than an unattended one.
When an eligible update is applied, Pagecore downloads it to
update_work_dir, verifies its archive SHA-256 and every file in its manifest,
then snapshots the current cms/ directory. It stages the verified replacement
beside the live engine and swaps the directories under an exclusive lock. Only
cms/ changes: private configuration, content, uploads, drafts, backups, and
site templates are preserved. The admin portion briefly returns 503 during
the swap, while public pages continue serving; failed preflight, download, or
verification steps leave the live engine untouched. The prior engine snapshots
are kept according to update_keep, so an operator can restore one manually
if a post-upgrade issue is discovered.
- Single-account login with a bcrypt password hash; browser-session-scoped progressive delays and a 5-failure, 5-minute lockout. This avoids a shared lockout that an unauthenticated client could use to deny editor access.
- Sessions are HttpOnly, SameSite=Lax, secure over HTTPS, with an absolute lifetime; the session ID is regenerated on login.
- CSRF protection — every state-changing API call requires a per-session token header.
- Path safety — fragment keys and post slugs are strictly validated and
resolved inside
content/only. - Safe Markdown boundary — raw HTML is always escaped and unsafe Markdown URL schemes are neutralized; site configuration cannot disable safe mode. Imported active elements are removed or converted to non-executable links.
- Upload validation and isolation — the upload allowlist is limited to
raster images and PDFs, with a size limit, server-side MIME sniffing (never
trusts the client), and raster decoding checks. Active SVG/XML uploads are
rejected. PDFs are served through a controlled endpoint with
nosniffand a restrictive sandbox policy. Uploaded files get randomized names, and uploads live outside the document root. Both raster images and PDFs are delivered through a controlled handler; PDFs render inline when the browser is set to display them. - Engine internals (
config.php,engine.php,auth.php,cms/lib/) are not reachable over HTTP. - Atomic writes (temp file + rename, Windows-safe) so a failed save never corrupts live content.
-
PHP 8.3+ on a branch still receiving security fixes; the
fileinfoextension is used when present, with a magic-byte fallback otherwise. The version is enforced at boot, so a host still defaulting the domain to an older branch fails every request until the per-domain PHP version is raised. -
The PHP
zipextension for the archive-installer contract innpm run test:php. The default Windows runtime isC:\Tools\PHP\php.exe. IfC:\Tools\PHP\ext\php_zip.dllis missing, download the same PHP version, architecture, compiler, and Thread Safe/Non-Thread Safe build shown byC:\Tools\PHP\php.exe -vfrom PHP for Windows, then takeext\php_zip.dllfrom that archive. InC:\Tools\PHP\php.ini, change;extension=ziptoextension=zip, open a new shell, and verify:& 'C:\Tools\PHP\php.exe' -m | Select-String '^zip$' & 'C:\Tools\PHP\php.exe' -r "exit(class_exists('ZipArchive') ? 0 : 1);"
Do not mix extension DLLs from different PHP builds.
-
A PHP-capable web server whose document root contains only public templates and
cms/; private storage must be a sibling or otherwise external path. The bundled PHP router is for loopback development only. -
Write access for the web-server user to
content/,content/.drafts/,uploads/,search-index.jsonandsitemap.xml.
This repository includes a working sample site under sample-site/. It uses
the reusable cms/ directory directly, but points the engine at
sample-site/config.php through the PAGECORE_CONFIG environment variable.
sample-site/_bootstrap.php copies that variable into CMS_CONFIG_FILE
(reading getenv() and $_SERVER) so a pinned sibling path cannot disagree
with SetEnv. You can also define a CMS_CONFIG_FILE constant before requiring
cms/engine.php if an integration needs a per-site config file.
Install the test runner and start the sample site:
npm install
npm run sample:startOpen http://127.0.0.1:8765/sample-site/ and sign in at /cms/login.php with
admin / admin.
These credentials are intentionally public and cannot boot in production
mode. The sample configuration is marked development_only; Pagecore requires
the explicit development opt-in and rejects non-loopback requests. The
launcher therefore binds to 127.0.0.1 and must not be exposed to a network.
Run the Playwright suite against the sample site:
npm run test:e2eRun the separate, reusable migration-output contract lane:
npm run test:migrationThe base configuration discovers only tests/sample-site.spec.js; the migration
configuration discovers only tests/migration.spec.js. Site-specific deployment
data such as a local zagozda/ checkout remains ignored and cannot change either
lane's test discovery.
Build one checksummed deployment archive with npm run release:build; verify
the complete build/install/drift contract with npm run release:test. Private
sites consume that archive through scripts/Install-PagecoreRelease.ps1 and
retain only their own cms/config.php, templates, and content. The Zagozda
launcher performs this verified install before it starts, so its ignored
fixture cannot become a second CMS implementation.
The Playwright config starts the PHP built-in server with
C:\Tools\PHP\php.exe (or PAGECORE_PHP_EXE when set). The committed browser
test site lives in sample-site/fixtures/; its page, post, visibility,
navigation, and upload contract is recorded in
sample-site/fixtures/test-site.json. Each browser-test worker receives a
private copy in the system temporary directory. That copy is reset before and
after every test, then removed at the end of the worker, so browser tests never
depend on data created by another test or a previous run.
Check the committed test-site contract with npm run test:sample-test-site.
Reset the local runnable sample to its committed fixture state with either
npm run sample:reset or npm run test:site:reset. The reset helper verifies
that its copied files match the committed fixtures byte-for-byte. The suite
covers visitor rendering, drafts, preview, publish, revision restore, post
creation, upload validation, media-library search, metadata sidecars, picker
insertion, deletion of unused uploads, content inventory, missing Markdown
creation and editable navigation.
Project-authored PHP, JavaScript, and CSS filenames are lowercase. Use hyphens to separate words when that improves readability. This keeps includes portable to case-sensitive hosts and makes the web-facing source tree consistent.
The remaining mixed-case names follow an external convention rather than a
Pagecore convention: PowerShell scripts use the standard Verb-Noun.ps1
format, C# and WPF filenames match their PascalCase types, README.md and
AGENTS.md are conventional repository metadata names, and
cms/lib/Parsedown.php retains its upstream vendored filename.
cms/ # the reusable engine
├── engine.php # core: config, rendering, content model, index generation
├── auth.php # login/logout, CSRF, brute-force lockout
├── api.php # JSON API used by the in-browser editor
├── assets/ # editor UI (vanilla JS + CSS, no build step)
├── lib/Parsedown.php # Markdown renderer
└── README.md # install & operations guide
sample-site/ # runnable demo site and content fixtures
scripts/ # sample reset/start helpers
tests/ # Playwright browser tests
content/ # protected site-content root; add Markdown beneath it
uploads/ # protected media root; PHP execution is blocked here