Zum Inhalt springen

CMS Studio

Ein vollständiges CMS-Modul: öffentliches Frontend, Administration, Vorlagen, Schema, AJAX-Listen und dotapp.js. Live-Studio: /documentation/examples/run/studio. Gesperrter Desk: /documentation/examples/run/studio/admin.

Was Sie bauen

Ein CMS ist kein Kontaktformular. Es sind zwei Produkte in einem Modul: eine öffentliche Website zum Lesen und ein Desk, an dem Redakteure den Inhalt ändern. Diese Anleitung baut beides in app/modules/Studio. Die Live-Demo ist das Bratislavaer Software-Studio Lumen Press — Start, Services, Insights, Über uns, Kontakt plus Desk mit Artikeln, Seiten, Menü, Medien und Einstellungen.

In dieser öffentlichen Demo ist kein Login möglich

Das Admin-Login ist ein echtes fo-rm. PHP prüft die Nutzlast und lehnt sie immer ab. Auth::login() wird nie aufgerufen. Jedes Save, Delete, Reorder und Settings-POST stoppt DeskGate@write. Niemand kann hier Inhalt einfügen. Die Desk-Seiten sind eine Nur-Lese-Vorschau, damit Sie die Oberfläche trotzdem sehen.

Nutzen Sie diese Seite als Rezept für ein echtes CMS. Tauschen Sie den PHP-Katalog gegen studio_*-Tabellen, legen Sie den Desk hinter Auth::isLogged() und behalten Sie die Vorlagen.

Titel und JSON-LD

Jede Live-Studio-Seite beginnt den Title mit Example: und liefert JSON-LD als TechArticle + LearningResource. Kein NewsArticle- oder Product-Schema.

Modulkarte

Ein Modul besitzt das ganze CMS. Trennen Sie „Frontend-App“ und „Admin-App“ nicht, solange es nicht wirklich zwei Produkte sind.

TeilPfadRolle
Routenmodule.init.phpÖffentliche URLs + /admin/*. Schreibrouten mit ->before('#Studio:DeskGate@write!').
Öffentliche SiteControllers/Site.phpHome, Artikel, Thema, About, Kontakt.
DeskControllers/Admin.phpLogin (hier immer fehlgeschlagen), Übersicht, Artikel, Seiten, Menü, Medien, Einstellungen.
GateMiddleware/DeskGate.phpDemo: jeden Schreibzugriff ablehnen. Produktion: Auth::isLogged() + Auth::can().
KatalogLibraries/Press.phpDemo-Inhalt in PHP. Produktion: DB::module('RAW') auf studio_*.
SchemaInstallation.phpVersionierte Tabellen: Artikel, Seiten, Themen, Menüs, Medien, Einstellungen.
Vorlagenviews/*.view.phpVollständige HTML-Dokumente + Fragmente für AJAX. Kein Blade, kein Twig, kein include.
JSassets/js/studio.js, admin.js$dotapp().form und $dotapp().load. Nicht jQuery.

Live-URLs

URLWas Sie sehen
/documentation/examples/run/studioStudio-Start
/documentation/examples/run/studio/article/{slug}Ein Insight
/topicsServices
/insightsInsight-Index
/adminLogin, der immer scheitert
/admin/deskNur-Lese-Desk-Vorschau
/admin/articlesPaginierte AJAX-Liste (suchen Sie „cloud“)

Prefix ist Config::module('Studio', 'prefix'), Standard /documentation/examples/run/studio. Auf Ihrer Site: öffentliche Site unter /, Desk unter /admin.

Dateien auf der Platte

Nach DotApper diese Pfade füllen. Die Live-Demo liest Artikel, Seiten, Themen und die Navigation aus Libraries/Press.php. Installation.php ist das Produktionsschema — die vollständige Datei steht weiter unten. In der öffentlichen Demo wird sie nicht ausgeführt.

app/modules/Studio/
  module.init.php          Routen
  Installation.php         studio_*-Tabellen (volle Datei unten)
  Libraries/View.php       Renderer-Helfer
  Libraries/Press.php      Demo-Katalog + menu()
  Libraries/Mark.php       Example-SEO
  Controllers/Site.php     öffentliche Site
  Controllers/Admin.php    Desk
  Middleware/DeskGate.php  Schreibsperre
  views/site.view.php      öffentliches Chrome (Nav looped $menu)
  views/site-*.view.php    öffentliche Fragmente
  views/admin.view.php     Desk-Chrome
  views/admin-*.view.php   Desk-Fragmente
  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: die vollständige Installation.php

Ein CMS ist nicht „eine Artikel-Tabelle“. Die öffentliche Site, der Desk, Menü, Medien und Einstellungen brauchen jeweils eine Tabelle, die das Modul besitzt. Jede Tabelle heißt studio_*. Nie unprefixierte Namen, nie dotapp_* für Studio-Daten. Ein funktionierendes DB::migrate() gibt es nicht. Versioniertes SQL schreiben Sie in Installation.php, das Installer erweitert.

Die öffentliche Demo führt diesen Installer nicht aus. Live-Seiten lesen PHP-Arrays aus Libraries/Press.php, damit ein Besucher kein INSERT machen kann. In Ihrem Projekt kopieren Sie diese Datei und rufen aus initialize() Installation::module('Studio')->install() auf, sobald eine Datenbank konfiguriert ist.

Wofür jede Tabelle da ist — diese Zusammensetzung hat der alte Snippet versteckt:

TabelleWer füllt sieWer liest sie
studio_topicsDesk → Topics (oder ein Seed)Öffentlicher Themenindex, topic_id des Artikels
studio_articlesDesk → Artikel-EditorStart, Artikel-URL, Themenliste
studio_pagesDesk → Pages (About, Impressum, Legal)/about und andere statische Dokumente
studio_menusDesk → Menü (eine Zeile je Navigation, code = primary)Join auf Items
studio_menu_itemsDesk → Menüzeilen (label, href, pos)site.view.php loopt sie in <nav>
studio_mediaDesk → UploadBibliothek, Artikelbilder
studio_settingsDesk → SettingsSitename, Tagline im Chrome
studio_installationsensureTable() / markDone()Idempotenz — Version 1.0.0 einmal

Lesen Sie die Klasse von oben nach unten. installer() liefert ein Array von Versions-Callbacks. alreadyDone('1.0.0') legt bei Bedarf studio_installations an und bricht ab, wenn die Version schon markiert ist. Dann läuft jedes CREATE TABLE in einer Schleife. Nur wenn alle gelingen, läuft markDone('1.0.0') — nicht im Success-Callback der ersten Tabelle. Das würde das Modul nach einer einzigen Tabelle als installiert markieren. uninstaller() droppt in umgekehrter Reihenfolge (Items vor Menüs, Artikel vor Themen).

Datei: app/modules/Studio/Installation.php — diese Datei vollständig kopieren

<?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);
        });
    }
}

Auf einer echten Site aus module.init.php einhängen (nicht in dieser öffentlichen 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()]);
    }
}

Listen, die wachsen können, müssen ->paginate($perPage, $page) plus AJAX-Pager ab Tag eins nutzen. Produktions-Artikelliste (die Demo nutzt stattdessen Press::paginateArticles() im Speicher):

$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);

Produktionsmenü für das öffentliche <nav> — von hier kommen die Links Home / Services / Insights / Company / Contact, sobald Sie Press.php verlassen:

$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: Formulare, Listen, Confirm

Zuerst /assets/dotapp/dotapp.js laden. Seitenlogik lauscht auf das Ereignis dotapp. $dotapp ist nicht jQuery. $dotapp().live() ruft handler(element, event) auf — das erste Argument ist der gefundene Knoten.

(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 });
})();

Artikel- und Medienlisten: Input entprellen, Suche ab drei Zeichen, Overlay .lp_busy während des Flugs, #listInner mit reply.html patchen. Pager-Buttons sind type="button" mit data-page. Sticky-Tabellenkopf + <mark> auf Treffern sind bei Lookup-Listen Pflicht.

$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);
  });
});

Dateien: $dotapp().uploadFile(file, url, progress) — nie FormData auf load() oder fo-rm (CRC kann Binärdaten nicht umhüllen). Die öffentliche Demo mountet keinen Upload, damit Besucher keine Dateien auf den Server legen.

Session für Warenkorb/Entwürfe/Filter: DSM::use('Studio'), nie $_SESSION.

Produktions-Checkliste

  • Mit DotApper scaffolden. Tabellen nur studio_*.
  • Öffentliche Site: Slugs, Themen, fo-rm-Kontakt, Modul-CSS, Renderer + Fragmente.
  • Desk: Auth::isLogged() + Auth::can('Studio.desk') auf jedem GET außer der Login-Seite.
  • Schreibzugriffe: crcCheck, eindeutige Extra-Schlüssel, erneut Auth::can, dann INSERT/UPDATE.
  • Wachende Listen: paginate() + AJAX-Pager + Suche ab 3 Zeichen + Overlay + Leerzustand + Sticky-Header + Highlight.
  • Zeilenaktionen: Buttons + load(). Ein Editor-fo-rm pro Screen. Delete über Modal.
  • Medien: uploadFile, paginierte Bibliothek, verschlüsselte Media-IDs.
  • Keine Blade/Eloquent/jQuery-APIs erfinden. Steht es nicht in AIRULES, app/parts nur lesen.

Live-Demo ausprobieren

Öffnen Sie das laufende Modul. Sie müssen nicht zum Seitenanfang zurückscrollen.

Lumen Press öffnen Nur-Lese-Desk öffnen

  1. Studio öffnen: /documentation/examples/run/studio
  2. Einen Service öffnen, ein Insight lesen, das Kontaktformular senden.
  3. Administration öffnen und ein beliebiges Passwort senden — es scheitert.
  4. Desk-Vorschau öffnen, Artikel suchen, Save / Delete / Menüpfeile klicken — jeder Schreibzugriff wird abgelehnt.

Quelle: app/modules/Studio/. Anleitung: diese Layouts unter Docs/views/layouts/pages/examples/studio*.layout.php.