v1.0 PHP 8.1 · No dependencies · Security-first MVC

BrickAndBricks Framework

A lightweight, security-first PHP 8.1+ MVC framework. No Composer dependencies required for the core. Built for clarity, control, and production use.

PHP 8.1+ MySQL / PDO PSR-4 Autoloader No External Deps

Overview

BrickAndBricks follows a classic MVC pattern: incoming requests are routed to a Controller, which reads input through DataExchange, queries the database through a DataManager module, and renders output via the View template engine.

Router

Maps URL + HTTP method to a controller method. Supports dynamic parameters like {id}.

Controller

Base class for all controllers. Handles auth, role checks, and HTTP responses.

DataManager

Static PDO wrapper. All DB modules extend this. Returns ['status', 'data', 'error'].

DataExchange

Centralizes all HTTP input (GET/POST/JSON). Verifies CSRF, sanitizes, clears superglobals.

View

Blade-lite template engine. Compiles .tpl files to cached PHP. Supports @if, @foreach, {{ }}.

Security

Sets CSP nonce, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy headers.

Directory Structure

/
├── app/
│   ├── controllers/    # YourController.php — extends Controller
│   ├── models/          # db.name.php — DB modules, extend DataManager
│   ├── routes/          # name.php — Router::get/post/... definitions
│   └── views/           # Templates (.tpl) + errors/ (404, 403, 500)
├── config/
│   ├── config.app.php   # debug, cors, redis, hsts, csp_extra
│   └── config.db.php    # host, db, user, pass, port
├── public/           # Document root — point your web server here
│   ├── index.php        # Front controller (bootstrap)
│   └── .htaccess        # Routes all requests to index.php
├── storage/
│   ├── logs/            # app-YYYY-MM-DD.log
│   ├── views/           # Compiled template cache
│   ├── ratelimiter/     # File-based rate limit fallback
│   └── uploads/         # Uploaded files
└── system/           # Framework core — do not modify without reason
    ├── Autoloader.php
    ├── class/           # All core classes (Auth, Router, View, …)
    └── config/
        └── config.roles.php
    
Web server document root Point Apache/Nginx to the public/ directory — not the project root. The .htaccess routes all requests to index.php.

Request Lifecycle

1
.htaccess → public/index.php Front controller bootstraps the entire framework.
2
Autoloader registered Namespaces System\Classes, System, App mapped to directories. PSR-4 on-demand loading.
3
Logger + ErrorHandler booted Global exception and PHP error handlers registered. Logging directory prepared.
4
Session configured + started Security::sessionParams() sets strict session cookie params before session_start().
5
Security headers sent CSP nonce generated, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy set.
6
RateLimiter + View booted Driver auto-detected (Redis → APCu → File). View cache directory prepared.
7
Routes loaded All app/routes/*.php files included. Routes registered in Router.
8
Router::dispatch() URL matched → DataExchange::boot() (CSRF check, input read, superglobals cleared) → Controller instantiated → allowAction() checks auth → method called.

Autoloader

A custom PSR-4 autoloader. No Composer needed for the core framework. Register namespace → directory mappings, then call boot().

// public/index.php — registers all namespaces
require ABSPATH . '/system/Autoloader.php';

Autoloader::register('System\\Classes', ABSPATH . '/system/class');
Autoloader::register('System',          ABSPATH . '/system');
Autoloader::register('App',             ABSPATH . '/app');
Autoloader::boot();
Order matters Register more-specific namespaces before generic ones. System\Classes must come before System, otherwise System\Classes\Logger resolves to system/Classes/Logger.php (wrong path on case-sensitive Linux filesystems).
NamespaceDirectoryExample class
System\Classessystem/class/System\Classes\Router
Systemsystem/System\Autoloader
App\Controllersapp/controllers/App\Controllers\ClientsController
App\ModulesLoaded by name convention, not namespace

config/config.app.php

return [
    // Show full error details (disable in production)
    'debug'           => false,

    // Set false for local HTTP development
    'session_secure'  => true,

    // IPs allowed to use X-Forwarded-For (e.g. load balancers)
    'trusted_proxies' => [],

    // Allowed CORS origins — [] disables CORS, ['*'] = allow all
    'cors_origins'    => [],

    // HSTS max-age in seconds (0 = disabled)
    'hsts_max_age'    => 31536000,

    // Extra CSP directives appended to the default policy
    'csp_extra'       => '',

    // Redis connection (used by RateLimiter)
    'redis'           => [
        'host'     => '127.0.0.1',
        'port'     => 6379,
        'password' => '',
        'timeout'  => 1.0,
    ],
];

config/config.db.php

return [
    'host' => 'localhost',
    'db'   => 'my_database',
    'user' => 'root',
    'pass' => 'secret',
    // 'port' => 3306,  // optional, defaults to 3306
];

The connection uses utf8mb4 charset and FETCH_ASSOC mode by default. A single shared PDO connection is reused across all DB modules in one request.

system/config/config.roles.php

Maps role name strings (used in $rights) to their integer IDs stored in the database.

return [
    'admin'     => 1,
    'moderator' => 2,
    'user'      => 3,
];

To add a role: add the entry here and a matching row in the roles table in your database.

Router

Registers routes and dispatches incoming requests to the correct controller method.

Registering routes

// app/routes/clients.php
use System\Classes\Router;

Router::get   ('/clients',      'ClientsController', 'index');
Router::get   ('/clients/{id}', 'ClientsController', 'show');
Router::post  ('/clients',      'ClientsController', 'store');
Router::put   ('/clients/{id}', 'ClientsController', 'update');
Router::delete('/clients/{id}', 'ClientsController', 'destroy');

URL parameters ({id}, {slug}, etc.) are automatically extracted and passed as arguments to the controller method in order.

Available methods

MethodHTTP verb
Router::get()GET
Router::post()POST
Router::put()PUT
Router::patch()PATCH
Router::delete()DELETE

Automatic responses

SituationResponse
No route matches URL404 — HTML or JSON depending on Accept header
Route exists but wrong method405 Method Not Allowed
CSRF verification timing DataExchange::boot() (which verifies CSRF and reads all input) is called after the URL and HTTP method are matched. Requests to non-existent routes never trigger CSRF or input parsing.

Controller

All controllers extend System\Classes\Controller. The constructor automatically calls allowAction(), which enforces authentication and role-based access.

Anatomy of a controller

namespace App\Controllers;

use System\Classes\Controller;
use System\Classes\DataExchange;

class ArticlesController extends Controller
{
    // Who can call which method
    protected array $rights = [
        'admin' => ['index', 'show', 'store', 'update', 'destroy'],
        'user'  => ['index', 'show'],
    ];

    public function index(): void
    {
        $articles = DBArticles::getAllArticles();
        $this->json($articles['data']);
    }

    public function show(int $id): void
    {
        $result = DBArticles::getArticleById($id);
        if (!$result['data']) { $this->json(['error' => 'Not found'], 404); }
        $this->json($result['data']);
    }
}

$rights — authorization

ValueBehaviour
$rights = []Public — no authentication required
$rights = ['admin' => ['index']]Requires login + admin role to call index()
Method not listed for user's role403 Forbidden
User not logged inTreated as 'guest' role — 403 if guest not in $rights

Response methods

MethodDescription
$this->json($data, $code = 200)Send JSON response
$this->html($view, $data = [], $code = 200)Render .tpl view and send HTML
$this->xml($data, $code = 200, $root = 'response')Send XML response
$this->redirect($url, $code = 302)HTTP redirect
$this->forbidden()403 response (JSON or HTML)

DataExchange

The single point of entry for all HTTP input. Reads GET/POST/JSON bodies, verifies CSRF tokens, sanitizes values, and then clears all superglobals ($_GET, $_POST, $_REQUEST, $_FILES). After DataExchange::boot() runs, input is only accessible through this class.

Reading input

All incoming keys are prefixed with PG to avoid collisions:

// HTML form field name="email" → key is 'PGemail'
$email = DataExchange::getValue('PGemail');
$all   = DataExchange::getValueAll();

// Files
if (DataExchange::hasFile('avatar')) {
    $file = DataExchange::getFile('avatar');
}

Session helpers

DataExchange::getSession('key', 'default');
DataExchange::setSession('key', $value);
DataExchange::destroySession();

HTTP helpers

MethodReturns
getMethod()Current HTTP verb (GET, POST, …)
isGet() / isPost() / isPut() / isDelete()bool
isAjax()true if X-Requested-With: XMLHttpRequest
isApiRequest()true if JSON Accept/Content-Type or AJAX
getClientIp()Client IP — respects X-Forwarded-For for trusted proxies

CSRF protection

Automatically verified for all POST / PUT / PATCH / DELETE requests. Token is expected in:

On mismatch → 419 CSRF Token Mismatch.

DataManager

Abstract base class for all database modules. All methods are static — call directly on your DB class, no instantiation needed. Every public method returns a standardized array:

// Success
['status' => true,  'data' => $rows,   'error' => null]

// Failure
['status' => false, 'data' => null,    'error' => 'error message']

Creating a module

// app/models/db.products.php
class DBProducts extends DataManager
{
    public static function getAllProducts(): array
    {
        return static::select('SELECT * FROM products ORDER BY name');
    }

    public static function getProductById(int $id): array
    {
        return static::selectOne(
            'SELECT * FROM products WHERE id = :id',
            [':id' => $id]
        );
    }
}

Calling a method

$result = DBProducts::getAllProducts();

if ($result['status']) {
    $products = $result['data']; // array of rows
}

Read methods

MethodReturns in data
select($sql, $params)All matching rows (array of arrays)
selectOne($sql, $params)Single row or null
selectValue($sql, $params)Scalar value (COUNT, SUM, …)
selectPaged($sql, $params, $page, $perPage)Rows + total key for pagination
exists($sql, $params)true / false

Write methods

MethodReturns in data
insert($sql, $params)Last insert ID
insertBatch($table, $rows)Row count
update($sql, $params)Affected row count
upsert($table, $data, $updateCols)Last insert ID
delete($sql, $params)Affected row count

Transactions

$result = $db->transaction(function($db) {
    $db->insert('INSERT INTO orders ...', [...]);
    $db->update('UPDATE stock ...', [...]);
    // Any exception here triggers automatic ROLLBACK
});

Parameter binding

Named parameters are bound with bindValue(). You can specify the PDO type explicitly:

$this->select(
    'SELECT * FROM products WHERE active = :active AND price > :min',
    [
        ':active' => [true,    PDO::PARAM_BOOL],
        ':min'    => [10.00,  PDO::PARAM_STR],
    ]
);

If type is omitted, it is inferred automatically (null, bool, int, or string).

selectPaged — pagination-ready queries

$page   = (int) (DataExchange::getValue('PGpage') ?? 1);
$result = $this->selectPaged(
    'SELECT SQL_CALC_FOUND_ROWS * FROM products WHERE active = :a',
    [':a' => 1],
    $page,    // current page (null = no LIMIT, return all)
    20        // per page
);
// $result['data']  → rows for this page
// $result['total'] → total matching rows (for Pagination::make)
SQL_CALC_FOUND_ROWS required The query must include SQL_CALC_FOUND_ROWS right after SELECT for the total count to work.

Validator

Rule-based input validation using pipe-separated rule strings.

$data       = DataExchange::getValueAll();
$validation = Validator::check($data, [
    'PGname'  => 'isNotEmpty|isString|maxLength:100',
    'PGemail' => 'isNotEmpty|isEmail',
    'PGage'   => 'isInt',
]);

if (!$validation['status']) {
    $this->json(['errors' => $validation['errors']], 422);
}

Available rules

RuleDescription
isNotEmptyRequired field (allows "0")
isIntInteger value (no decimals)
isIntNotZeroInteger greater than zero
isFloatNumeric (int or float)
isStringUnicode alphanumeric + common punctuation
isEmailValid e-mail (FILTER_VALIDATE_EMAIL)
isDateValid Y-m-d date
isArrayIntArray of integers
minLength:NMinimum N characters (mb_strlen)
maxLength:NMaximum N characters

Return structure

[
    'status' => false,
    'errors' => [
        'PGname'  => ['Value cannot be empty.'],
        'PGemail' => ['Invalid email address.'],
    ]
]

Auth

Session-based authentication. Uses DBUsers for credential lookup.

Login

$result = Auth::login($email, $password);
// ['status' => true]   — success, session populated
// ['status' => false, 'error' => 'Invalid credentials'] — failure

On success: session_regenerate_id(true) is called before storing user data (session fixation prevention). The password hash is never stored in the session.

Session data structure

$_SESSION['_auth'] = [
    'id'    => 42,
    'email' => 'user@example.com',
    'name'  => 'John Doe',
    'role'  => 'admin',     // role name string
];

Helper methods

MethodDescription
Auth::isLoggedIn()Returns bool
Auth::user()Returns full session array
Auth::user('name')Returns single field
Auth::logout()Destroys session
Auth::check()Enforces login — called automatically by Controller

DBUsers — required table module

// app/models/db.users.php
class DBUsers extends DataManager
{
    // Used by Auth::login() — returns row including password hash
    public static function getUserByEmail(string $email): array { ... }

    // Used by Auth::user() refresh — NO password returned
    public static function getUserById(int $id): array { ... }

    public static function createUser(string $name, string $email, string $password, int $roleId): array { ... }
    public static function updatePassword(int $id, string $newPassword): array { ... }
    public static function deactivateUser(int $id): array { ... }
}
Passwords Always hash passwords with password_hash($pass, PASSWORD_BCRYPT). The DBUsers::createUser() and updatePassword() methods must handle hashing internally — never pass plain-text passwords to the database.

View

Blade-lite template engine. Compiles .tpl files to plain PHP and caches them in storage/views/. Cache is invalidated automatically when the source file changes.

Rendering a template

// From a controller:
$this->html('clients/index.tpl', [
    'clients' => $result['data'],
    'page'    => $pagination,
]);

// As a string (e.g. for emails or nested partials):
$html = View::getTpl('emails/welcome.tpl', ['name' => 'Jan']);

Template directives

DirectivePurpose
{{ $var }}Escaped output — runs through htmlspecialchars()
{!! $html !!}Raw output — use for nested template strings
@csrfInserts hidden <input name="_csrf"> field
@if(expr) … @endifConditional block
@elseif(expr) / @elseConditional branches
@foreach($arr as $item) … @endforeachLoop
@for($i=0; …) … @endforC-style loop
@include('partial.tpl')Include another template (shares parent data)
@include('partial.tpl', ['key' => $val])Include with extra data

Example template

<!DOCTYPE html>
<html>
<head>
    <title>{{ $title ?? 'My App' }}</title>
    <link rel="stylesheet" href="/css/app.css" nonce="{{ $cspNonce }}">
</head>
<body>

@if($flash['success'] ?? false)
    <div class="alert">{{ $flash['success'][0] }}</div>
@endif

@foreach($clients as $client)
    <p>{{ $client['name'] }} — {{ $client['email'] }}</p>
@endforeach

<form method="POST" action="/clients">
    @csrf
    <input name="PGname">
    <button>Add</button>
</form>

@include('partials/footer.tpl')

</body>
</html>

CSP nonce

The $cspNonce variable is shared globally in all templates via View::share() in index.php. Add it to all inline <script> and <style> tags:

<script nonce="{{ $cspNonce }}">
    console.log('Hello');
</script>

Global shared data

// Available in every template automatically (set in index.php):
$user      // Auth::user() or null
$cspNonce  // CSP nonce string
$flash     // Flash::all() array

Security

Handles HTTP security headers and session hardening. Called automatically in index.php.

Default CSP policy

Content-Security-Policy:
  default-src 'none';
  script-src  'nonce-{NONCE}' 'strict-dynamic';
  style-src   'nonce-{NONCE}';
  img-src     'self' data:;
  font-src    'self';
  connect-src 'self';
  frame-ancestors 'none';
  object-src  'none';
  base-uri    'none';

Add extra directives via config.app.phpcsp_extra.

All headers set

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policyno-referrer
Strict-Transport-Securitymax-age=31536000; includeSubDomains (if enabled)
Content-Security-PolicyStrict nonce-based policy (see above)
Permissions-PolicyDisables camera, microphone, geolocation

Session hardening

SettingValue
session.use_strict_mode1 — rejects unrecognised session IDs
session.use_only_cookies1 — no session ID in URL
Cookie SameSiteStrict
Cookie HttpOnlytrue — JS cannot read session cookie
Cookie SecureConfigurable via session_secure
Session namesid

RateLimiter

Auto-detects the best available backend: Redis → APCu → File.

// Typically used in controllers (e.g. login throttling)
$key = 'login:' . DataExchange::getClientIp();

if (RateLimiter::tooManyAttempts($key, 5)) {
    $wait = RateLimiter::availableIn($key);
    // reject — show wait time
}

// On failed attempt:
RateLimiter::hit($key, 300); // 300 sec lock window

// On success — reset counter:
RateLimiter::clear($key);

Methods

MethodDescription
tooManyAttempts($key, $max)true if attempts ≥ max
hit($key, $decaySec = 60)Increment counter, returns new count
attempts($key)Current attempt count
availableIn($key)Seconds until key expires
clear($key)Reset counter
getDriver()'redis', 'apcu', or 'file'

Configure Redis in config/config.app.php under the redis key. File-based storage uses storage/ratelimiter/ with flock for concurrency safety.

Flash

One-time session messages. Set before redirect, read once on the next request.

// Setting messages
Flash::success('Record saved.');
Flash::error('Something went wrong.');
Flash::warning('Check your input.');
Flash::info('You have been logged out.');

// Reading in a template (available as $flash from View::share)
@if($flash['success'] ?? false)
    <div class="alert-success">{{ $flash['success'][0] }}</div>
@endif
@if($flash['error'] ?? false)
    <div class="alert-error">{{ $flash['error'][0] }}</div>
@endif

FileUpload

Validates and stores uploaded files. MIME type is detected via finfo — not trusting the browser-provided Content-Type.

if (DataExchange::hasFile('avatar')) {
    $result = FileUpload::handle('avatar', [
        'maxSize'      => 2 * 1024 * 1024,  // 2 MB
        'allowedTypes' => ['image/jpeg', 'image/png', 'image/webp'],
        'allowedExt'   => ['jpg', 'jpeg', 'png', 'webp'],
        'uploadDir'    => 'uploads/avatars',
    ]);

    if ($result['status']) {
        $path = $result['data']['path'];     // e.g. /storage/uploads/avatars/abc123.jpg
        $mime = $result['data']['mimeType']; // image/jpeg
    }
}

Options

OptionDefaultDescription
maxSize5 MBMaximum file size in bytes
allowedTypes[]Allowed MIME types. Empty = all allowed.
allowedExt[]Allowed extensions. Empty = all allowed.
uploadDiruploadsDirectory under storage/

Return structure (single file)

[
    'filename' => 'a3f9c21b4e.jpg',     // random hex name
    'original' => 'photo.jpg',          // original filename
    'path'     => '/storage/uploads/avatars/a3f9c21b4e.jpg',
    'size'     => 102400,
    'mimeType' => 'image/jpeg',
]
Multiple files If the form field is a multi-file input (name="photos[]"), FileUpload::handle() returns an array of result arrays.

Pagination

$page   = (int) (DataExchange::getValue('PGpage') ?? 1);
$result = DBProducts::getProductsPaged($page);

$pagination = Pagination::make(
    $result['total'],   // total records from selectPaged
    $page,              // current page
    20                  // per page
);

$this->html('products/index.tpl', [
    'products'   => $result['data'],
    'pagination' => $pagination,
]);

Pagination data

[
    'total'       => 148,
    'perPage'     => 20,
    'currentPage' => 3,
    'lastPage'    => 8,
    'from'        => 41,     // first record on this page
    'to'          => 60,     // last record on this page
    'hasPrev'     => true,
    'hasNext'     => true,
    'prevPage'    => 2,
    'nextPage'    => 4,
    'pages'       => [1, '...', 2, 3, 4, '...', 8],
    'pageParam'   => 'page',
    'url'         => '/products',
]

Template usage

@if($pagination['lastPage'] > 1)
<nav class="pagination">
    @foreach($pagination['pages'] as $p)
        @if($p === '...')
            <span>…</span>
        @elseif($p === $pagination['currentPage'])
            <strong>{{ $p }}</strong>
        @else
            <a href="{{ $pagination['url'] }}?page={{ $p }}">{{ $p }}</a>
        @endif
    @endforeach
</nav>
@endif

Logger

File-based logging to storage/logs/app-YYYY-MM-DD.log. Thread-safe via FILE_APPEND | LOCK_EX.

Logger::info('User logged in', ['id' => 42]);
Logger::warning('Slow query detected', ['ms' => 1200]);
Logger::error('Payment gateway timeout', ['order' => 99]);
Logger::debug('Query executed', ['sql' => $sql]);

Log entry format

[2026-03-28 14:05:33] INFO: User logged in {"id":42}
[2026-03-28 14:05:33] ERROR: Payment gateway timeout {"order":99}

ErrorHandler

Global handler for exceptions and PHP errors. Registered automatically in index.php.

SituationWeb requestAPI request
Unhandled exceptionRenders app/views/errors/500.tplJSON: {"status":false,"error":"500 Internal Server Error"}
Debug mode ONShows full trace in HTMLFull trace in JSON
PHP fatal/parse errorCaught via shutdown handlerSame

Debug mode

// config/config.app.php
'debug' => true,  // development only — never in production!
Production Always set debug => false in production. Debug mode exposes file paths, SQL queries, and stack traces.

Building a DB Module

File naming: app/models/db.{name}.php
Class naming: DB{Name} — must extend DataManager

// app/models/db.invoices.php
namespace App\Models;

use System\Classes\DataManager;

class DBInvoices extends DataManager
{
    public static function getAllInvoices(): array
    {
        return static::select('SELECT * FROM invoices ORDER BY created_at DESC');
    }

    public static function getInvoiceById(int $id): array
    {
        return static::selectOne('SELECT * FROM invoices WHERE id = :id', [':id' => $id]);
    }

    public static function createInvoice(string $number, int $clientId, float $total): array
    {
        return static::insert(
            'INSERT INTO invoices (number, client_id, total) VALUES (:n, :c, :t)',
            [':n' => $number, ':c' => $clientId, ':t' => $total]
        );
    }

    public static function deleteInvoice(int $id): array
    {
        return static::delete('DELETE FROM invoices WHERE id = :id', [':id' => $id]);
    }
}
Naming convention Methods should follow the pattern: getAll{Name}s, get{Name}ById, create{Name}, update{Name}, delete{Name}. This makes controllers readable and consistent.

Building a Controller

File: app/controllers/{Name}Controller.php
Class: {Name}Controller in namespace App\Controllers

namespace App\Controllers;

use System\Classes\Controller;
use System\Classes\DataExchange;
use System\Classes\Flash;
use System\Classes\Validator;
use App\Models\DBInvoices;

class InvoicesController extends Controller
{
    protected array $rights = [
        'admin' => ['index', 'show', 'store', 'destroy'],
        'user'  => ['index', 'show'],
    ];

    public function index(): void
    {
        $result = DBInvoices::getAllInvoices();
        $this->html('invoices/index.tpl', ['invoices' => $result['data']]);
    }

    public function store(): void
    {
        $data       = DataExchange::getValueAll();
        $validation = Validator::check($data, [
            'PGnumber'    => 'isNotEmpty|maxLength:50',
            'PGclient_id' => 'isNotEmpty|isInt',
            'PGtotal'     => 'isNotEmpty|isFloat',
        ]);

        if (!$validation['status']) {
            $this->json(['errors' => $validation['errors']], 422);
        }

        $result = DBInvoices::createInvoice(
            $data['PGnumber'],
            (int) $data['PGclient_id'],
            (float) $data['PGtotal']
        );

        if (!$result['status']) {
            $this->json(['error' => $result['error']], 500);
        }

        $this->json(['id' => $result['data']], 201);
    }
}

Defining Routes

Create a file in app/routes/ — it is loaded automatically. Any filename works.

// app/routes/invoices.php
use System\Classes\Router;

Router::get   ('/invoices',           'InvoicesController', 'index');
Router::get   ('/invoices/{id}',      'InvoicesController', 'show');
Router::post  ('/invoices',           'InvoicesController', 'store');
Router::delete('/invoices/{id}',      'InvoicesController', 'destroy');

// Multiple URL params:
Router::get('/clients/{clientId}/invoices/{id}', 'InvoicesController', 'showForClient');
// Controller method: public function showForClient(int $clientId, int $id)

Creating Views

Templates go in app/views/ with a .tpl extension. Organize them in subdirectories by module.

app/views/
├── errors/
│   ├── 404.tpl
│   ├── 403.tpl
│   └── 500.tpl
├── auth/
│   └── login.tpl
├── clients/
│   ├── index.tpl
│   └── show.tpl
└── partials/
    ├── header.tpl
    └── footer.tpl

Including partials

<!-- app/views/clients/index.tpl -->
@include('partials/header.tpl', ['title' => 'Clients'])

<main>
    @foreach($clients as $client)
        <div>{{ $client['name'] }}</div>
    @endforeach
</main>

@include('partials/footer.tpl')

Error views

Create app/views/errors/404.tpl, 403.tpl, 500.tpl. They receive $message and (in debug mode) $trace.

Full CRUD Example

Here is how a complete feature comes together. Checklist for every new module:

StepFile to create
1. DB moduleapp/models/db.invoices.php — class DBInvoices in namespace App\Models
2. Controllerapp/controllers/InvoicesController.php — with $rights
3. Routesapp/routes/invoices.php — Router::get/post/…
4. Viewsapp/views/invoices/index.tpl, show.tpl, etc.

Login Flow

// app/controllers/LoginController.php
class LoginController extends Controller
{
    protected array $rights = [];  // Public

    public function showForm(): void
    {
        if (Auth::isLoggedIn()) { $this->redirect('/dashboard'); }
        $this->html('auth/login.tpl');
    }

    public function login(): void
    {
        $key = 'login:' . DataExchange::getClientIp();

        if (RateLimiter::tooManyAttempts($key, 5)) {
            $wait = RateLimiter::availableIn($key);
            $this->html('auth/login.tpl', ['error' => "Try again in {$wait} seconds."], 429);
        }

        $data   = DataExchange::getValueAll();
        $result = Auth::login($data['PGemail'], $data['PGpassword']);

        if (!$result['status']) {
            RateLimiter::hit($key, 300);
            $this->html('auth/login.tpl', ['error' => $result['error']]);
        }

        RateLimiter::clear($key);
        Flash::success('Welcome, ' . Auth::user('name') . '!');
        $this->redirect('/dashboard');
    }

    public function logout(): void
    {
        Auth::logout();
        Flash::info('You have been logged out.');
        $this->redirect('/login');
    }
}
<!-- app/views/auth/login.tpl -->
<form method="POST" action="/login">
    @csrf
    @if($error ?? false)
        <p class="error">{{ $error }}</p>
    @endif
    <input type="email"    name="PGemail"    value="{{ $email ?? '' }}">
    <input type="password" name="PGpassword">
    <button>Sign in</button>
</form>

File Upload Example

<!-- Template -->
<form method="POST" action="/profile/avatar" enctype="multipart/form-data">
    @csrf
    <input type="file" name="avatar" accept="image/*">
    <button>Upload</button>
</form>
// Controller
public function uploadAvatar(): void
{
    if (!DataExchange::hasFile('avatar')) {
        $this->json(['error' => 'No file provided'], 400);
    }

    $upload = FileUpload::handle('avatar', [
        'maxSize'      => 2097152,
        'allowedTypes' => ['image/jpeg', 'image/png', 'image/webp'],
        'uploadDir'    => 'uploads/avatars',
    ]);

    if (!$upload['status']) {
        $this->json(['error' => $upload['error']], 422);
    }

    // Store $upload['data']['path'] in DB
    DBUsers::updateAvatar(
        Auth::user('id'),
        $upload['data']['path']
    );  // DBUsers::updateAvatar() is a static method

    $this->json(['path' => $upload['data']['path']]);
}