What it is

FlatPanel is a CakePHP plugin shipped with Flat Cake that mounts a tiny admin at /panel (configurable). It lets you edit any PHP template under templates/Pages/, templates/layout/ or templates/element/ straight from the browser using the Monaco editor, with PHP syntax validation before every save.

The plugin is fully isolated from the public site: its own AppController, its own layout, its own stylesheet, its own JS. A full visual reset of the public site cannot break the panel.

  • Plugin path: plugins/FlatPanel/
  • Mount point: /panel (override via FlatPanel.prefix)
  • Config file: config/panel.php (gitignored)
  • Tests: vendor/bin/phpunit --testsuite flat-panel

Setup

Copy the example config, generate a password hash using the built-in command, paste the line it prints, and you're in.

cp config/panel.example.php config/panel.php
bin/cake hash_password admin
# → paste the printed line into config/panel.php under FlatPanel.users

The command prompts for the password interactively (echo is hidden on real terminals) so the plaintext never reaches your shell history. Run it once per user — FlatPanel.users accepts any number of entries.

Default URL prefix is /panel. Override via FLAT_PANEL_PREFIX in config/.env or edit the prefix key directly.

Writable directories

The web server user needs write access to:

  • templates/ — the files you edit
  • tmp/panel_backups/ — timestamped copies made before every change
  • tmp/panel_auth_attempts/ — rate-limit bookkeeping

Authentication

Sessions are cookie-based with a 2-hour inactivity timeout — every request refreshes the timer. Credentials are verified against password_verify() on bcrypt hashes kept in config/panel.php.

Login attempts are rate-limited per client IP via a file-based counter in tmp/panel_auth_attempts/. After repeated failures the IP is blocked for 15 minutes, regardless of the username it tried.

Multiple users

Add one entry per person under FlatPanel.users:

'users' => [
    'admin'  => '$2y$12$...',
    'editor' => '$2y$12$...',
    'chief'  => '$2y$12$...',
],

All users share the same permissions. If you need role separation, it's one extra check away.

Editor

Editing opens the file in Monaco (VS Code's editor core) loaded from a public CDN. If the CDN is unreachable, the panel silently falls back to a plain textarea so you can still make changes.

  • Save: Ctrl/Cmd+S, or the Save button.
  • Preview: for templates/Pages/* files two controls live in the top header. The eye button toggles a side-by-side live preview — the editor slides to 50% while the rendered page slides in from the right, and re-loads automatically after every save. The Preview ↗ button opens the same page in a new tab.
  • Lint before save: every save runs php -l on a temporary copy; syntax errors are rejected and the live file is untouched. If PHP CLI is unavailable on the host a banner is shown and saves proceed without the check.
  • Theme sync: Monaco follows the panel's light/dark theme, re-themed live when you switch.

Whitelist

Only .php files under these three directories are accepted:

templates/Pages/
templates/element/
templates/layout/

Path validation resolves symlinks and rejects anything that doesn't fall inside one of these trees. There's no way to address files outside the whitelist via the UI or the API.

Create, rename, delete

The dashboard splits templates into Layouts, Elements, and Pages. Each box has a small "+ New X" button in its header that opens the create form for that kind.

  • Slug rules: lowercase letters, digits, hyphens and underscores. No path components.
  • Position (optional): a number that pins the file at that order among siblings. Stored as an NN_ prefix on the filename; stripped from the label and URL so 20_about.php still renders as "About" at /about.
  • Boilerplate: new pages start with a minimal <section> skeleton.
  • Rename: creates a backup of the original first; the URL changes with the filename. The modal has the same Position field as the create form — it auto-fills with the current NN_ prefix (if any), and clearing it drops the pin back to alphabetical ordering.
  • Delete: creates a backup first, then unlinks. Entries listed under FlatPanel.protectedFiles (by default home.php) cannot be deleted.

Previewing file metadata

Hovering a file row reveals its size and last-modified time without visual clutter in the default state. The filename stays as the primary affordance.

Backups & restore

Every save, rename and delete drops a timestamped copy of the affected file into tmp/panel_backups/, mirroring the original path. Microsecond precision keeps rapid changes (e.g. restore-over-live) from colliding.

tmp/panel_backups/
    templates/Pages/
        home.php.2026-04-22_14-35-02_123456.php
        home.php.2026-04-22_09-11-48_087221.php
    templates/element/
        header.php.2026-04-21_16-02-17_401003.php

Trash view

The Backups button on the dashboard opens /panel/files/trash. Each group is a file with a Live or Deleted badge, showing every stored version with its time and size. Two actions per version:

  • Preview — opens Monaco in read-only mode so you can review the contents before committing to anything.
  • Restore — copies the version back to its original location. If the live file still exists it gets a fresh backup first, so restoration itself is reversible.

Cleanup

On every write, backups older than 30 days for that specific file are pruned automatically. Nothing else deletes backups — if you need more retention, bump the cutoff in FileManager::cleanOldBackups().

Appearance

The cog in the top-right opens a small popover with two settings: theme mode and accent colour. Choices persist in cookies (fp_theme, fp_accent) and are rendered server-side into the <html> tag, so the panel never flashes the wrong colours on the first paint.

  • Theme: Auto / Light / Dark. Auto follows the OS via prefers-color-scheme and re-syncs Monaco when the OS pref changes.
  • Accent: six presets. The accent drives the logo layers, buttons, focus ring, status messages — one variable (--fp-accent) powers the whole palette.

The panel's stylesheet uses --fp-* tokens exclusively — no --cb-*, no shared custom properties with the public site. If you strip or redesign the frontend, the panel keeps working with its own design system intact.

Command line

hash_password

Generates a bcrypt hash for FlatPanel.users. Always asks for the password interactively — the secret never lives on the command line, so it stays out of shell history. Requires confirmation.

bin/cake hash_password editor
# → Password:
# → Confirm password:
# → 'editor' => '$2y$12$...'

Omit the username to be prompted for it too. Password echo is hidden via stty -echo on real terminals; on non-TTY environments (CI, piped stdin) the command falls back to a plain prompt with a visible notice.

Security notes

  • Path traversal: every file operation validates the resolved realpath against the whitelist. Symlinks, .. segments and absolute paths all get the same treatment.
  • Restore scope: restore paths are validated twice — the source must sit inside tmp/panel_backups/, and the derived target must land in a whitelisted templates/ subdir and end in .php.
  • CSRF: Cake's CSRF middleware runs with httponly=true. Forms use the auto-injected _csrfToken field; the editor's save XHR reads the token from a server-rendered constant instead of the cookie.
  • Protected files: home.php is listed under FlatPanel.protectedFiles by default and cannot be deleted from the UI. Extend the list to cover anything else that shouldn't disappear.
  • Rate limit: 5 failed logins per IP per 15 minutes. The successful login counter resets on success.

Self-hosting Monaco

Monaco is loaded from cdnjs.cloudflare.com by default. If your CSP blocks external script origins, vendor the editor locally:

  1. Copy Monaco's min/vs/ bundle into plugins/FlatPanel/webroot/monaco/vs/.
  2. Update the two script URLs in plugins/FlatPanel/templates/layout/panel.php to /flat_panel/monaco/vs/loader.min.js (and match the AMD paths.vs config above it).

If Monaco fails to load for any reason, the editor transparently degrades to a plain <textarea> — you never get stuck.