Skip to content

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.

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.

PiecePathRole
Routesmodule.init.phpPublic URLs + /admin/*. Write routes use ->before('#Studio:DeskGate@write!').
Public siteControllers/Site.phpHome, services, insights, article, about, contact.
DeskControllers/Admin.phpLogin (always fails here), dashboard, articles, pages, menu, media, settings.
GateMiddleware/DeskGate.phpDemo: reject every write. Production: require Auth::isLogged() + Auth::can().
CatalogLibraries/Press.phpDemo content in PHP. Production: DB::module('RAW') on studio_*.
SchemaInstallation.phpVersioned tables: articles, pages, topics, menus, media, settings.
Templatesviews/*.view.phpFull HTML documents + fragments for AJAX lists. No Blade, no Twig, no include.
JSassets/js/studio.js, admin.js$dotapp().form and $dotapp().load. Not jQuery.

Live URLs

URLWhat you see
/documentation/examples/run/studioPublic studio home
/documentation/examples/run/studio/article/{slug}One insight
/topicsServices
/insightsInsight index
/adminLogin that always fails
/admin/deskRead-only desk preview
/admin/articlesPaginated 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):

FileYou write
module.init.phpPrefix + every public and desk route
Installation.phpAll studio_* tables (full file in the Schema section)
Libraries/View.phpRenderer helper: document vs fragment
Libraries/Press.phpDemo catalog (production: DB queries)
Libraries/Mark.phpExample titles + TechArticle JSON-LD
Controllers/Site.phpPublic site
Controllers/Admin.phpDesk (login always fails here)
Middleware/DeskGate.phpReject every write on the public demo
views/*.view.phpOne chrome document + inner fragments
assets/css/studio.css, assets/js/*.jsLook 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:

TableWho fills itWho reads it
studio_topicsDesk → Topics (or a seed)Public topic index, article topic_id
studio_articlesDesk → Articles editorHome, article URL, topic listing
studio_pagesDesk → Pages (About, masthead, legal)/about and other static documents
studio_menusDesk → Menu (one row per nav, code = primary)Join to items
studio_menu_itemsDesk → Menu rows (label, href, pos)site.view.php loops this into <nav>
studio_mediaDesk → uploadLibrary list, article images
studio_settingsDesk → SettingsSitename, tagline in the chrome
studio_installationsensureTable() / 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

FileKindWhat it paints
views/site.view.phpDocumentRibbon, logo, dropdown <nav> from $menu, $bodyHtml, footer, studio.js
views/site-home.view.phpFragmentHero, services, featured insight, team
views/site-article.view.phpFragmentOne insight
views/site-topics.view.phpFragmentServices index
views/site-insights.view.phpFragmentInsight index
views/site-topic.view.phpFragmentInsights in one practice
views/site-page.view.phpFragmentAbout / leadership
views/site-contact.view.phpFragmentfo-rm named contactForm
assets/css/studio.cssCSSServed as /assets/modules/Studio/css/studio.css
assets/js/studio.jsJSDrawer menu + contact $dotapp().form
assets/img/ImagesLogo, 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

FileKindWhat it paints
views/admin-login.view.phpDocumentLocked login (always status 0)
views/admin.view.phpDocumentMobile drawer (#stDeskMenuBtn), sidebar, error banners, confirm modal, admin.js
views/admin-desk.view.phpFragmentOverview counts
views/admin-articles.view.phpFragmentSearch + list wrap
views/admin-articles-inner.view.phpAJAX fragmentTable + pager HTML returned in reply.html
views/admin-article.view.phpFragmentEditor fo-rm saveArticle (save is rejected)
views/admin-pages.view.phpFragmentStatic pages list
views/admin-menu.view.phpFragmentMenu rows + up/down buttons
views/admin-media.view.php / admin-media-inner.view.phpFragment + AJAXPaginated library
views/admin-settings.view.phpFragmentSitename fo-rm (save is rejected)
assets/js/admin.jsJSForms, 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-rm contact, 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::can again, then INSERT/UPDATE.
  • Growing lists: paginate() + AJAX pager + search from 3 characters + overlay + empty state + sticky header + highlight.
  • Row actions: buttons + load(). One editor fo-rm per 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/parts read-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

  1. Open the studio: /documentation/examples/run/studio
  2. Open a service, read an insight, send the contact form.
  3. Open administration and submit any password — it fails.
  4. 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.