Test Application

PHP-MVC Apps Directory

Each app lives in its own subdirectory here and is auto-detected by the framework at runtime.

No manual registration is required — just clone/create the folder and reload.


DO NOT REINVENT — Framework Capabilities (AI & Dev Quick Reference)

Before writing any of the following from scratch inside an app, check libs/ in the framework root — it already exists.

You need…Use this, don't build your own
Login / logout / sessionauth::handleLogin(), session.php
Password hashinghash::create('sha256', $pw, HASH_PASSWORD_KEY)
"Login with Google / LinkedIn"libs/oauth.php (social_login class) — constants OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET, OAUTH_LINKEDIN_CLIENT_ID, OAUTH_LINKEDIN_CLIENT_SECRET in app's config.php
Magic link sign-inlogin controller — POST to login/{appName}/magic with email; verifies via login/{appName}/magicverify/{token}/{login}
User account settings pagecontrollers/controller.settings.php — GET/POST settings/{appName} — edit username, email, password
"Forgot password" / resetlogin controller methods lostpw() / resetpw() — stateless HMAC token, no extra table
Registration + email verifylogin controller register() / verify() — reuse or call from app's config
Outbound email(new sendmail())->foundationEmail()never call mail() directly
Database queriesExtend model, use $this->db->select/insert/update/delete()never new PDO
CSRF protectionsession::csrfToken() (form) + session::verifyCsrfToken() (POST)
Forms / form layoutlibs/form.php + libs/form_layout.php — Foundation 6 and Tailwind output
File uploads / image resizelibs/images.php
HTTP requests (cURL)libs/curl.php
Event / audit logginglibs/event_log.php
Cross-app API callslibs/api_client.php + X-API-Key header system
IP geolocationlibs/geoLocate.php
Page-view trackinglibs/hits.php
File-based cachelibs/cache.php
UK postcode lookuplibs/postcode.php
reCAPTCHAlibs/reCaptcha.php
Date formattinglibs/date.php
IP blocklist enforcementlibs/deny.php
Email open/click trackinglibs/mailTracker.php
DB table install (safe)libs/install.php — CREATE TABLE IF NOT EXISTS pattern
Lightweight ORMlibs/orm.php — Active Record with cross-app DB switching
API Manifestsapi.json — opt-in for automated API discovery

CSS framework: read the CSS_FRAMEWORK constant (or APP_CSS_FRAMEWORK for an app override) — do not hardcode Foundation or Tailwind class names without checking which is active. Use form_layout.php for all form rendering.

APP_NAME constant is set automatically by the framework for the current app. It equals the app folder name with a trailing slash (e.g. chatBubble/). Use it for file paths and view rendering only — never for URLs (see below).


URL constants — always use these in views and controllers

Three constants are available after bootstrap. Use the right one for each context:

ConstantValueUse for
APP_URLApp's base URL — strips prefix when app is DEFAULT_APPAll in-app navigation links
FW_URLFramework root URL — adds FRAMEWORK_PREFIX when DEFAULT_APP is activeLinks to framework controllers (about, index, etc.)
URLRaw site root, always https://example.com/Reserved routes (login, logout, oauth, api) and static asset paths

In-app navigation (views and controllers):

// Link to the app root
APP_URL                                  // → https://example.com/           (if DEFAULT_APP)
                                         // → https://example.com/chatBubble/ (if not)

// Link to a controller/method
APP_URL . 'details'                      // → https://example.com/details
APP_URL . 'details/' . $id               // → https://example.com/details/123

// Redirect in a controller
header('Location: ' . APP_URL);
header('Location: ' . APP_URL . 'resolve/' . $id);

Framework controller links (e.g. an about or index controller in the framework root):

FW_URL . 'about'     // → https://example.com/_fw/about  (when DEFAULT_APP is set)
                     // → https://example.com/about       (when no DEFAULT_APP)

Reserved framework routes — always use URL, never APP_URL or FW_URL:

URL . 'login/' . APP_NAME_BARE          // login/{appName}
URL . 'oauth/google/' . APP_NAME_BARE   // oauth/{provider}/{appName}
URL . 'logout'
URL . 'api/...'

Static assets (files served directly from the filesystem, not routed):

URL . 'apps/chatBubble/assets/logo.svg'  // always URL — this is a filesystem path, not a route

Rendering views from a controller — use APP_NAME here (file path, not a URL):

// APP_NAME has a trailing slash — no extra slash needed before 'views/'
$this->view->render('../apps/' . APP_NAME . 'views/index');
$this->view->render('../apps/' . APP_NAME . 'views/details');

Common mistakes to avoid:

// BAD — URL . APP_NAME breaks when this app is DEFAULT_APP
//       (APP_NAME is still 'chatBubble/' but the correct URL has no prefix)
header('Location: ' . URL . APP_NAME);
<a href="<?= URL . APP_NAME ?>details">

// BAD — hardcoded app name
header('Location: ' . URL . 'chatBubble/');
$this->view->render('../apps/chatBubble/views/index');

// BAD — double slash (APP_NAME ends with /, don't add another)
URL . APP_NAME . '/details'              // → .../chatBubble//details

// GOOD
header('Location: ' . APP_URL);
<a href="<?= APP_URL ?>details">
$this->view->render('../apps/' . APP_NAME . 'views/index');

App Types

1. Standard PHP App

A full MVC app with its own controllers, models, views, and optionally its own config and DB.

Structure:

apps/myApp/

controllers/ controller.index.php, controller.about.php, …

models/ myApp_model.php, …

views/ header.php, footer.php, index/, …

config.php (optional — overrides site DB/settings for this app)

public/ css, js, images

The framework routes /myApp/method/param to the matching controller in the app.

If controller.myApp.php doesn't exist, it falls back to controller.index.php

as the default handler for that app.

2. API-First App (Mobile & Web)

Apps can expose a secure API for mobile or cross-app use by including an api.json manifest in their root.

Structure:

apps/myApp/

api.json (API manifest for discovery)

controllers/ Dual-purpose controllers using $this->respond()

models/ ORM-based models extending orm class

Endpoints are automatically available at /api/myApp/... and require an API key by default when using $this->requireApiKey().

3. Static SPA Wrapper (wrapperApp)

For React, Vue, or any static HTML app that you want to put behind authentication.

Copy apps/wrapperApp/controllers/controller.index.php into your app's controllers/ folder.

The wrapper:

- Redirects unauthenticated users to the login page

- Reads index.html from the app root

- Fixes Vite absolute asset paths (/appName/assets/) → (/apps/appName/assets/)

so Apache's mod_rewrite serves the built assets directly from the filesystem

- Outputs the HTML and exits (no framework header/footer)


Integrating an Existing App from GitHub / Bitbucket

1. cd into the apps directory:

cd /path/to/php-mvc/apps

2. Clone the app repo into a folder matching its route name:

git clone https://github.com/USERNAME/REPONAME.git myApp

3. The framework picks it up automatically on next request — no config needed.

4. If the app has its own config.php, copy/edit it inside the app folder.

It will override the site-level config.php for that app's routes only.

You can also override outbound email settings here:

define('APP_SENDING_EMAIL', 'app-specific@domain.com');

define('APP_SITEADDRESS', 'App Office, 456 Branch St');

define('APP_EMAILS_ACTIVE', false);

5. If the app needs its own database tables, visit /myApp/install

(if an install controller/method exists) or run the SQL manually.

GitHub Token (for private repos on the server):

git config --global credential.helper store

Then do one authenticated clone/pull — credentials are saved to ~/.git-credentials.

Format stored: https://USERNAME:TOKEN@github.com


Creating a New App from Scratch

1. Create the folder structure:

mkdir -p apps/myApp/{controllers,models,views,public}

2. Copy the wrapperApp controller if it's a static SPA:

cp apps/wrapperApp/controllers/controller.index.php apps/myApp/controllers/

Or create a standard controller extending the base class:

apps/myApp/controllers/controller.index.php

class index extends controller {

function __construct() { parent::__construct(); auth::handleLogin(); }

public function index() {

$this->view->load('header');

$this->view->render('index/index');

$this->view->load('footer');

}

}

3. Views use APPINSTALLED constant (set automatically) for paths:

view::load() and view::render() resolve to apps/myApp/views/ automatically.

4. Push to its own repo:

cd apps/myApp

git init

git remote add origin https://github.com/USERNAME/REPONAME.git

git add . && git commit -m "Initial commit"

git push -u origin main


App Cron Jobs

Apps can implement their own cron jobs by placing a script in the app folder (e.g., apps/myApp/cron.php).

Pattern for App Cron:

#!/usr/local/bin/php.cli
<?php
define('CLI_MODE', true);
require 'config.php'; // App-specific config
require '../../libs/bootstrap.php'; // Framework core

spl_autoload_register(function($class) {
    $dirs = ['../../libs/', '../models/'];
    foreach($dirs as $dir) {
        if (file_exists($dir . strtolower($class).'.php')) {
            require_once($dir . strtolower($class).'.php');
            return;
        }
    }
});

// App logic here...

App Testing

The framework's unified test runner (/tests.php) automatically discovers any files matching *_test.php within your app's test/ directory.

Create a test:

1. Create directory apps/myApp/test/

2. Create my_feature_test.php

3. Add your assertions. The runner determines success by the process exit code: use exit(1) (or any non-zero value) to indicate failure.

Example:

<?php
echo "Running myApp feature test...\n";
if ($myAppFeature->isWorking()) {
    echo "Success!";
    exit(0);
} else {
    echo "Feature failed.";
    exit(1);
}

Framework Routing

Standard (no DEFAULT_APP)

/                  → controllers/controller.index.php → index()
/about             → controllers/controller.about.php → index()
/about/team        → controllers/controller.about.php → team()
/myApp             → apps/myApp/controllers/controller.index.php → index()
/myApp/method      → apps/myApp/controllers/controller.index.php → method()
/myApp/about       → apps/myApp/controllers/controller.about.php → index()

With DEFAULT_APP (e.g. define('DEFAULT_APP', 'mail'))

The named app's controllers handle the root and all unmatched routes. Other installed

apps and reserved framework routes are unaffected.

/                  → apps/mail/controllers/controller.index.php → index()
/about             → apps/mail/controllers/controller.about.php → index()
/booking           → apps/booking/... (installed app, unchanged)
/login             → controllers/controller.login.php (reserved — always framework)

With define('FRAMEWORK_PREFIX', '_fw'), framework controllers shadowed by DEFAULT_APP

remain reachable:

/_fw/about         → controllers/controller.about.php (framework version)

Priority order: FRAMEWORK_PREFIX match → installed app prefix → reserved route

(login, logout, oauth, api, admin) → DEFAULT_APP → framework root fallback.


Framework Libraries (libs/)

orm.php Lightweight ORM with cross-app DB switching

session.php Session management, CSRF tokens, login state

auth.php handleLogin() redirect, handleAdmin(level, redirect) role check

database.php PDO wrapper — select(), insert(), update(), delete(), query()

hash.php HMAC-SHA256 hashing & AES-256-GCM encryption

form.php Form builder — field definitions fed into form_layout.php

form_layout.php Renders forms for Foundation 6 or Tailwind (CSS_FRAMEWORK constant)

view.php load() / render() — prepends views/ or APPINSTALLED path

helper.php getFileClasses(), getFileComments() — used by dashboard code browser

links.php Directory scanner → HTML link lists, used by nav and dashboard

oauth.php Social login via Google / LinkedIn — social_login static class

sendmail.php Outbound email via native PHP mail()

webmail.php IMAP inbox reader (requires IMAP_USER / IMAP_PASS in config)

images.php Image upload, resize, thumbnail generation

cache.php Simple file-based cache

curl.php HTTP request helper (GET/POST via cURL)

hits.php Page-view tracking — increments hits table per user per page

members_area.php Legacy members area widget (array-driven dashboard boxes)

date.php Date formatting helpers

geoLocate.php IP geolocation using GeoIP2 data

postcode.php UK postcode lookup / distance calculation

reCaptcha.php Google reCAPTCHA v2 verification

parsecsv.php CSV parser

install.php DB table installer — reads SQL and runs CREATE TABLE IF NOT EXISTS

event_log.php Application event logging to DB

mailTracker.php Email open/click tracking

chatBubble.php Simple chat/messaging widget

smsResponse.php SMS webhook handler

deny.php IP/user blocklist enforcement

link_generator.php Slug and friendly-URL generator

etrr_*.php ML feature extraction / worker pipeline (specialist lib)


Framework Library API Reference

Method signatures, return types, and usage notes for every shared library. Read this before writing any feature that touches auth, data, email, or forms.


util/auth.phpauth

auth::handleLogin(string|false $path = false, int $userLevel = 1): void

Call in every controller __construct() that requires login. Redirects unauthenticated users to login/{APP_NAME}. Checks session::get('user_level') >= $userLevel. Also enforces per-app access via user_app table for non-admins (role < 2). Safe in CLI_MODE (skips checks).

auth::handleAdmin(int $userLevel = 2, string|false $path = false): void

Stricter check — redirects unless user_level > $userLevel. Use for admin-only sections.

Session keys set by login controller (available after handleLogin passes):

userid (int), login (string), email (string), role (string), user_level (string), loggedin (1), app_context (string).


libs/session.phpsession

session::init(): void                          // bootstrapped automatically, call manually in CLI
session::set(string $key, mixed $value): void
session::get(string $key): mixed               // returns null if not set
session::destroy(): void
session::csrfToken(): string                   // generates 64-char hex token, stores in $_SESSION
session::verifyCsrfToken(?string $submitted): bool  // hash_equals compare, returns false on mismatch
session::updateClicks(): int                   // increments click counter, returns new count

There is no session::unset() method. To clear a single key (e.g. flash messages), use unset($_SESSION['key']) directly — this is the accepted pattern:

// Flash message read-and-clear pattern:
$msg = session::get('status_message');   // read via wrapper
unset($_SESSION['status_message']);      // no session::unset() exists — direct unset is correct here

CSRF pattern (always use this):

// In view:   <input type="hidden" name="csrf_token" value="<?= $this->csrf ?>">
// In controller: $this->view->csrf = session::csrfToken();
// In POST handler:
if (!session::verifyCsrfToken($_POST['csrf_token'] ?? null)) { /* reject */ }

libs/database.phpdatabase extends PDO

Never instantiate directly inside an app. Always extend model or orm and use the provided helpers.

// Singleton — returns same connection for same credentials
database::getInstance(string $type, string $host, string $name, string $user, string $pass): database

libs/orm.phporm

Lightweight base class for Active Record models. Use instead of model for simpler syntax and built-in cross-app DB switching.

class my_record extends orm {
    protected static $table = 'my_table';
}

// Basic CRUD
$item = my_record::find(1);
$items = my_record::where(['status' => 'active']);
$newItem = new my_record(['name' => 'New']);
$newItem->save();

// Cross-app DB switching (reads target app's config.php automatically)
$remoteUser = user_record::find(123, 'banking');

Instance methods (available as $this->db inside any model):

$this->db->select(string $sql, array $params = []): array
// Returns array of associative arrays. Empty array if no rows.
// Sets $this->db->rowCount after execution.

$this->db->insert(string $table, array $data): string   // returns lastInsertId()
$this->db->update(string $table, array $data, string $where, array $whereParams = []): int  // returns affected rows
$this->db->delete(string $table, string $where, int $limit = 1, array $whereParams = []): int

$this->db->tableExists(string $name): bool
$this->db->dropTable(string $name): void
$this->db->returnData(array $r): array|false  // returns false if array empty

Parameter binding: always use named params:

$rows = $this->db->select('SELECT * FROM user WHERE userid = :id', [':id' => $uid]);
$this->db->update('user', ['email' => $email], 'userid = :id', [':id' => $uid]);

libs/hash.phphash

hash::create(string $algo, string $data, string $salt): string
// HMAC hash. algo: 'sha256' (standard), 'MD5', 'sha1'
// Standard password hash: hash::create('sha256', $password, HASH_PASSWORD_KEY)

hash::encrypt(string $plaintext, string $key = null): string
// AES-256-GCM. Returns base64-encoded "iv:tag:ciphertext".
// Uses HASH_GENERAL_KEY constant if $key omitted.

hash::decrypt(string $encoded, string $key = null): string|false
// Returns plaintext or false on failure/tamper.

Config constants required: HASH_PASSWORD_KEY (passwords), HASH_GENERAL_KEY (general encryption), HASH_VERIFY_KEY (email verification tokens).


libs/sendmail.phpsendmail

$mailer = new sendmail();   // uses APP_SENDING_EMAIL or SENDING_EMAIL constant as From

$mailer->foundationEmail(array $data, string|false $reply = false): string|bool

$data array keys:

[
  'to'          => 'user@example.com',   // required
  'subject'     => 'Your subject',       // required
  'text'        => '<p>HTML body</p>',   // required
  'title'       => 'Email heading',      // optional — shown in template header
  'address'     => '123 Street, City',   // optional — footer address (falls back to APP_SITEADDRESS / SITEADDRESS)
  'unsubscribe' => 'https://...',        // optional — unsubscribe link in footer
  'emailid'     => 42,                   // optional — event table ID for open tracking
]

Returns false if emailsActive / APP_EMAILS_ACTIVE is false, or on mail() failure. Returns mail result string on success.

HTML body handling: Pass HTML fragments directly in 'text' — any content containing an HTML tag is treated as HTML and injected into the Foundation email template. You do not need to wrap in tags. Plain text (no HTML tags) is auto-converted with nl2br().

Config constants: SENDING_EMAIL (or APP_SENDING_EMAIL), emailsActive (or APP_EMAILS_ACTIVE), SITETITLE, URL.


libs/images.phpimages

$img = new images(string $basePath = null, string $baseUrl = null);
// Defaults: ROOT.'public/images/'  and  URL.'public/images/'

$img->getImages(string|int $folderId): array
// Returns array of URLs for jpg/jpeg/png/gif files in that folder subfolder.

$img->uploadImages(string|int $folderId, array $files): void
// Processes $_FILES-style array. Allows jpg/jpeg/png/gif only. Creates folder if needed.

$img->deleteImages(string|int $folderId, array $filenames): void

$img->render(string|int|null $folderId, object $folderList = null, string $editUrlBase = null): string
// Returns HTML upload/delete interface. Pass folderId for single-entity view.

images::fileStatusButton(string $filename = null, string $type = 'img'): string
// Static. Returns HTML button: green if file exists, warning if not.
// type: 'img' → public/images/, 'doc' → public/documents/

libs/curl.phpcurl

curl::curl_data(string $url): array

Returns:

[
  'errno'    => int,     // 0 = success
  'errmsg'   => string,
  'content'  => mixed,  // json_decode(response, true) — array or null
  // + all curl_getinfo() keys including 'http_code'
]

Timeout: 10s response, 120s connect. Follows up to 10 redirects.

Limitation — GET only, no custom headers: curl::curl_data() sends a plain GET with no ability to set headers, POST body, or auth tokens. Do not use it for OAuth flows or any API call that requires Authorization headers, Content-Type: application/x-www-form-urlencoded, or POST. For those cases, use curl_init() directly with the full option set — this is a legitimate exception and will not be flagged as an anti-pattern when inside a dedicated OAuth/API integration method. Use libs/api_client.php instead for cross-app calls within this framework.


libs/oauth.phpsocial_login (all static)

Handles Google and LinkedIn OAuth 2.0 / OIDC login. Used by controller.oauth.php — do not build your own OAuth flow.

social_login::isConfigured(string $provider): bool
// Returns true if the required constants for 'google' or 'linkedin' are defined and non-empty.

social_login::authUrl(string $provider, string $appName): string
// Returns the OAuth provider's authorization URL with CSRF state stored in session.
// Redirect the user to this URL to begin the OAuth flow.

social_login::handleCallback(string $provider, string $appName, array $params): ?array
// Call from the OAuth callback route. Validates state, exchanges code for token,
// fetches user info, and returns a normalised user array or null on failure.
// Normalised array: ['email', 'name', 'provider_id', 'provider', 'raw']

Routes (handled by controller.oauth.php):

GET /oauth/google/{appName}           → redirects to Google auth page
GET /oauth/linkedin/{appName}         → redirects to LinkedIn auth page
GET /oauth/callback/{provider}/{app}  → exchanges code, logs in or creates user

Required constants in app's config.php:

define('OAUTH_GOOGLE_CLIENT_ID',       '...');
define('OAUTH_GOOGLE_CLIENT_SECRET',   '...');
define('OAUTH_LINKEDIN_CLIENT_ID',     '...');   // optional
define('OAUTH_LINKEDIN_CLIENT_SECRET', '...');   // optional

Setup:

1. Copy config.example.phpconfig.php in the app folder.

2. Google: console.cloud.google.com → OAuth 2.0 client → add redirect URI: {YOUR_URL}oauth/callback/google/{appName}

3. LinkedIn (optional): developer.linkedin.com → "Sign In with LinkedIn using OpenID Connect" → redirect URI: {YOUR_URL}oauth/callback/linkedin/{appName}

Behaviour on callback: If email already exists, the user is logged in and the OAuth provider_id is linked. If new, an account is created with an empty password (so the user can always set one later via the settings page).


libs/oauth.php — adding OAuth buttons to views

<?php
$oauthGoogle   = defined('OAUTH_GOOGLE_CLIENT_ID')   && OAUTH_GOOGLE_CLIENT_ID   !== '';
$oauthLinkedIn = defined('OAUTH_LINKEDIN_CLIENT_ID') && OAUTH_LINKEDIN_CLIENT_ID !== '';
?>
<?php if ($oauthGoogle): ?>
<a href="<?= URL ?>oauth/google/<?= $appName ?>">Continue with Google</a>
<?php endif; ?>

The app's config.php must be loaded before the view renders. The login controller loads it automatically during login and register flows.


$model = new login_model();

// Create a 15-minute single-use magic sign-in token:
$result = $model->createMagicToken(string $email): ?array
// Returns ['token' => string, 'login' => string, 'email' => string] or null if email not found.

// Send the magic link email:
$model->sendMagicLinkEmail(string $toEmail, string $login, string $linkUrl, string $appName): int
// Returns 1 on success, 0 on failure.

Magic link flow:

1. User POSTs email to login/{appName}/magic

2. Controller calls createMagicToken(), builds URL: {URL}login/{appName}/magicverify/{token}/{login}

3. Controller calls sendMagicLinkEmail() — always shows the same "check your email" message regardless of whether the email exists (prevents enumeration)

4. User clicks link → magicverify sub-action calls magicLogin() → sets session → redirects into app


controllers/controller.settings.php — user account settings

Global settings controller usable by any app. Handles username, email, and password changes.

Routes:

GET  /settings              → settings page (uses app_context session for branding)
GET  /settings/{appName}    → settings page with forced app context
POST /settings/savePassword → update password (current required; skipped for OAuth-only accounts)
POST /settings/saveEmail    → update email
POST /settings/saveUsername → update username/login

Linking from an app:

<a href="<?= URL ?>settings/coffee">Account settings</a>

The page renders inside the app's own header.php / footer.php if they exist, otherwise falls back to the global header/footer. All forms include CSRF protection.

login_model methods used by settings controller:

$model->verifyPassword(int $userId, string $password): bool
$model->updatePassword(int $userId, string $newPassword): bool
$model->updateEmail(int $userId, string $newEmail): bool
$model->updateLogin(int $userId, string $newLogin): bool   // returns false if login already taken
$model->getUserById(int $userId): array|false
$model->checkLoginOrEmailExists(string $login, string $email): array|false

libs/event_log.phpevent_log extends model

$log = new event_log();

$log->checkEventType(string $type, string $codeFile, string $isEmail = '0'): int
// Finds or creates an event type record. Returns eventTypeid.
// $type: human label e.g. 'user_registered', $codeFile: e.g. 'register'

$log->insertEvent(
    int $eventTypeid,
    string $description,
    int|false $userid = false,
    int|false $agreementid = false,
    int|false $otherUserid = false,
    string|false $recipientEmail = false
): int  // returns inserted eventid

Rate limiting built in: >14 events/5min from same IP triggers an automatic IP ban via deny.

Typical usage:

$typeId = $log->checkEventType('user_login', 'login');
$log->insertEvent($typeId, "User {$login} logged in", $userid);

libs/api_client.phpapi_client

api_client::post(string $app, string $endpoint, string $apiKey, array $data = []): array
api_client::get(string $app, string $endpoint, string $apiKey, array $params = []): array

Builds URL as URL . $app . '/' . $endpoint. Sends X-API-KEY header + JSON body.

Response array:

[
  'success' => bool,    // true if HTTP 200–299
  'status'  => int,     // HTTP status code
  'data'    => mixed,   // decoded JSON response body
  'raw'     => string,  // raw response
  'error'   => string,  // curl error if request failed
]

SSL verification disabled automatically when ENVIRONMENT = 'development'.


libs/form.phpform

Chainable fluent validator for POST data.

$form = new form();
$form->post('email')->val('isEmail')
     ->post('login')->val('minlength', 3)->val('maxlength', 64)
     ->post('password')->val('minlength', 8)
     ->submit();            // throws Exception with error summary if any validation failed
$form->fetch('fieldName'): mixed   // returns single sanitised POST value
$form->fetch(): array              // returns all validated POST values

Available validators (pass as string to val()): isEmail, minlength, maxlength, isNumeric, isDate, notEmpty.


libs/cache.phpcache

$cache = new cache(['cacheDir' => 'cache/', 'lifeTime' => 3600]);

$cache->set_url(string $url, array $params, mixed $request = null): string
// Builds final URL with query string. Sets internal hash for filename.

$cache->perform_cache(string $url, string $hash = null, bool $xml = null): array
// Fetches from file cache if fresh, else fetches live via curl and caches.
// Returns decoded array from cache file.

$cache->getStatus(): array  // ['cachestatus' => 'updated'|'retrieved'|'Failed'|'error...']
$cache->setLifeTime(int $seconds): int
$cache->save(string $content, string $hash = null): void
$cache->get(string $hash = null): string   // empty string if cache miss/expired

libs/helper.phphelper (static utilities)

helper::getRealIP(): string
// Returns real client IP (Cloudflare → X-Forwarded-For → REMOTE_ADDR). Returns 'UNKNOWN' if none valid.

helper::isEmail(string $data): string|false
// Returns email string if valid, false otherwise.

helper::isNumber(mixed $value): bool
helper::sanitizeNumeric(mixed $value): float|null   // strips commas, rounds to 2dp

helper::randomString(int $length = 6): string       // hex string from random_bytes

helper::niceDate(string|DateTime $date, string $format = 'jS M', string $currentYear = null): string|false
// Formats date. Appends year if not current year.

helper::percent(float $num1, float $num2): float    // (num1/num2)*100
helper::percent_r(float $num1, float $num2): float  // 100 - (num1/num2)*100

helper::sanitize(array $post): array                // sanitises entire POST array for safe output
helper::linkify(string $data): string               // converts URLs in text to <a> tags
helper::GreenYellowRed(int $number): string         // HTML badge: green >66, yellow 33–66, red <33

helper::getFileClasses(string $file): array         // returns class names parsed from a PHP file
helper::getAvailableApps(): array                   // returns list of apps from /apps directory
helper::getAppMetadata(string $appName): array      // ['title' => ..., 'description' => ...]
helper::limitNewlines(string $text): string         // collapses excess newlines
helper::jsonToTable(string $jsonText, ...): string  // renders JSON as HTML table
helper::array_sort_by_column(array &$arr, string $col, int $dir = SORT_ASC): void
helper::isValidEmailDomain(string $email): bool     // DNS MX lookup

libs/chatBubble.phpchatBubble extends model

Floating feedback/message widget. Stores messages in the message DB table.

$chat = new chatBubble(string|false $urlString = false);

$chat->displayChatBubble(array $options = ['bug'=>'Bug Report','feature'=>'Feature Request','message'=>'Message'], string $place = 'bug'): string
// Returns full HTML+JS for the fixed chat bubble widget. Echo directly into any view.
// Handles its own POST submission internally (calls insertSubmit()).

$chat->chatStats(): string
// Returns HTML dashboard summary of all messages, grouped by type and resolved status.
// Use in admin views.

$chat->updateResolvedStatus(): void
// Call from a controller action to handle AJAX resolved-status toggle. Echoes JSON and exits.

$chat->getUserLogin(int $userid): string|false
$chat->getUserEmail(int $userid): string|false
$chat->getUserRole(int $userid): string|false
$chat->listUsers(int|false $userid = false): array|false

message table columns: messageid, toUser, fromUser, message, dateSent, dateSeen, type, hitid, page, device, darkMode, resolved, ip, spam, location.


libs/spam_filter.phpspam_filter (all static)

All methods accept a string or array of strings. All return an array with per-item counts and a 'total' key.

spam_filter::censor(string|array $data): array
// Detects badword matches from util/badwords.txt.
// +3 per spam TLD (top|click|xyz|work|etc.), matches case-insensitive.
// ['item0' => 2, 'total' => 2]  — values are offence counts

spam_filter::censorConsonants(string|array $data): array
// Detects 6+ consecutive consonants (common spam obfuscation signal).

spam_filter::censorHTMLSpam(string|array $data): array
// Scores HTML-based spam signals: link density, tracking params, hidden content,
// shorteners (bit.ly etc.), suspicious TLDs, known spam phrases.
// Negative score for legitimate newsletter signals (unsubscribe, copyright).

spam_filter::countGoodWords(string|array $data): array
// Counts legitimate words from util/goodwords.txt. Higher = more likely genuine.

spam_filter::removeGoodWords(string $text): string
// Strips goodwords from text, returns remainder for further analysis.

Typical spam gate:

$offence = spam_filter::censor($message)['total']
         + spam_filter::censorConsonants($message)['total'];
if ($offence >= 10) { deny::banip($ip); exit; }
if ($offence > 0)   { /* flag for review */ }

Wordlist files: util/badwords.txt, util/goodwords.txt (copy from *.example.txt on first deploy).


libs/model.php — base model class

Every app model should extend this:

class my_model extends model {
    public function __construct() {
        parent::__construct();
        // $this->db is now a live database instance
        // $this->view is available for rendering
    }
}

parent::__construct() selects the correct DB credentials: if APP_NAME is defined and the app has APP_OWN_USERS = true with APP_DB_* constants set, those are used; otherwise falls back to SITE_DB_*.


Notes

  • apps/wrapperApp — template only, not a real app, excluded from app detection
  • apps/README.md — this file, excluded from the dashboard apps list
  • Each app stays in .gitignore for the main phpmvc repo — apps are separate git repos
  • CSS_FRAMEWORK constant (set in config.php) switches form_layout between Foundation 6 and Tailwind
  • The Tailwind header (views/header_tailwind.php) includes a Foundation class compat layer

so shared views render correctly without Foundation CSS loaded


Shared Architectural Patterns (IMPORTANT)

To prevent divergence and ensure stability across app deployments, all apps MUST leverage these framework-standard choices. These patterns are essential for maintaining a unified security, tracking, and communication layer.

1. Authentication & Security

  • Local Auth: Call auth::handleLogin() in your controller's constructor. This handles session initialization, redirects to the main site login, and enforces per-app access permissions.
  • OAuth Login: Use libs/oauth.php (social_login class — not oauth) for Google or LinkedIn integration. Define constants OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET, OAUTH_LINKEDIN_CLIENT_ID, OAUTH_LINKEDIN_CLIENT_SECRET in the app's config.php. See the social_login API reference section above for full setup instructions.
  • Magic Link Sign-in: POST to login/{appName}/magic with an email field. The framework sends a single-use 15-minute link; no password required. Use as an alternative to "forgot password". See login_model::createMagicToken() and sendMagicLinkEmail() in the API reference above.
  • User Settings Page: controllers/controller.settings.php provides a ready-made settings page at settings/{appName}. Link to it from your app's nav. Users can change username, email, and password (OAuth-only accounts can set a password without entering a current one).
  • CSRF Protection: Always use session::csrfToken() in forms and session::verifyCsrfToken() in POST handlers. The form_layout library handles this automatically.

2. Database & Models

  • Standard DB: Always extend the base model class and use the provided PDO wrapper methods (select, insert, update, delete). Do NOT instantiate raw PDO connections within apps.
  • Table Detection: Use libs/install.php for database setup. The framework expects tables to be created non-destructively (CREATE TABLE IF NOT EXISTS).

3. Communication & Tracking

  • Outbound Email: Use (new sendmail())->foundationEmail() for well-formatted HTML emails. To enable tracking, pass a unique emailid (from the event table) in the data array.
  • Event Logging: Log all significant app actions using libs/event_log.php. This feeds into the global admin tracking and security layer.
  • Inter-App API: Apps should communicate securely via libs/api_client.php. This uses the X-API-KEY system to allow one app to trigger actions in another without sharing session state.
  • Chat & UI: Use libs/chatBubble.php for consistent in-app notifications and messaging widgets.

4. Application Context

  • APP_NAME: Use the APP_NAME constant to generate app-relative URLs and paths.
  • Overrides: Leverage the app's local config.php to override global constants like SENDING_EMAIL or SITEADDRESS when your app requires specific branding or delivery settings.

Common Mistakes (AVOID THESE)

Mistakes made by developers during the banking ↔ budget integration. Document so others don't repeat them.

M1 — Wrong require_once paths inside apps

Wrong:

require_once ROOT . 'libs/BankTagMapper.php';
require_once ROOT . 'models/banking_import_model.php';

Right:

require_once __DIR__ . '/../libs/BankTagMapper.php';
require_once __DIR__ . '/../models/banking_import_model.php';

ROOT is the framework root (/path/to/php-mvc/). An app's files are under

apps/appName/, so ROOT . 'libs/...' resolves to the *framework* libs, not the

app's. Always use __DIR__-relative paths for secondary requires inside an app.

This also works regardless of the PHP process's CWD.


M2 — Embedding query params in api_client::get() endpoint string

Wrong:

api_client::get('banking', "api/transactions?user_id={$uid}&since={$since}", $key);

Right:

api_client::get('banking', 'api/transactions', $key, ['user_id' => $uid, 'since' => $since]);

api_client::get() signature: get(string $app, string $endpoint, string $apiKey, array $params = []).

Params go in the 4th argument — the library builds and encodes the query string for you.

Embedding params in the URL string bypasses encoding and breaks the abstraction.


M3 — Reading $response['data'] without drilling into the response body

Wrong:

$apiBankTxns = $response['data'] ?? [];

Right:

$apiBankTxns = $response['data']['transactions'] ?? [];

api_client returns ['success', 'status', 'data', 'raw', 'error'] where data

is the *full decoded JSON body* from the remote endpoint. If the remote endpoint

returns {"transactions": [...]}, you must drill down: $response['data']['transactions'].

Always check what shape the remote endpoint actually returns.


M4 — Using define() constants for per-user configuration

Wrong:

// config.php
define('BUDGET_BANK_IMPORT_ACCOUNT_ID', 1);

// model
$accountId = defined('BUDGET_BANK_IMPORT_ACCOUNT_ID') ? BUDGET_BANK_IMPORT_ACCOUNT_ID : 1;

Right:

// DB table with userid PK; constant as fallback default only
$accountId = $this->getImportAccountId($userId); // reads from DB, falls back to constant

PHP constants are process-wide singletons. Any config that varies per user *must*

be stored in a database table keyed on userid. Constants are fine as installation

defaults (seeded into the DB on first use), but must never be the live source of

per-user state. See budget_import_settings table + banking_import_model for the

reference implementation.


M5 — Leaving debug output in production code

Wrong:

print "nb - dates filters tags";  // left in controller, breaks page layout

Search for stray print, echo, var_dump, var_export calls before committing.

In this MVC framework the controller output is injected directly into the page — any

unexpected output will corrupt the HTML, break JSON responses, or cause header errors.


M6 — Calling sendmail methods statically

Wrong:

sendmail::email($emailParams);
sendmail::foundationEmail($data);

Right:

$mailer = new sendmail();
$mailer->email($emailParams);

$mailer = new sendmail();
$mailer->foundationEmail($data);

sendmail is an instance class — its constructor reads APP_SENDING_EMAIL / SENDING_EMAIL

into $this->fromAddress, and all methods use instance properties. PHP 8 throws a fatal error

when non-static methods are called statically (`Non-static method sendmail::email() cannot be

called statically`). Always instantiate first. Pass a custom from-address to the constructor

when the app needs a different sender: new sendmail('noreply@myapp.com').


(C) 2015-2026 Simeon Welby