Pramnos Framework Guide¶
Pramnos is a PHP MVC framework designed for building robust web applications with a focus on security, modularity, and clean code architecture. This guide covers the framework's structure, conventions, and best practices.
Overview¶
Core Components¶
The Pramnos framework follows the Model-View-Controller (MVC) pattern with these key components:
- Controllers: Handle HTTP requests and business logic
- Models: Manage data and business rules
- Views: Present data to users (HTML templates)
- Application: Central application management
- Database: Data access layer with security features
Directory Structure¶
src/
├── Controllers/ # Application controllers
├── Models/ # Data models and business logic
├── Views/ # HTML templates and view files
├── Api/ # API controllers and endpoints
│ └── Controllers/ # API-specific controllers
├── OAuth2/ # OAuth2 specific components
│ ├── routes.php # OAuth2 route definitions
│ └── sso_routes.php # SSO route definitions
└── Application.php # Main application class
app/
├── api.php # API application entry point
├── app.php # Main application entry point
├── config/ # Configuration files
├── language/ # Internationalization files
├── Migrations/ # Database migration files
└── themes/ # UI themes and assets
www/
├── index.php # Web application entry point
└── api/ # API endpoint entry points
Controllers¶
Basic Controller Structure¶
<?php
namespace YourNamespace\Controllers;
class ExampleController extends \Pramnos\Application\Controller
{
public function __construct(?\Pramnos\Application\Application $application = null)
{
// Define public actions (no authentication required)
$this->addaction(['public_action', 'another_public']);
// Define authenticated actions (login required)
$this->addAuthAction(['private_action', 'dashboard']);
// Set module name for views and navigation
$this->_modulename = 'Example';
parent::__construct($application);
}
public function display()
{
// Default action when controller is accessed without specific method
return $this->dashboard();
}
public function dashboard()
{
$view = $this->getView('Example');
// Set page title and breadcrumbs
$this->header = 'Dashboard';
$doc = \Pramnos\Framework\Factory::getDocument();
$doc->title = 'My Dashboard';
$this->addbreadcrumb('Dashboard', sURL . 'Example/dashboard');
return $view->display('dashboard');
}
}
Controller Conventions¶
- Class Names: Use PascalCase (e.g.,
UserDashboard,ApiController) - File Names: Match class names (e.g.,
UserDashboard.php) - Namespaces: Use project-specific namespaces (e.g.,
Project\Controllers) - Methods: Use camelCase for action methods
Reading an id out of the URL¶
An action's parameters are not URL segments. The classic dispatcher passes the
request's arguments array to every action, so an action declaring
mixed $id = null receives that array — and (int) of an array is not the id.
Read it from the request instead:
public function view(mixed $id = null): mixed
{
// The route may pass one (an API routing table does); the dispatcher does not.
if (!is_scalar($id) || (int) $id < 1) {
$id = (int) \Pramnos\Http\Request::staticGetOption();
}
// …
}
This has cost real features twice. Capabilities::sync() could not be called
through the classic dispatcher at all — a ?string declaration made it a
TypeError. Organizations::addmember() and removemember() trusted the
argument, so adding a member to an organization was impossible: the form posts
to organizations/addmember/{id}, the id never arrived, and the screen answered
"No valid entries were selected" and redirected to organizations/0/members. Both
actions look correct in isolation and their unit tests passed, because a test that
calls addmember(5) constructs a state no request can produce.
One id per path, and the rest as query parameters. staticGetOption() returns
a single option, and the parser only fills it when the path has exactly one segment
after the action. action/a/b becomes $_GET['a'] = 'b' — so a second id in a
second segment does not arrive as an option or as an argument:
adminUrl('Organizations/removemember/' . $orgId) . '?userid=' . $userId // reads back
adminUrl('Organizations/removemember/' . $orgId . '/' . $userId) // does not
And read the second id from $_GET/$_POST only — never from
staticGetOption(), which is the first segment. Resolving both the same way made
them equal, the update matched no row, and the screen still reported success.
Authentication and Authorization¶
// Public actions (no login required)
$this->addaction(['login', 'register', 'forgotPassword']);
// Authenticated actions (login required)
$this->addAuthAction(['dashboard', 'profile', 'settings']);
// Check if user is authenticated
$currentUser = \Pramnos\User\User::getCurrentUser();
if ($currentUser) {
// User is logged in
$userId = $currentUser->userid;
$username = $currentUser->username;
}
URL Handling and Redirects¶
// Always use sURL constant for URLs (works in subdirectories)
$this->redirect(sURL . 'Controller/action');
// Add breadcrumbs
$this->addbreadcrumb('Home', sURL);
$this->addbreadcrumb('Dashboard', sURL . 'Dashboard/dashboard');
// Set page headers
$this->header = 'Page Title';
$doc = \Pramnos\Framework\Factory::getDocument();
$doc->title = 'Browser Title';
Security: CSRF Protection¶
Pramnos Framework includes built-in CSRF (Cross-Site Request Forgery) protection. It uses a session-stable, random parameter name to prevent malicious form submissions.
1. In your Template/View¶
Use the Session::getTokenField() method to include the protection in your forms:
<form action="<?php echo sURL; ?>User/save" method="POST">
<!-- This generates the hidden CSRF field -->
<?php echo \Pramnos\Http\Session::getInstance()->getTokenField(); ?>
<input type="text" name="username" />
<button type="submit">Save</button>
</form>
2. In your Controller (Option A: Direct Check)¶
Validate the token before processing the POST data:
public function save()
{
$session = \Pramnos\Http\Session::getInstance();
if (!$session->checkToken('POST')) {
$this->addError('Security token invalid or expired. Please try again.');
return $this->redirect(sURL . 'User/profile');
}
// Process form data safely...
}
2. In your Controller (Option B: Validation Rule)¶
You can also use the csrf validation rule for a cleaner approach:
public function save()
{
$request = new \Pramnos\Http\Request();
$session = \Pramnos\Http\Session::getInstance();
$token = $session->getToken();
$data = $request->validate([
$token => 'csrf', // Automated CSRF validation
'name' => 'required|string',
]);
// Continues only if validation (including CSRF) passes
}
3. Manual Token Regeneration¶
For maximum security, you should regenerate the CSRF token after sensitive events like User Login or Logout:
// After successful login
$session = \Pramnos\Http\Session::getInstance();
$session->regenerateToken();
[!TIP] The
Session::reset()method automatically callsregenerateToken(), so resetting your session state for a new user also secures their future forms.[!TIP] The CSRF token is stable per session, meaning multiple tabs can be open simultaneously without breaking the protection.
Security: Content Security Policy (CSP)¶
Pramnos Framework provides built-in support for a Nonce-based Content Security Policy. This approach allows for a highly restrictive script-src and style-src while still permitting legitimate inline content throughout your application.
1. How It Works¶
- Nonce Generation: A unique, cryptographically secure nonce is generated once per request in
Application::exec(). This value is accessible viaApplication::getInstance()->cspNonce. - Header Emission: The
Content-Security-Policyheader is automatically sent to the browser before any content is rendered. - Auto-Injection: The framework's HTML rendering layer (
DocumentTypes\Html) automatically post-processes the output to inject thenonceattribute into all:- Inline
<script>tags (those without asrcattribute). - Internal
<style>tags.
- Inline
2. Configuring CSP Domains¶
While the framework provides secure defaults, you must explicitly whitelist external domains used by your application in app/app.php under the csp key:
// app/app.php
'csp' => [
'script-src' => [
'https://maps.googleapis.com',
'https://cdn.jsdelivr.net'
],
'style-src' => [
'https://fonts.googleapis.com',
'https://cdnjs.cloudflare.com'
],
'img-src' => [
'https://maps.gstatic.com',
'https://*.tile.openstreetmap.org'
],
'font-src' => [
'https://fonts.gstatic.com'
],
'connect-src' => [
'https://maps.googleapis.com'
]
]
3. Apache Configuration¶
When using the framework's built-in CSP, ensure you remove any manual Content-Security-Policy headers from your Apache .htaccess or VirtualHost files to prevent header duplication or conflicts.
Reading the request body¶
$_POST is filled by PHP for POST only. A handler that reads it under DELETE,
PUT or PATCH finds nothing, and nothing about the code says it will. That has
already shipped as three separate bugs in one application — banning worked and
unbanning was impossible, an endpoint worked over POST and failed over DELETE on
the same route, a third accepted JSON and refused the form-encoded body every
other endpoint used. All three passed their unit tests, because a test that seeds
$_POST for a DELETE constructs a state no real request can produce.
Use body(). It answers for whatever method this request actually used:
$request = new \Pramnos\Http\Request();
$fields = $request->body(); // the body, whatever the method
$id = $request->bodyValue('id'); // one field
$reason = $request->bodyValue('reason', ''); // …with a default
| Method | What body() returns |
|---|---|
POST |
$_POST (form-encoded), or the decoded JSON body |
PUT / DELETE / PATCH |
the decoded body — PHP fills nothing for these |
GET / HEAD |
$_GET, because on a GET the query is the input |
| anything else | the decoded body |
Two things it does that allCurrent() does not:
- It reads the method live, from
$_SERVER, rather than using the one captured when the singleton was built. The captured one is right in production and stale anywhere the method is set afterwards — including every test, which is how a fix can pass over HTTP and fail under PHPUnit. - It decodes on demand, so it still works when the object was constructed under a different method.
JSON bodies¶
A JSON body is detected from the content type, or from a body that starts with {
or [ when the header is absent (a hand-written curl and more than one HTTP
client omit it), and is decoded associatively — arrays all the way down. A
shallow (array) json_decode($raw) casts only the top level and leaves every
nested value an stdClass, which makes a handler that iterates a nested list and
checks is_array() reject the whole payload.
A body that declares or looks like JSON and is not valid JSON yields an empty
array, deliberately. It is not handed to parse_str, because
parse_str('{"id":7}', $out) produces ['{"id":7}' => ''] — non-empty, so an
empty() fallback never fires, and nonsense, so nothing reads correctly either.
Request::$putData, $deleteData and $patchData remain public and are what
all('DELETE') and friends return; body() is the accessor that saves you having
to know which one applies.
The raw body, when you need the bytes¶
A signature to verify, a WebAuthn assertion, a JSON manifest — anything where the
exact bytes matter and a decoded array will not do. Read it with
Request::rawBody():
Never file_get_contents('php://input'). It is a stream, and reading it is
not repeatable: for multipart/form-data the second read returns an empty string
under every SAPI, and with enable_post_data_reading off so does the first one
after anything else has read it. So a request whose body had already been read —
by body(), by a middleware, by another handler — reached the second reader as a
request with no body, and the handler answered "malformed or missing payload"
for a payload that was present. rawBody() reads once and keeps the result, so
every reader in a request gets it, in whatever order they ask.
It returns a string, never false: a false from file_get_contents() passes
an if ($raw === '') check for "no payload" and then reaches json_decode(),
which turns a 400 into a 500.
The cache is per request — Request::resetInstance() clears it, so a worker
handling several requests in one process does not answer with the previous body.
In a test, supply a body with Request::setRawInput($body); that is the same
value rawBody() returns, which is what makes a handler's body-reading branch
reachable at all.
Views and Templates¶
View Structure¶
// In controller
$view = $this->getView('ViewName');
$view->data = $someData;
$view->user = $currentUser;
return $view->display('template_name');
Important: View template files must use the .html.php extension, not just .html. This allows for PHP code execution within templates when needed.
Templates in a subdirectory¶
The convention is flat: views/<module>/<template>.<type>.php. That is what the
reference application and all three scaffolded themes use, and nothing needs to be
configured for it.
An application migrating off the legacy view layer will have them one level
down — pramnos_application_view built <path>/tpl/<template>.<type>.php. Declare
it once on a base view rather than moving the files:
It is a declaration, not a search. Trying tpl/ whenever the flat path misses would
put a file_exists() on every render of every project, for ever, to serve a layout
none of them use — and would quietly establish a second convention the framework
then owes support for.
Where the framework looks for an application's views¶
When a view is not found on the controller's own paths, getView() falls back to
the application directory. That path comes from APPS_PATH when it is defined,
and from ROOT/<INCLUDES> otherwise.
The distinction matters for an installation whose applications do not live beside
its framework code: INCLUDES describes where the code is, APPS_PATH describes
where the applications are. In a stock layout they are the same directory. When
they are not, a fallback built from the wrong one searches somewhere that does not
exist — and finds nothing, which is indistinguishable from a view that is genuinely
absent.
INCLUDES itself defaults to src.
Template Files¶
Templates are stored in /src/Views/ViewName/template_name.html.php:
<div>
<h1>{{header}}</h1>
<!-- Use sURL for all links -->
<a href="<?php echo sURL;?>Controller/action">Link Text</a>
<!-- Display data -->
<p>Welcome, <?php echo $this->user->username;?>!</p>
</div>
When a template is not found¶
getTpl() returns false, logs "Cannot find view template" and leaves the view's
output alone — a missing template is a failure to report, not a page to abandon.
One thing happens before that, and it is worth knowing because it looks like magic
otherwise: if the request carries ?format=json and the view has a model with a
getJsonList() method, the model answers instead. That is how a view can serve a
DataTables-style JSON endpoint at the same route as its HTML page without a template for
it.
class Posts extends \Pramnos\Application\Model
{
public function getJsonList(): string { /* … */ }
}
The check is is_object($this->model) && method_exists(...). It used to be
isset($this->model), which is why this section exists: $model is declared
public $model = false, and isset() answers not null rather than not empty, so the
guard passed for every view that has no model at all — and method_exists(false, …) is a
TypeError on PHP 8. The branch that exists to recover from a missing template was
taking the page down instead, on any request with ?format=json. Reported from a
consuming application's home page.
Layouts and partials¶
A template can declare a layout and fill named sections of it:
<?php $this->layout('layouts/main'); ?>
<?php $this->section('content'); ?>
<h1><?= $this->e($this->title) ?></h1>
<?php $this->endsection(); ?>
<!-- src/Views/layouts/main.html.php -->
<!doctype html>
<html><head><title><?= $this->e($this->title) ?></title></head>
<body><?= $this->yield('content') ?></body></html>
$this->insert('partials/card', ['item' => $item]) renders a partial with extra
variables.
Where the framework looks, in order:
- the name as an absolute path;
src/Views/<ViewName>/— the view's own directory, so a per-view override wins;ROOT/views/;src/Views/— beside the view directories, where something shared by several of them belongs;- a theme override, if the active theme allows them.
Both .html.php and .tpl.php are tried at each step.
Debug output. In debug mode an HTML view appends a comment naming the template it
rendered and when. It is emitted only when Application::isDebugMode() says so — the
APP_DEBUG environment variable, the DEVELOPMENT constant, or the debug setting. It
used to be unconditional, which put the application's file layout into the source of
every public page.
Step 4 was missing until 2026-08-16, which made a shared layout the one thing that
could not be found — and a declared layout that does not resolve renders the child
alone: a page returned with 200, no <head>, and, until the same change, nothing
in any log. It presents as a stylesheet that failed to load. If a page comes back
looking unstyled and structurally bare, check the log for Layout not found: before
looking at anything else.
Routing¶
URL Patterns¶
- Controllers:
/ControllerName/action
Application Configuration¶
Application Class¶
<?php
namespace YourNamespace;
class Application extends \Pramnos\Application\Application
{
public function __construct()
{
parent::__construct();
// Set application-specific configuration
$this->setConfig('app_name', 'Your App Name');
$this->setConfig('version', '1.0.0');
}
public function exec($query = '')
{
// Custom application logic before execution
// Call parent execution
parent::exec($query);
}
}
Configuration Files¶
Configuration is typically stored in /app/config/settings.php:
<?php
return [
'database' => [
'host' => 'localhost',
'username' => 'dbuser',
'password' => 'dbpass',
'database' => 'dbname'
],
'app' => [
'name' => 'Your Application',
'version' => '1.0.0',
'timezone' => 'UTC'
],
'security' => [
'session_timeout' => 3600,
'password_hash_algo' => PASSWORD_DEFAULT
]
];
Declining the automatic session¶
Application::init() starts a session on every request. That is the right default
for an application whose pages are mostly signed-in, and the wrong one for a site
serving anonymous traffic: every response carries Set-Cookie: PHPSESSID, including
a page render for a visitor who will never read or write a thing.
That is what stops the page cache from ever storing anything.
PageCache refuses to store a response that sets a
cookie — correctly, because such a response is per-visitor in its body too. So the
two features are mutually exclusive until you say otherwise:
Check what you are actually sending:
Reading and writing a session value¶
$session = \Pramnos\Http\Session::getInstance();
$session->set('checkout_step', 2);
$session->get('checkout_step'); // 2, or null when absent
$session->has('checkout_step'); // true — and true for a stored null
$session->remove('checkout_step');
set() calls ensureStarted() first, because in lazy mode a write can be the first thing
that needs a session at all — without it the value would be assigned to an array that is never
persisted and read back as null on the next request. has() and remove() deliberately start
nothing: asking whether something is stored must not hand a session cookie to a visitor with no
state, and there is nothing to remove from a session that does not exist.
has() is not get($key) !== null. It distinguishes a stored null from an absent key, which
is the question a caller actually has when deciding whether to compute a value again.
set()was missing until 2026-08-28.get()existed, so callers wrote$session->set(...)and gotCall to undefined method— a fatal on whichever request reached that line, not an error anything saw earlier. One was found in production on a path a full-page cache had been hiding behind cache misses.
What "lazy" means, and what it does not¶
It means do not create a session for a visitor who has none. A request arriving with a session cookie still starts one at exactly the point it always did.
That distinction is the whole reason the mode is usable. Around two hundred places
in the framework read $_SESSION directly — Session::staticIsLogged() above all —
and "never start one" would report every signed-in visitor as anonymous until
something happened to call a token helper.
| Request | Eager (default) | Lazy |
|---|---|---|
| Anonymous, no cookie | session started, Set-Cookie sent |
no session, no cookie, cacheable |
| Carrying a session cookie | session started | session started |
| Signs in during the request | session started | started by the login path |
It also changes the cache headers you send¶
session_start() does more than set a cookie. PHP's session.cache_limiter defaults
to nocache, so starting a session queues three response headers:
Pragma: no-cache
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Nothing in this framework asks for those, and nothing removes them. So in lazy mode an anonymous visitor gets none of them and an eager one gets all three — which means which cache headers a response carries depends on whether a session happened to start, not on any decision about the page.
It matters most on a page-cache hit, where the page did not come from the application
at all and the headers are left over from init(). That is safe as it stands — telling
a browser not to store a shared copy is right, and it is what stops an anonymous page
being handed back after sign-in — but it is not a decision. Say what you mean with
cacheControl, which also
clears the leftover Pragma and Expires.
If your application writes to $_SESSION¶
Call ensureStarted() first, on any request that may not have a session yet:
It is idempotent and costs one session_status() check when a session already
exists.
A write without it is lost silently
PHP will happily let you write to $_SESSION when no session has been started.
The value goes into a plain array and disappears at the end of the request —
no error, no warning. This is why the mode is opt-in rather than the
default.
The framework's own write paths already call it: signing in, the pending two-factor
step, passkey challenges, validation errors and old input, flash messages and
errors, and ?lang=.
Declining session tracking¶
SessionTrackingMiddleware records visitors in the sessions table. It runs
automatically unless the application names it in middleware — so, until this
key existed, the way to switch it off was to declare it and then arrange not to run
it, in two places, each needing a comment explaining the other.
Omission is not refusal, and one application learned that the expensive way: its config carried the comment "session tracking is deliberately NOT wired" while the middleware had been running the whole time — two cookies and a database upsert on every request, crawler hits included — and a passing test guarded the claim while the behaviour was the opposite.
Accepts the spellings a config file actually contains: false, 0, '0',
'false', 'no', 'off' and '' all decline. true, 1, 'yes', 'on' leave
it on, as does omitting the key.
What the row is, and the two things that keep it cheap¶
One row per visitor, keyed on visitorid — not one per request. The table is a live-visitor list
rather than a record, so it is written by an upsert and swept rather than accumulated.
The upsert is hand-written per dialect: ON DUPLICATE KEY UPDATE on MySQL and
ON CONFLICT (visitorid) DO UPDATE … RETURNING logout on PostgreSQL. The RETURNING is a feature, not
a flourish — the upsert deliberately does not touch logout, and reads back what it was. That flag
is how «an administrator ended this session» reaches the visitor: somebody else sets it in the table,
and the visitor's next request finds it and is signed out. An upsert that wrote every column, logout
included, would clear the instruction before it was ever read, and the eject button would silently do
nothing.
Two settings keep the cost down, and both trade promptness for writes:
session_write_interval(60s) — nothing is written twice in the same minute unless the visitor navigated somewhere new. A page that loads and then calls its own API would otherwise write the row twice a second apart, and a page making ten XHR calls ten times. Only a navigation records a URL: an XHR is the page talking to the server, and because it runs after the page that made it, recording its URL would leave the row permanently showing/users/datafor somebody looking at/users.session_gc_divisor(100) — one request in N sweeps rows older than five minutes, rather than every request issuing theDELETE. Rows are allowed to be five minutes stale, so how promptly they go does not matter to anything that reads them.
The cost of both is that a visitor drops off the list up to a minute late, and a forced logout takes up to a minute to be noticed. Neither is a promise anything makes.
The two inference rules still work and are still checked, after this one: naming the
middleware in middleware, or registering the deprecated Addon\System\Session.
An explicit answer is never overruled by a guess about one.
Is anything reading the table?
Session tracking earns its cost when something reads sessions — an active
devices screen, an admin presence view. If your application answers those from
somewhere else, this is two cookies and one write per request for a table with
no reader.
Flash messages and errors¶
A controller tells the user what happened by flashing a sentence and redirecting. The message survives exactly one request: it is shown on the page the redirect lands on, and not again on a reload.
// In a controller
$this->addMessage('Saved.');
$this->addError('That record no longer exists.');
$this->redirect(sURL . 'organizations');
// In a view or layout
foreach ($this->messages as $message) {
echo '<div class="alert alert-success">'
. htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</div>';
}
foreach ($this->flashErrors as $error) {
echo '<div class="alert alert-danger">'
. htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</div>';
}
Outside a view, read them from the request: $request->messages() and
$request->flashErrors().
Do not pass messages as query parameters
redirect(sURL . 'things?error=not_found') is the shape to avoid. The text ends up in the
URL, so it is shown again on every reload, it stays in browser history, it is in whatever
the user pastes when asking for help, and it is user-controlled input arriving at a page
that displays it.
The framework itself did this in sixty-seven places until 2026-08-17. All are converted.
If your application reads the flash through Base
All four members keep working, and they are a supported way to do it — they are protected
on Base, so every controller, model and theme inherits them:
| Member | Consumes the flash? | Typical use |
|---|---|---|
hasErrors() / hasMessages() |
no | the gate: if ($this->hasErrors()) |
_printErrors() / _printMessages() |
yes | renders <span>s |
_getErrors() / _getMessages() |
yes | the raw array, e.g. for a JSON response |
All four now consult the same per-request capture, because View::__construct() drains
$_SESSION into it. Without that, a gate answers false for a flash that arrived perfectly
well and the printer behind it is never reached — which is silent: nothing fails, the page
simply says nothing. That happened to a reference application, in its theme header and five
views, and took three real HTTP requests against two framework versions to find. Neither its
5497-test suite nor this framework's saw it.
The gates are non-destructive on purpose: a gate that consumed would leave the printer with nothing, which is the same silence by the opposite route.
One residual: a View snapshots the bags when it is constructed, so an application that
prints the flash both in its theme header and in a template will print it twice. Which
one prints is the application's call — the framework cannot make that choice without
breaking the other.
Three things to know about the mechanism¶
$this->messagesis not$this->errors.errorsis the per-field output of a validator (['email' => ['Not an email address.']]);messagesandflashErrorsare whole sentences. A template that iterates one expecting the other gets field names where it wanted text.- Reading consumes. The session entry is cleared by the first read of the request, and the values stay available for the rest of it — so a controller and a template can both read them without one silently eating the other's.
- It needs a session. With none,
addMessage()keeps the value on the object for the current request only, which is what a CLI or API context wants anyway. - A second request in the same process needs
Request::resetInstance(). The capture is per request, so serving two without resetting hands the second the first one's — already consumed — bag. One process, one request never sees this; a worker, a daemon or a test making two requests does, and what it sees is a flash mechanism that worked once and then went silent.resetInstance()clears the capture along with the rest of the derived state.
Checking for errors in the same request¶
Exception Handling¶
try {
// Risky operation
$result = $this->performOperation();
} catch (\Exception $e) {
$this->addError('Operation failed: ' . $e->getMessage());
error_log($e->getMessage());
return $this->showErrorPage();
}
Framework Factory Classes¶
Common Factory Usage¶
// Get authentication handler
$auth = \Pramnos\Framework\Factory::getAuth();
// Get document handler
$doc = \Pramnos\Framework\Factory::getDocument();
// Get current user
$user = \Pramnos\User\User::getCurrentUser();
// Get database instance (alternative method)
$database = \Pramnos\Database\Database::getInstance();
getInstance() builds; currentInstance() only looks¶
Application::getInstance() is a factory. With no instance for the key it
reads app.php, defines constants and runs the whole constructor — setting up
the database, the language and the session. That is what you want when you want
an application.
It is emphatically not what you want when you only need to read a setting:
// Wrong in low-level code — may construct an entire application
$proxies = Application::getInstance()->applicationInfo['trusted_proxies'] ?? [];
// Right — null when there is none, and nothing is built
$app = Application::currentInstance();
$proxies = $app?->applicationInfo['trusted_proxies'] ?? [];
Use currentInstance() anywhere that runs before the application is ready:
CSRF verification, middleware that runs early in the pipeline, and anything on
the database connection path. The last one is not hypothetical — reading the
application name while a connection was being opened built an application, which
set up Settings, which queried the database through the very connection still
being established. ConnectionPathPurityTest now fails the build if that
returns.
Four directories are guarded the same way. Nothing under src/Pramnos/Auth/,
src/Pramnos/Http/Middleware/, src/Pramnos/User/ or src/Pramnos/Database/ may
call Application::getInstance(); ApplicationFactoryPurityTest reads the source
and fails the build if it does, with an enumerated exemption list that is
currently empty. ConnectionPathPurityTest guards the connection path in
particular; this is the wider rule that path is one instance of.
That guard exists because this section did not prevent the mistake. Nine call sites were still using the factory, and the placements say why the rule matters:
- inside a token-minting method, in
SessionExchange; - inside
User::getCurrentUser()— asking who is signed in constructed an entire application, database and session included; - inside
Database::displayError(), where building an application buildsSettings, which queries the database that just failed. That also made the method's ownerror_log()fallback unreachable, so a database error outside a request went nowhere at all; - and in four middleware and driver methods that wanted to write one property or read two booleans.
Every one of them was written as if ($app) or if (!is_object($app)) — a guard
for a null the factory cannot return. So the guard was dead and the construction
was live, and the source had been saying so all along.
One practical consequence when you convert a call site: currentInstance()
declares ?Application while getInstance() declares nothing, so any test that
installs a plain stdClass in the registry as a fake application starts failing
with a TypeError. That is the correct type being enforced — the registry is
meant to hold applications — but it is the first thing you will see. Use a real
subclass with an empty constructor.
If you genuinely need to build an application from guarded code, add the file to
ApplicationFactoryPurityTest::EXEMPT with the reason. Having to write the reason
down is the point.
Outside the guarded directories: audited, and twelve calls kept¶
The remaining getInstance() calls in src/ were examined one by one rather than
left as an unknown. Eleven were converted and one deleted:
- the CSP-nonce reads in the debug panel and in the
HtmlandRawrenderers, where rendering already happens inside a request and theif ($app && …)guard was already written for a null the factory could not return; Broadcastable::resolveBroadcastingManager(), where building an application in order to ask whether broadcasting is configured was a side effect inside atrywhosecatchreports "not configured";RouteList's third fallback strategy, which asks whether a global instance exists;TestClient, whoseif ($appInstance === null)fallback was unreachable and carried a coverage-ignore saying so — it is now live;- five generated templates the scaffolder writes into every project, now
currentInstance()?->…; - and a line in the session-cleanup addon that assigned
$appand never read it, so an entire application was constructed for a variable nothing used. Deleted.
Twelve are kept deliberately, and they fall into two groups:
- Console bootstraps —
Console\Application,TimescaleDrain,TimescaleEnsure,PolicyEngine,BroadcastServe,BaseTestCase, and the two bootstrap scripts the scaffolder generates. Building an application is what these are for; the factory is the correct call. - Constructor fallbacks in
ControllerandTheme:__construct($application = null)resolving the current one when none was passed. In a real request both calls answer identically, andcurrentInstance()would put null into$this->applicationfor the standalone case — which every unit test that builds a controller by hand relies on not happening. The blast radius is every controller and every theme, for no gain in production.
The guard covers the directories where the factory is a hazard rather than a choice. These twelve are choices.
This guide provides a comprehensive overview of the Pramnos framework structure and conventions. Use it as a reference for building consistent, secure, and maintainable applications within the Pramnos ecosystem.
Middleware Pipeline¶
A lightweight, composable middleware pipeline (PSR-15-inspired) for applying cross-cutting concerns — authentication, rate limiting, CORS, maintenance mode — without modifying controllers.
Route Middleware¶
use Pramnos\Http\Middleware\AuthMiddleware;
use Pramnos\Http\Middleware\ThrottleMiddleware;
$router->get('/dashboard', [DashboardController::class, 'index'])
->middleware(new AuthMiddleware());
$router->post('/api/export', fn() => exportData())
->middleware(
new AuthMiddleware(),
new ThrottleMiddleware(maxRequests: 5, perSeconds: 60)
);
Global Middleware (ServiceProvider::boot())¶
public function boot(): void
{
$router = $this->app->getRouter();
$router->addGlobalMiddleware(new MaintenanceModeMiddleware());
$router->addGlobalMiddleware(new CorsMiddleware(
allowedOrigins: ['https://app.example.com'],
allowCredentials: true
));
}
Controller Middleware¶
class ApiController extends \Pramnos\Application\Controller
{
public function __construct()
{
$this->addMiddleware('*', new AuthMiddleware());
$this->addMiddleware('export', new ThrottleMiddleware(5, 60));
parent::__construct();
}
}
Writing Your Own Middleware¶
Implement Pramnos\Http\MiddlewareInterface:
use Pramnos\Http\MiddlewareInterface;
use Pramnos\Http\Request;
class JsonOnlyMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): mixed
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (!str_contains($contentType, 'application/json')) {
throw new \Exception('This endpoint only accepts JSON.', 415);
}
return $next($request);
}
}
Using the Pipeline Standalone¶
use Pramnos\Http\MiddlewarePipeline;
$result = (new MiddlewarePipeline())
->pipe(new LoggingMiddleware())
->pipe(new AuthMiddleware())
->pipe(new ThrottleMiddleware(60, 60))
->run($request, fn($req) => $controller->myAction());
Built-in Middleware¶
| Class | Description |
|---|---|
AuthMiddleware |
Throws 401 or redirects if not logged in |
CorsMiddleware |
Sets Access-Control-* headers; handles OPTIONS preflight |
ThrottleMiddleware |
Rate-limits by IP using APCu (requires apcu extension) |
MaintenanceModeMiddleware |
Returns 503 while var/MAINTENANCE or maintenance.flag exists |
CsrfMiddleware |
Validates CSRF token on POST/PUT/PATCH/DELETE |
Maintenance mode: which flag, and what the client is told¶
There are two ways the site goes down on purpose, and until 2026-08-15 they watched different files:
| Raised by | Flag |
|---|---|
Application::startMaintenance() |
<ROOT>/var/MAINTENANCE |
MigrationRunner (for the duration of a batch) |
<ROOT>/var/MAINTENANCE |
A deployment script doing touch |
whatever it touches — historically <ROOT>/maintenance.flag |
MaintenanceModeMiddleware watched only the last one. So an application that
registered it exactly as shown above and then ran a migration served the whole
migration from the live site — the runner raised its flag, the middleware watched
the other, and nothing looked wrong. With no constructor argument it now watches both.
Pass a path explicitly and that path is the only one watched, because an application
that named its own file has said which file it means.
Retry-After is an hour by default in the middleware, five minutes from
Application::showError(). Define PRAMNOS_MAINTENANCE_RETRY_AFTER to set both:
What the client gets. Application::showError() — which the constructor calls when
var/MAINTENANCE exists, so a router-dispatched application reaches it too — now sends
a status and a content type:
| Situation | Status | Body |
|---|---|---|
| Maintenance, browser | 503 + Retry-After |
the HTML page, unchanged |
Maintenance, Accept: application/json |
503 + Retry-After |
{"error":"maintenance","retry_after":300,…} |
| Any other fatal (PHP version, addon, database) | 500 |
as above, "error":"unavailable", no retry |
It previously sent neither, which produced two failures that look unrelated and
are one bug: a JSON client got 200 OK with a page of HTML and failed on parsing
rather than recognising the state, and a crawler got the maintenance page as a 200,
making it eligible to be indexed in place of the real page.
A client is treated as wanting JSON when its Accept names application/json or it
sends X-Requested-With: XMLHttpRequest. Browsers send neither, so no list of API
paths has to be kept in step with the router.
Execution order:
Global middleware (registration order)
└─ Route-specific middleware (registration order)
└─ Permission check (unchanged)
└─ Action method
Response Object¶
Pramnos\Http\Response — an immutable-style fluent builder for HTTP responses.
use Pramnos\Http\Response;
// Simple HTML response
return Response::make('<p>Hello</p>')->send();
// JSON API response
return Response::json(['user' => $user])->send();
// Redirect
return Response::redirect('/dashboard', 302)->send();
// Custom status + headers
return Response::make('Created', 201)
->withHeader('Location', '/api/users/42')
->withHeader('X-Request-Id', $requestId)
->send();
Every mutator returns a new cloned instance — safe to share and branch:
$base = Response::json([])->withHeader('X-Api-Version', '2');
$ok = $base->withBody(json_encode(['ok' => true]))->withStatus(200);
$error = $base->withBody(json_encode(['error' => 'Not found']))->withStatus(404);
In Middleware¶
class AddSecurityHeadersMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): mixed
{
$response = $next($request);
if ($response instanceof Response) {
return $response
->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('X-Frame-Options', 'DENY');
}
return $response;
}
}
API Reference¶
Static factories:
- Response::make(string $body = '', int $status = 200): static
- Response::json(mixed $data, int $status = 200, int $flags = 0): static
- Response::redirect(string $url, int $status = 302): static
Fluent mutators (return new instance):
- withStatus(int $code): static
- withHeader(string $name, string $value): static
- withRawHeader(string $name, string $value): static
- withoutHeader(string $name): static
- withBody(string $body): static
Accessors:
- getStatusCode(): int, getBody(): string, getHeader(string $name): array
- hasHeader(string $name): bool, getHeaders(): array
Emission:
- send(): static — emits status code, headers, and body.
Exception Handler¶
Pramnos\Http\ExceptionHandler — centralises exception rendering and logging.
use Pramnos\Http\ExceptionHandler;
// Inside a catch block
ExceptionHandler::log($exception);
ExceptionHandler::render($exception, 'html', false)->send();
exit();
// Auto-detect format (JSON vs HTML)
$format = $doc->getType() === 'json' ? 'json' : 'html';
$debug = defined('DEVELOPMENT') && DEVELOPMENT === true;
ExceptionHandler::log($exception);
ExceptionHandler::render($exception, $format, $debug)->send();
// Global handler for early-bootstrap / CLI
set_exception_handler(function (\Throwable $e) {
ExceptionHandler::log($e);
ExceptionHandler::render($e, ExceptionHandler::detectFormat())->send();
exit(1);
});
Output Formats¶
| Scenario | Format | Debug | Output |
|---|---|---|---|
| HTML app — production | html |
false |
Friendly error page |
| HTML app — development | html |
true |
Full stack trace (HTML-escaped) |
| JSON API — production | json |
false |
{"error": "msg", "code": 422} |
| JSON API — development | json |
true |
+ "exception", "file", "line", "trace" array |
HTTP status mapping: getCode() is used when in 400–599 range; everything else maps to 500.
API Reference¶
| Method | Description |
|---|---|
ExceptionHandler::render(\Throwable $e, string $format = 'html', bool $debug = false): Response |
Build a Response for the exception |
ExceptionHandler::log(\Throwable $e, string $logFile = 'pramnosframework'): void |
Write full exception detail to error log |
ExceptionHandler::detectFormat(): string |
Returns 'json' or 'html' based on HTTP_ACCEPT |
Shortening text¶
Pramnos\General\StringHelper::excerpt() cuts text to a length without splitting a
word:
use Pramnos\General\StringHelper;
StringHelper::excerpt($post->body, 120); // 'The quick brown fox…'
StringHelper::excerpt($post->body, 120, ' [more]');
Three things it guarantees, each of them a bug it was written to remove:
- At most
$lengthcharacters, ellipsis included. Size a column or a meta description with it and the number holds. - HTML is stripped before measuring, so the length is a length of visible text — an
excerpt of markup gives prose, not an unclosed
<span class="…">. - A word longer than the limit is cut, not lost.
Καθηγητήςμαθηματικώνat 10 givesΚαθηγητής…. It used to give the ellipsis on its own —mb_strrpos()finds no space, returnsfalse,mb_substr()reads that as 0 — so one long word came back looking like missing text. A Greek compound, a name with no space, a URL and a hashtag are all that shape, and a listing page is where you find out.
null is an empty string, not a TypeError. A negative length is an
InvalidArgumentException.
Helpers::shortenText() is a deprecated alias¶
It forwards, so there is one implementation. Two behaviours changed on the way and both are visible to existing callers:
| before | now | |
|---|---|---|
| result length | could exceed $length — the suffix was appended after cutting to it |
never exceeds $length |
| one long word | the suffix alone | the word, hard-cut |
The default suffix is the character … rather than the entity …. It renders the
same in HTML, and it is correct in the places the entity was wrong — a plain-text email, a
JSON field, or anything that escapes the result and turned … into a visible
&hellip;. It also has to be one character, because the length now includes it, and
charging eight for an ellipsis leaves almost nothing of a short excerpt. A suffix you pass
yourself is used and counted literally.
$charset is ignored; excerpt() uses the internal encoding, which this framework sets
to UTF-8.
Not to be confused with CommandBase::truncateText()¶
The console has its own, and it is not a duplicate — it measures visible width,
ignoring ANSI escape codes, and it does split words. That is right for a table column
in a terminal and wrong for prose. Use excerpt() for anything a person reads as a
sentence.
symfony/string is installed too, and its truncate($length, $ellipsis, cut: false)
guarantees the opposite of excerpt(): it extends to the next word boundary, so a
limit of 5 on The quick brown fox returns ten characters, and a single long word comes
back whole and unmarked. Useful when you want at least $length; not when the bound is
the point.
Reading a user agent¶
Pramnos\General\Helpers::getBrowser($agent) returns the browser, its version, the
operating system and its version, the rendering engine — and which engine worked it
out:
$b = \Pramnos\General\Helpers::getBrowser($_SERVER['HTTP_USER_AGENT'] ?? '');
$b->browser; // 'Chrome'
$b->version; // '120.0'
$b->majorver; // '120'
$b->platform; // 'Windows' — the operating system
$b->os_number; // '10' — its version
$b->engine; // 'Blink'
$b->detector; // 'device-detector' | 'browscap' | 'sniff'
Install the parser, or get a name and nothing else¶
Three engines are tried in order, and only the first fills every field:
detector |
Needs | Fills |
|---|---|---|
device-detector |
composer require matomo/device-detector |
everything above |
browscap |
PHP's browscap ini pointing at a browscap.ini |
all but os_number |
sniff |
nothing | browser only |
sniff is what an installation gets by default, and it is a six-branch regex that
returns a lowercase name — version, platform, majorver, os_number and engine
come back empty. The method has always returned a valid object either way, which is what
made this hard to notice: one consuming application measured 3,040 visits with a browser
name and 771 with an operating system, and nothing had gone wrong as far as any code
could tell.
matomo/device-detector is a suggest, not a requirement — a framework should not put
a user-agent parser into every project that installs it. Add it when you store or report
any of these fields:
Its regexes ship inside the package, so unlike browscap/browscap-php there is no data
file to provision and no monthly refresh: staleness becomes composer update rather than
a cron job, and there is no way for a missing download to degrade it silently.
What detector is for¶
An empty version used to mean either this agent is not identifiable or there was no
parser running, and those call for opposite responses — the first is a fact about the
visitor, the second is a missing package. detector is how a caller tells them apart,
and it is the reason the field exists at all.
Two details that are deliberate¶
platform is the operating system. device-detector has a platform of its own and
it means the CPU architecture — x64, ARM. Passing that through would have been the
obvious mapping and would have quietly changed what a public field means for every
existing caller.
A crawler gets a name and nothing else. Googlebot with an empty version and engine,
rather than an invented version. This object goes into statistics tables a row at a time,
and a fabricated number is worse there than an empty one.
Related Documentation¶
- Database API Guide — Database operations and best practices
- Authentication Guide — User authentication and authorization
- Routing Guide — Modern routing with PHP 8 attributes
- Security Guide — CSRF, XSS, sessions, 2FA
- Authorization Guide — Policy engine and access control
- Cache System Guide — Performance optimization
- Console Commands Guide — CLI tools and generators
- Logging System Guide — Application monitoring
- Document & Output Guide — Output formats
- Theme System Guide — UI theming and templates
- Email System Guide — Email handling and notifications
- Media System Guide — File uploads and media processing
- Internationalization Guide — Multi-language support