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.
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
public/ directory — not the project root. The .htaccess routes all requests to index.php.
Request Lifecycle
System\Classes, System, App mapped to directories. PSR-4 on-demand loading.
Security::sessionParams() sets strict session cookie params before session_start().
app/routes/*.php files included. Routes registered in Router.
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();
System\Classes must come before System, otherwise System\Classes\Logger resolves to system/Classes/Logger.php (wrong path on case-sensitive Linux filesystems).
| Namespace | Directory | Example class |
|---|---|---|
System\Classes | system/class/ | System\Classes\Router |
System | system/ | System\Autoloader |
App\Controllers | app/controllers/ | App\Controllers\ClientsController |
App\Modules | — | Loaded 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
| Method | HTTP verb |
|---|---|
Router::get() | GET |
Router::post() | POST |
Router::put() | PUT |
Router::patch() | PATCH |
Router::delete() | DELETE |
Automatic responses
| Situation | Response |
|---|---|
| No route matches URL | 404 — HTML or JSON depending on Accept header |
| Route exists but wrong method | 405 Method Not Allowed |
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
| Value | Behaviour |
|---|---|
$rights = [] | Public — no authentication required |
$rights = ['admin' => ['index']] | Requires login + admin role to call index() |
| Method not listed for user's role | 403 Forbidden |
| User not logged in | Treated as 'guest' role — 403 if guest not in $rights |
Response methods
| Method | Description |
|---|---|
$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
| Method | Returns |
|---|---|
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:
- Form field:
_csrf(use@csrfin templates) - HTTP header:
X-Csrf-Token(for AJAX/API calls)
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
| Method | Returns 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
| Method | Returns 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 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
| Rule | Description |
|---|---|
isNotEmpty | Required field (allows "0") |
isInt | Integer value (no decimals) |
isIntNotZero | Integer greater than zero |
isFloat | Numeric (int or float) |
isString | Unicode alphanumeric + common punctuation |
isEmail | Valid e-mail (FILTER_VALIDATE_EMAIL) |
isDate | Valid Y-m-d date |
isArrayInt | Array of integers |
minLength:N | Minimum N characters (mb_strlen) |
maxLength:N | Maximum 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
| Method | Description |
|---|---|
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 { ... }
}
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
| Directive | Purpose |
|---|---|
{{ $var }} | Escaped output — runs through htmlspecialchars() |
{!! $html !!} | Raw output — use for nested template strings |
@csrf | Inserts hidden <input name="_csrf"> field |
@if(expr) … @endif | Conditional block |
@elseif(expr) / @else | Conditional branches |
@foreach($arr as $item) … @endforeach | Loop |
@for($i=0; …) … @endfor | C-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.php → csp_extra.
All headers set
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | no-referrer |
Strict-Transport-Security | max-age=31536000; includeSubDomains (if enabled) |
Content-Security-Policy | Strict nonce-based policy (see above) |
Permissions-Policy | Disables camera, microphone, geolocation |
Session hardening
| Setting | Value |
|---|---|
session.use_strict_mode | 1 — rejects unrecognised session IDs |
session.use_only_cookies | 1 — no session ID in URL |
Cookie SameSite | Strict |
Cookie HttpOnly | true — JS cannot read session cookie |
Cookie Secure | Configurable via session_secure |
| Session name | sid |
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
| Method | Description |
|---|---|
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
| Option | Default | Description |
|---|---|---|
maxSize | 5 MB | Maximum file size in bytes |
allowedTypes | [] | Allowed MIME types. Empty = all allowed. |
allowedExt | [] | Allowed extensions. Empty = all allowed. |
uploadDir | uploads | Directory 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',
]
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.
| Situation | Web request | API request |
|---|---|---|
| Unhandled exception | Renders app/views/errors/500.tpl | JSON: {"status":false,"error":"500 Internal Server Error"} |
| Debug mode ON | Shows full trace in HTML | Full trace in JSON |
| PHP fatal/parse error | Caught via shutdown handler | Same |
Debug mode
// config/config.app.php
'debug' => true, // development only — never in production!
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]);
}
}
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:
| Step | File to create |
|---|---|
| 1. DB module | app/models/db.invoices.php — class DBInvoices in namespace App\Models |
| 2. Controller | app/controllers/InvoicesController.php — with $rights |
| 3. Routes | app/routes/invoices.php — Router::get/post/… |
| 4. Views | app/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']]);
}