CMS Studio
A complete CMS module: public front end, administration desk, templates, schema, AJAX lists, and dotapp.js.
Live studio: /documentation/examples/run/studio.
Locked desk: /documentation/examples/run/studio/admin.
What you are building
A CMS is not a contact form. It is two products that share one module: a public website people read, and a desk editors use to change that website. This walkthrough builds both inside app/modules/Studio. The live example is a Bratislava software house called Lumen Press — home, services, insights, about, contact, plus an administration shell with articles, pages, menu, media, and settings.
This public demo cannot sign in
The administration login is a real fo-rm. PHP validates the payload, then always rejects it. Auth::login() is never called. Every save, delete, reorder, and settings POST is stopped by DeskGate@write. Nobody can insert content into the public demo. The desk pages are a read-only preview so you can still see the UI.
Use this page as the recipe for a real CMS on your own project. Swap the in-memory catalog for studio_* tables, put the desk behind Auth::isLogged(), and keep the same templates.
Titles and JSON-LD
Every live Studio page starts its title with Example: and ships JSON-LD as TechArticle + LearningResource.
There is no NewsArticle or Product schema.
- Users module — real login, register, 2FA,
AuthGate - Secure forms —
fo-rm,formName, CRC - AJAX lists —
paginate(), encrypted ids, search - DotApper CLI — scaffold the module, never hand-create the skeleton
Module map
One module owns the whole CMS. Do not split “frontend app” and “admin app” into two modules unless they are genuinely separate products.
| Piece | Path | Role |
|---|---|---|
| Routes | module.init.php | Public URLs + /admin/*. Write routes use ->before('#Studio:DeskGate@write!'). |
| Public site | Controllers/Site.php | Home, services, insights, article, about, contact. |
| Desk | Controllers/Admin.php | Login (always fails here), dashboard, articles, pages, menu, media, settings. |
| Gate | Middleware/DeskGate.php | Demo: reject every write. Production: require Auth::isLogged() + Auth::can(). |
| Catalog | Libraries/Press.php | Demo content in PHP. Production: DB::module('RAW') on studio_*. |
| Schema | Installation.php | Versioned tables: articles, pages, topics, menus, media, settings. |
| Templates | views/*.view.php | Full HTML documents + fragments for AJAX lists. No Blade, no Twig, no include. |
| JS | assets/js/studio.js, admin.js | $dotapp().form and $dotapp().load. Not jQuery. |
Live URLs
| URL | What you see |
|---|---|
| /documentation/examples/run/studio | Public studio home |
/documentation/examples/run/studio/article/{slug} | One insight |
| /topics | Services |
| /insights | Insight index |
| /admin | Login that always fails |
| /admin/desk | Read-only desk preview |
| /admin/articles | Paginated AJAX list (search “cloud”) |
Prefix is Config::module('Studio', 'prefix'), default /documentation/examples/run/studio. On your site use / for the public site and /admin for the desk.
Files on disk
After DotApper, fill these paths. The live demo reads articles, pages, topics, and the nav from Libraries/Press.php.
Installation.php is the production schema — it is listed in full later on this page. It is not executed on the public demo.
app/modules/Studio/
module.init.php routes
Installation.php studio_* tables (full file below)
Libraries/View.php Renderer helper
Libraries/Press.php demo catalog + menu()
Libraries/Mark.php Example SEO
Controllers/Site.php public site
Controllers/Admin.php desk
Middleware/DeskGate.php write lock
views/site.view.php public chrome (nav loops $menu)
views/site-*.view.php public fragments
views/admin.view.php desk chrome
views/admin-*.view.php desk fragments
assets/css/studio.css
assets/js/studio.js
assets/js/admin.js
assets/img/ logo, hero, practices, team
Scaffold with DotApper
Never hand-create the module skeleton. Generate it, then fill in routes and classes.
php dotapper.php --create-module=Studio
php dotapper.php --module=Studio --create-controller=Site
php dotapper.php --module=Studio --create-controller=Admin
php dotapper.php --module=Studio --create-middleware=DeskGate
--module= must appear before --create-controller / --create-middleware.
That creates app/modules/Studio/ with Controllers/, Middleware/, Libraries/, views/, assets/, and module.init.php.
DotApper also drops placeholder files you can ignore or replace: Api/Api.php, Controllers/Controller.php, views/clean.view.php, views/layouts/example.layout.php, module.listeners.php.
What you then write by hand (this walkthrough):
| File | You write |
|---|---|
module.init.php | Prefix + every public and desk route |
Installation.php | All studio_* tables (full file in the Schema section) |
Libraries/View.php | Renderer helper: document vs fragment |
Libraries/Press.php | Demo catalog (production: DB queries) |
Libraries/Mark.php | Example titles + TechArticle JSON-LD |
Controllers/Site.php | Public site |
Controllers/Admin.php | Desk (login always fails here) |
Middleware/DeskGate.php | Reject every write on the public demo |
views/*.view.php | One chrome document + inner fragments |
assets/css/studio.css, assets/js/*.js | Look and $dotapp behaviour |
Routes: the complete module.init.php
Static controllers: 'Studio:Site@home!'. Trailing ! is required. Pair each path with and without a trailing slash.
Dynamic article slugs are not STATIC_ROUTE. Register exact admin paths before /admin/articles/{slug:s} so /admin/articles is not swallowed.
Write POSTs attach ->before('#Studio:DeskGate@write!'). There is no Laravel Route::group().
File: app/modules/Studio/module.init.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Studio;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Router;
class Module extends \Dotsystems\App\Parts\Module
{
public function initialize($dotApp)
{
Config::module('Studio', 'prefix') ?? Config::module('Studio', 'prefix', '/documentation/examples/run/studio');
$p = rtrim((string) Config::module('Studio', 'prefix'), '/');
$pair = function (string $path): array {
$path = rtrim($path, '/');
return [$path, $path . '/'];
};
Router::get($pair($p), 'Studio:Site@home!', Router::STATIC_ROUTE);
Router::get($pair($p . '/topics'), 'Studio:Site@topics!', Router::STATIC_ROUTE);
Router::get($pair($p . '/insights'), 'Studio:Site@insights!', Router::STATIC_ROUTE);
Router::get($pair($p . '/about'), 'Studio:Site@about!', Router::STATIC_ROUTE);
Router::get($pair($p . '/contact'), 'Studio:Site@contact!', Router::STATIC_ROUTE);
Router::post($pair($p . '/contact'), 'Studio:Site@contactSave!', Router::STATIC_ROUTE);
Router::get($p . '/article/{slug:s}', 'Studio:Site@article!');
Router::get($p . '/topic/{slug:s}', 'Studio:Site@topic!');
Router::get($pair($p . '/admin'), 'Studio:Admin@login!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin'), 'Studio:Admin@loginSave!', Router::STATIC_ROUTE);
Router::get($pair($p . '/admin/desk'), 'Studio:Admin@desk!', Router::STATIC_ROUTE);
Router::get($pair($p . '/admin/articles'), 'Studio:Admin@articles!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/articles/list'), 'Studio:Admin@articlesList!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/articles/save'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::post($pair($p . '/admin/articles/delete'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($p . '/admin/articles/{slug:s}', 'Studio:Admin@article!');
Router::get($pair($p . '/admin/pages'), 'Studio:Admin@pages!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/pages/save'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($pair($p . '/admin/menu'), 'Studio:Admin@menu!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/menu/save'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($pair($p . '/admin/media'), 'Studio:Admin@media!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/media/list'), 'Studio:Admin@mediaList!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/media/delete'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($pair($p . '/admin/settings'), 'Studio:Admin@settings!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/settings'), 'Studio:Admin@settingsSave!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
}
public function initializeRoutes()
{
return ['/documentation/examples/run/studio', '/documentation/examples/run/studio/*'];
}
public function initializeCondition($routeMatch)
{
return $routeMatch;
}
}
new Module($dotApp);
Read slugs with $request->matchData()['slug']. Missing article → new Response(404, 'Article not found').
Renderer helper: the complete View.php
Call Renderer::new()->module('Studio')->setView($name) before setViewVar.
A view that fails to render returns "" — log it and return HTTP 500.
page() is a full HTML document (chrome). fragment() is an inner view swapped into {{ var: $bodyHtml }}.
There is no Blade, no Twig, no PHP include inside a view.
File: app/modules/Studio/Libraries/View.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Studio\Libraries;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Renderer;
use Dotsystems\App\Parts\Response;
class View
{
public static function prefix(): string
{
return rtrim((string) Config::module('Studio', 'prefix'), '/');
}
public static function docsUrl(): string
{
return '/documentation/examples/studio';
}
public static function dotappJs(): string
{
return '/assets/dotapp/dotapp.js?n=' . bin2hex(random_bytes(4));
}
public static function assetV(): string
{
return '270';
}
public static function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function seoPack(string $pageLabel, string $path, string $robots = 'index,follow'): array
{
$title = Mark::title($pageLabel);
$desc = Mark::description();
return [
'title' => $title,
'metaDescription' => $desc,
'canonical' => Mark::canonical($path),
'jsonLd' => Mark::jsonLd($title, $desc, $path),
'robots' => $robots,
];
}
public static function page(string $name, array $vars)
{
$r = Renderer::new()->module('Studio')->setView($name, 'clean');
foreach ($vars as $key => $value) {
$r->setViewVar($key, $value);
}
$html = $r->renderView();
if ($html === '') {
Logger::use()->error('Studio view empty', ['view' => $name]);
return new Response(500, 'Template error');
}
return $html;
}
public static function fragment(string $name, array $vars): string
{
$r = Renderer::new()->module('Studio')->setView($name, 'clean');
foreach ($vars as $key => $value) {
$r->setViewVar($key, $value);
}
$html = $r->renderView();
if ($html === '') {
Logger::use()->error('Studio fragment empty', ['view' => $name]);
return '<p class="lp-empty">Could not render this block.</p>';
}
return $html;
}
}
Schema: the complete Installation.php
A CMS is not “one articles table”. The public site, the desk, the menu, media, and settings each need a table the module owns.
Every table is studio_*. Never unprefixed names, never dotapp_* for studio data.
There is no working DB::migrate(). You write versioned SQL in Installation.php, which extends Installer.
The public demo does not run this installer. Live pages read PHP arrays from Libraries/Press.php so a visitor cannot INSERT a row.
On your own project you copy this file, then call Installation::module('Studio')->install() from initialize() once a database is configured.
What each table is for — this is the composition the snippet in older docs hid:
| Table | Who fills it | Who reads it |
|---|---|---|
studio_topics | Desk → Topics (or a seed) | Public topic index, article topic_id |
studio_articles | Desk → Articles editor | Home, article URL, topic listing |
studio_pages | Desk → Pages (About, masthead, legal) | /about and other static documents |
studio_menus | Desk → Menu (one row per nav, code = primary) | Join to items |
studio_menu_items | Desk → Menu rows (label, href, pos) | site.view.php loops this into <nav> |
studio_media | Desk → upload | Library list, article images |
studio_settings | Desk → Settings | Sitename, tagline in the chrome |
studio_installations | ensureTable() / markDone() | Idempotency — version 1.0.0 runs once |
Read the class top to bottom. installer() returns an array of version callbacks. alreadyDone('1.0.0') creates studio_installations if needed and bails if that version is already marked.
Then every CREATE TABLE runs in a loop. Only if all succeed does markDone('1.0.0') run — not inside the first table’s execute() success callback. That mistake would mark the module installed after a single table.
uninstaller() drops in reverse order (items before menus, articles before topics).
File: app/modules/Studio/Installation.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Studio;
use Dotsystems\App\Parts\DB;
use Dotsystems\App\Parts\Installer;
use Dotsystems\App\Parts\Logger;
class Installation extends Installer
{
public static function installer()
{
return [
'1.0.0' => function () {
if (self::alreadyDone('1.0.0')) {
return;
}
$sql = [];
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_topics` (
`id` INT NOT NULL AUTO_INCREMENT,
`slug` VARCHAR(80) NOT NULL,
`title` VARCHAR(160) NOT NULL,
`blurb` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_articles` (
`id` INT NOT NULL AUTO_INCREMENT,
`topic_id` INT NOT NULL DEFAULT 0,
`slug` VARCHAR(160) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`excerpt` VARCHAR(255) NOT NULL DEFAULT '',
`body` MEDIUMTEXT NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`published_at` DATETIME NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`),
KEY `topic_status` (`topic_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_pages` (
`id` INT NOT NULL AUTO_INCREMENT,
`slug` VARCHAR(160) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`body` MEDIUMTEXT NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_menus` (
`id` INT NOT NULL AUTO_INCREMENT,
`code` VARCHAR(40) NOT NULL,
`title` VARCHAR(120) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_menu_items` (
`id` INT NOT NULL AUTO_INCREMENT,
`menu_id` INT NOT NULL,
`label` VARCHAR(120) NOT NULL,
`href` VARCHAR(255) NOT NULL,
`pos` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `menu_pos` (`menu_id`, `pos`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_media` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(200) NOT NULL,
`kind` VARCHAR(40) NOT NULL DEFAULT 'image',
`path` VARCHAR(255) NOT NULL,
`bytes` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_settings` (
`id` INT NOT NULL AUTO_INCREMENT,
`skey` VARCHAR(80) NOT NULL,
`svalue` TEXT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `skey` (`skey`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$ok = true;
foreach ($sql as $chunk) {
DB::module('RAW')->q(function ($qb) use ($chunk) {
$qb->raw($chunk, []);
})->execute(
function () {},
function ($error) use (&$ok) {
$ok = false;
Logger::use()->error('Studio 1.0.0 failed', $error);
}
);
if (!$ok) {
return;
}
}
self::markDone('1.0.0');
},
];
}
public static function uninstaller()
{
return [
'1.0.0' => function () {
$tables = [
'studio_settings',
'studio_media',
'studio_menu_items',
'studio_menus',
'studio_pages',
'studio_articles',
'studio_topics',
'studio_installations',
];
foreach ($tables as $table) {
DB::module('RAW')->q(fn($qb) => $qb->raw('DROP TABLE IF EXISTS `' . $table . '`', []))
->execute(null, function ($e) use ($table) {
Logger::use()->error($table . ' drop failed', $e);
});
}
},
];
}
private static function ensureTable(): void
{
DB::module('RAW')->q(function ($qb) {
$qb->raw(
"CREATE TABLE IF NOT EXISTS `studio_installations` (
`id` INT NOT NULL AUTO_INCREMENT,
`installation_id` VARCHAR(100) NOT NULL,
`installed_at` DATETIME NOT NULL,
`status` TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `ver` (`installation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
[]
);
})->execute(null, function ($e) {
Logger::use()->error('studio_installations table', $e);
});
}
private static function alreadyDone(string $version): bool
{
self::ensureTable();
$rows = DB::module('RAW')->q(function ($qb) use ($version) {
$qb->raw(
'SELECT 1 AS ok FROM `studio_installations` WHERE `installation_id` = :v AND `status` = 1 LIMIT 1',
['v' => $version]
);
})->all();
return !empty($rows);
}
private static function markDone(string $version): void
{
DB::module('RAW')->q(function ($qb) use ($version) {
$qb->insert('studio_installations', [
'installation_id' => $version,
'installed_at' => date('Y-m-d H:i:s'),
'status' => 1,
]);
})->execute(null, function ($e) {
Logger::use()->error('Studio markDone', $e);
});
}
}
Hook it from module.init.php on a real site (not on this public demo):
$dbs = Config::get('databases');
if (is_array($dbs) && $dbs !== []) {
try {
Installation::module('Studio')->install();
} catch (\Throwable $e) {
Logger::use()->error('Studio install skipped', ['msg' => $e->getMessage()]);
}
}
Lists that can grow must use ->paginate($perPage, $page) plus an AJAX pager on day one.
Production article list (the demo uses Press::paginateArticles() in memory instead):
$result = DB::module('RAW')->q(function ($qb) use ($q, $useSearch) {
$qb->select(['id', 'title', 'status', 'published_at'])
->from('studio_articles')
->orderBy('id', 'DESC');
if ($useSearch) {
$esc = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $q);
$qb->where('title', 'LIKE', '%' . $esc . '%');
}
})->paginate(10, $page);
Production menu for the public <nav> — this is where the Home / Services / Insights / Company / Contact links come from once you leave Press.php:
$menu = DB::module('RAW')->q(function ($qb) {
$qb->select(['i.id', 'i.label', 'i.href', 'i.pos'])
->from('studio_menu_items', 'i')
->join('studio_menus m', 'm.id', '=', 'i.menu_id')
->where('m.code', '=', 'primary')
->orderBy('i.pos', 'ASC');
})->all();
Public site: where templates and the menu come from
The studio site is two layers. A document view owns <html>, the EXAMPLE ribbon, the sticky header, dropdowns, the footer, CSS, and scripts.
An inner view is only the <main> body (home, one insight, contact form).
PHP renders the inner view first, then injects that HTML string as $bodyHtml into the document.
That is why you do not include templates and you do not put a second <html> in site-home.view.php.
Every public view file
| File | Kind | What it paints |
|---|---|---|
views/site.view.php | Document | Ribbon, logo, dropdown <nav> from $menu, $bodyHtml, footer, studio.js |
views/site-home.view.php | Fragment | Hero, services, featured insight, team |
views/site-article.view.php | Fragment | One insight |
views/site-topics.view.php | Fragment | Services index |
views/site-insights.view.php | Fragment | Insight index |
views/site-topic.view.php | Fragment | Insights in one practice |
views/site-page.view.php | Fragment | About / leadership |
views/site-contact.view.php | Fragment | fo-rm named contactForm |
assets/css/studio.css | CSS | Served as /assets/modules/Studio/css/studio.css |
assets/js/studio.js | JS | Drawer menu + contact $dotapp().form |
assets/img/ | Images | Logo, hero, practices, team — /assets/modules/Studio/img/ |
Template rules: close with {{ /if }} and {{ /foreach }}, never endif.
Print with {{ var: $title }} only — there is no {{ $title }}.
Layout partials use {{ layout:name }}. PHP include in a view is forbidden.
How a page is assembled
Site::home() loads sample rows from Press::articles(), then calls private site('site-home', ...).
That helper renders the fragment, then the document. Copy this method — it is the whole composition:
File: app/modules/Studio/Controllers/Site.php — method site()
private static function site(string $inner, string $pageLabel, string $path, array $vars)
{
$p = View::prefix();
$innerVars = $vars;
$innerVars['prefix'] = $p;
$body = View::fragment($inner, $innerVars);
$nav = (string) ($vars['nav'] ?? '');
return View::page('site', array_merge(View::seoPack($pageLabel, $path), [
'nav' => $nav,
'bodyHtml' => $body,
'prefix' => $p,
'docsUrl' => View::docsUrl(),
'homeUrl' => $p . '/',
'topicsUrl' => $p . '/topics',
'insightsUrl' => $p . '/insights',
'aboutUrl' => $p . '/about',
'contactUrl' => $p . '/contact',
'adminUrl' => $p . '/admin',
'deskUrl' => $p . '/admin/desk',
'dotappJs' => View::dotappJs(),
'assetV' => View::assetV(),
'logoUrl' => Press::asset('lumen-logo.png'),
'settings' => Press::settings(),
'topics' => Press::topics(),
'menu' => Press::nav($nav),
]));
}
Where the menu comes from
The desk table still uses a flat Press::menu() (Home / Services / Insights / Company / Contact).
The public header uses Press::nav($active), which adds dropdown children for Services, Insights, and Company.
site.view.php loops $menu into <nav> and nested $item['kids'] into the panels.
On a real CMS you replace both helpers with a query on studio_menu_items (see Schema). The view file does not change.
File: app/modules/Studio/Libraries/Press.php — method menu()
public static function menu(): array
{
$p = View::prefix();
return [
['id' => 1, 'label' => 'Home', 'href' => $p . '/', 'pos' => 1],
['id' => 2, 'label' => 'Services', 'href' => $p . '/topics', 'pos' => 2],
['id' => 3, 'label' => 'Insights', 'href' => $p . '/insights', 'pos' => 3],
['id' => 4, 'label' => 'Company', 'href' => $p . '/about', 'pos' => 4],
['id' => 5, 'label' => 'Contact', 'href' => $p . '/contact', 'pos' => 5],
];
}
Document chrome
File: app/modules/Studio/views/site.view.php — copy the live file for the full head and footer
<header class="lp-top">
<a class="lp-logo" href="{{ var: $homeUrl }}">
<img class="lp-logo-mark" src="{{ var: $logoUrl }}" width="42" height="42" alt="" />
<span class="lp-logo-type">LUMEN<span>PRESS</span></span>
<span class="lp-example-tag">example</span>
</a>
<button type="button" class="lp-burger" id="lpMenuBtn" aria-controls="lpNav" aria-expanded="false">Menu</button>
<nav class="lp-nav" id="lpNav" aria-label="Studio">
{{ foreach $menu as $item }}
{{ if $item['drop'] }}
<div class="lp-drop {{ var: $item['cls'] }}">
<a class="lp-drop-link" href="{{ var: $item['href'] }}">{{ var: $item['label'] }}</a>
<button type="button" class="lp-drop-caret" aria-expanded="false"></button>
<div class="lp-drop-panel">
{{ foreach $item['kids'] as $kid }}
<a href="{{ var: $kid['href'] }}"><strong>{{ var: $kid['label'] }}</strong><span>{{ var: $kid['blurb'] }}</span></a>
{{ /foreach }}
</div>
</div>
{{ else }}
<a href="{{ var: $item['href'] }}" class="{{ var: $item['cls'] }}">{{ var: $item['label'] }}</a>
{{ /if }}
{{ /foreach }}
</nav>
</header>
<main class="lp-main" id="main">{{ var: $bodyHtml }}</main>
The live file also has Open Graph tags, a four-column footer, and googlebot-news: noindex. Copy app/modules/Studio/views/site.view.php for the exact document.
Home fragment
No <html> here. Variables come from Site::home(): $settings, $featured, $topics, $articles, $team, $clients, $prefix.
The live home is a full studio landing (hero photograph, services, insights, partners). Copy app/modules/Studio/views/site-home.view.php.
File: app/modules/Studio/views/site-home.view.php — opening of the live file
<section class="lp-hero">
<div>
<p class="lp-kicker">Bratislava software studio</p>
<h1>{{ var: $settings['tagline'] }}</h1>
<p class="lp-lead">Lumen Press designs platforms, cloud estates, and product surfaces for operators who still want the keys.</p>
<div class="lp-hero-actions">
<a class="lp-btn" href="{{ var: $prefix }}/topics">See the work</a>
<a class="lp-btn lp-btn-ghost" href="{{ var: $prefix }}/contact">Start a conversation</a>
</div>
</div>
</section>
Contact form
Always $request->crcCheck() first. Always $request->data(true)['data'] for the payload. Always DotApp::DotApp()->ajaxReply($body, $code).
Put {{ formName(contactForm) }} between the <fo-rm> tags.
$answer = $request->form(['POST'], 'contactForm', function ($request) {
$data = $request->data(true)['data'] ?? [];
$email = trim((string) ($data['email'] ?? ''));
if (!Validator::isEmail($email)) {
return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Enter a valid email.']];
}
return ['code' => 200, 'body' => [
'status' => 1,
'message' => 'Thanks. We received your note.',
]];
}, function () {
return ['code' => 403, 'body' => ['status' => 0, 'message' => 'Invalid signature']];
}, $request->getPath());
Administration: desk templates and the write lock
The desk is the same module, a second document. Sidebar + lock banner live in admin.view.php.
On a phone the sidebar is an off-canvas drawer (#stDeskMenuBtn).
Each screen is a fragment rendered into {{ var: $bodyHtml }} the same way as the public site.
Login is a separate document: admin-login.view.php with a fo-rm named loginForm.
Every desk view file
| File | Kind | What it paints |
|---|---|---|
views/admin-login.view.php | Document | Locked login (always status 0) |
views/admin.view.php | Document | Mobile drawer (#stDeskMenuBtn), sidebar, error banners, confirm modal, admin.js |
views/admin-desk.view.php | Fragment | Overview counts |
views/admin-articles.view.php | Fragment | Search + list wrap |
views/admin-articles-inner.view.php | AJAX fragment | Table + pager HTML returned in reply.html |
views/admin-article.view.php | Fragment | Editor fo-rm saveArticle (save is rejected) |
views/admin-pages.view.php | Fragment | Static pages list |
views/admin-menu.view.php | Fragment | Menu rows + up/down buttons |
views/admin-media.view.php / admin-media-inner.view.php | Fragment + AJAX | Paginated library |
views/admin-settings.view.php | Fragment | Sitename fo-rm (save is rejected) |
assets/js/admin.js | JS | Forms, lists, modal confirm |
Locked login (this demo)
The handler still uses crcCheck + $request->form(..., 'loginForm', ...). It never calls Auth::login().
Any email/password pair returns status 0. That is intentional.
return ['code' => 200, 'body' => [
'status' => 0,
'locked' => 1,
'message' => 'Unable to sign in.',
]];
Production login
Copy the Users module pattern: Auth::login(['email' => $email, 'password' => $password, 'stage' => 0], $remember),
then redirect to the desk. Protect GET desk routes. Do not ship that gate on this public docs site.
Router::get($pair($p . '/admin/desk'), 'Studio:Admin@desk!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@check!');
public static function check($request)
{
if (!Auth::isLogged()) {
return Response::redirect($prefix . '/admin', 302);
}
if (!Auth::can(['Studio.desk'])) {
return new Response(403, 'Forbidden');
}
}
Write lock: the complete DeskGate.php
Every mutating admin POST is registered with ->before('#Studio:DeskGate@write!').
Returning a Response from a before-hook stops the controller. The article editor is still a real fo-rm so loaders work — Save posts, this gate rejects, the row does not change.
Delete uses a graphical confirm (never alert / confirm). Menu up/down is buttons + load(), not a fo-rm per arrow.
File: app/modules/Studio/Middleware/DeskGate.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Studio\Middleware;
use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Response;
class DeskGate extends \Dotsystems\App\Parts\ModuleMiddleware
{
public static function write($request)
{
$body = DotApp::DotApp()->ajaxReply([
'status' => 0,
'locked' => 1,
'message' => 'This public demo never writes content. Sign-in is disabled, so nobody can insert or change rows here.',
], 200);
return new Response(200, $body);
}
}
Desk chrome
File: app/modules/Studio/views/admin.view.php — document shell (sidebar + fragment slot)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ var: $title }}</title>
<meta name="description" content="{{ var: $metaDescription }}" />
<meta name="robots" content="{{ var: $robots }}" />
<meta name="googlebot" content="{{ var: $robots }}" />
<meta name="googlebot-news" content="noindex" />
<link rel="canonical" href="{{ var: $canonical }}" />
<meta property="og:title" content="{{ var: $title }}" />
<meta property="og:description" content="{{ var: $metaDescription }}" />
<script type="application/ld+json">{{ var: $jsonLd }}</script>
<link rel="stylesheet" href="/assets/modules/Studio/css/studio.css?v={{ var: $assetV }}" />
</head>
<body class="st-body">
<div class="st-lock" role="note"><strong>EXAMPLE</strong> — DotApp PHP Framework 2.0 documentation demo.</div>
<header class="st-desk-bar">
<a class="st-brand" href="{{ var: $deskUrl }}">Lumen desk</a>
<button type="button" class="st-burger" id="stDeskMenuBtn" aria-controls="stSide" aria-expanded="false" aria-label="Open menu"><span></span><span></span><span></span></button>
</header>
<div class="st-scrim" id="stScrim" hidden="hidden"></div>
<div class="st-shell">
<aside class="st-side" id="stSide">
<div class="st-nav-head">
<a class="st-brand" href="{{ var: $deskUrl }}">Lumen desk</a>
<button type="button" class="st-nav-close" id="stDeskMenuClose" aria-label="Close menu">Close</button>
</div>
<nav>
<a href="{{ var: $deskUrl }}" class="{{ if $nav === "desk" }}is-active{{ /if }}">Overview</a>
<a href="{{ var: $articlesUrl }}" class="{{ if $nav === "articles" }}is-active{{ /if }}">Articles</a>
<a href="{{ var: $pagesUrl }}" class="{{ if $nav === "pages" }}is-active{{ /if }}">Pages</a>
<a href="{{ var: $menuUrl }}" class="{{ if $nav === "menu" }}is-active{{ /if }}">Menu</a>
<a href="{{ var: $mediaUrl }}" class="{{ if $nav === "media" }}is-active{{ /if }}">Media</a>
<a href="{{ var: $settingsUrl }}" class="{{ if $nav === "settings" }}is-active{{ /if }}">Settings</a>
</nav>
<p class="st-side-meta"><a href="{{ var: $homeUrl }}">Public site</a><a href="{{ var: $loginUrl }}">Login (locked)</a><a href="{{ var: $docsUrl }}">CMS walkthrough</a></p>
</aside>
<main class="st-main">
<div id="error-message" class="lp-error" hide="hide"></div>
<div id="status" class="lp-status" hide="hide"></div>
{{ var: $bodyHtml }}
</main>
</div>
<div id="stConfirm" class="st-modal" hidden="hidden">
<div class="st-modal-card">
<h2 id="stConfirmTitle">Delete this row?</h2>
<p id="stConfirmText">On a real CMS this would remove the record. On this demo the request is rejected.</p>
<button type="button" class="lp-btn js-st-ok">Delete</button>
<button type="button" class="lp-btn lp-btn-ghost js-st-cancel">Cancel</button>
</div>
</div>
<script src="{{ var: $dotappJs }}"></script>
<script src="/assets/modules/Studio/js/admin.js?v={{ var: $assetV }}"></script>
</body>
</html>
Encrypted ids
Different extra keys per field. Encryption is not authorization — on a real desk still call Auth::can(). These are field names, not secrets.
$enc = Crypto::encrypt((string) $row['id'], 'Studio.article.id');
$id = Crypto::decrypt((string) ($data['id'] ?? ''), 'Studio.article.id');
if ($id === false) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Invalid item.'], 200);
}
Keys used in this module: Studio.article.id, Studio.page.id, Studio.menu.id, Studio.media.id, Studio.topic.slug.
dotapp.js: forms, lists, confirm
Load /assets/dotapp/dotapp.js first. Page logic listens for the dotapp event.
$dotapp is not jQuery. $dotapp().live() calls handler(element, event) — the first argument is the matched node.
(function () {
var runMe = function ($dotapp) {
$dotapp().form("#loginForm").before(function (data, form) {
if ($dotapp(form).attr("blocked") == 1) return $dotapp().halt();
$dotapp(form).attr("blocked", "1");
$dotapp("#loginBtn").attr("loading", "true").attr("loader", "dots");
}).after(function (data, response, form) {
var reply = $dotapp().parseReply(response);
if (reply && reply.message) $dotapp("#error-message").attr("hide", "false").html(reply.message);
$dotapp(form).attr("blocked", "0");
$dotapp("#loginBtn").removeAttr("loading").removeAttr("loader");
});
};
if (window.$dotapp) runMe(window.$dotapp);
else window.addEventListener("dotapp", function () { runMe(window.$dotapp); }, { once: true });
})();
Article and media lists: debounce input, fire search from three characters, overlay .lp_busy while in flight, patch #listInner with reply.html.
Pager buttons are type="button" with data-page. Sticky table header + <mark> on matches are required for lookup lists.
$dotapp().live("click", ".js-st-page", function (el, ev) {
var btn = (el && el.nodeType === 1) ? el : ev.currentTarget;
var page = parseInt(btn.getAttribute("data-page"), 10) || 1;
$dotapp().load(listUrl, "POST", { page: page, q: currentQuery }, function (raw) {
var reply = $dotapp().parseReply(raw);
if (reply && reply.html) $dotapp("#listInner").html(reply.html);
});
});
Files: $dotapp().uploadFile(file, url, progress) — never FormData on load() or fo-rm (CRC cannot wrap a binary).
The public demo does not mount upload so a visitor cannot put files on the server.
Session for a real cart/drafts/filters: DSM::use('Studio'), never $_SESSION.
Production checklist
- Scaffold with DotApper. Tables are
studio_*only. - Public site: slugs, topics,
fo-rmcontact, module CSS,Renderer+ fragments. - Desk:
Auth::isLogged()+Auth::can('Studio.desk')on every GET that is not the login page. - Writes:
crcCheck, unique extra keys,Auth::canagain, then INSERT/UPDATE. - Growing lists:
paginate()+ AJAX pager + search from 3 characters + overlay + empty state + sticky header + highlight. - Row actions: buttons +
load(). One editorfo-rmper screen. Confirm delete in a modal. - Media:
uploadFile, paginated library, encrypted media ids. - Do not invent Blade/Eloquent/jQuery APIs. If it is not in AIRULES, open
app/partsread-only.
Try the live demo
Open the running module. You do not need to scroll back to the top of this page.
Open Lumen Press Open read-only desk
- Open the studio: /documentation/examples/run/studio
- Open a service, read an insight, send the contact form.
- Open administration and submit any password — it fails.
- Open the desk preview, search articles, click Save / Delete / menu arrows — every write is rejected.
Source: app/modules/Studio/. Walkthrough source: these layout files under Docs/views/layouts/pages/examples/studio*.layout.php.