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 / session | auth::handleLogin(), session.php |
| Password hashing | hash::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-in | login controller — POST to login/{appName}/magic with email; verifies via login/{appName}/magicverify/{token}/{login} |
| User account settings page | controllers/controller.settings.php — GET/POST settings/{appName} — edit username, email, password |
| "Forgot password" / reset | login controller methods lostpw() / resetpw() — stateless HMAC token, no extra table |
| Registration + email verify | login controller register() / verify() — reuse or call from app's config |
| Outbound email | (new sendmail())->foundationEmail() — never call mail() directly |
| Database queries | Extend model, use $this->db->select/insert/update/delete() — never new PDO |
| CSRF protection | session::csrfToken() (form) + session::verifyCsrfToken() (POST) |
| Forms / form layout | libs/form.php + libs/form_layout.php — Foundation 6 and Tailwind output |
| File uploads / image resize | libs/images.php |
| HTTP requests (cURL) | libs/curl.php |
| Event / audit logging | libs/event_log.php |
| Cross-app API calls | libs/api_client.php + X-API-Key header system |
| IP geolocation | libs/geoLocate.php |
| Page-view tracking | libs/hits.php |
| File-based cache | libs/cache.php |
| UK postcode lookup | libs/postcode.php |
| reCAPTCHA | libs/reCaptcha.php |
| Date formatting | libs/date.php |
| IP blocklist enforcement | libs/deny.php |
| Email open/click tracking | libs/mailTracker.php |
| DB table install (safe) | libs/install.php — CREATE TABLE IF NOT EXISTS pattern |
| Lightweight ORM | libs/orm.php — Active Record with cross-app DB switching |
| API Manifests | api.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:
| Constant | Value | Use for |
APP_URL | App's base URL — strips prefix when app is DEFAULT_APP | All in-app navigation links |
FW_URL | Framework root URL — adds FRAMEWORK_PREFIX when DEFAULT_APP is active | Links to framework controllers (about, index, etc.) |
URL | Raw 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.php — auth
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.php — session
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.php — database 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.php — orm
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.php — hash
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.php — sendmail
$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.php — images
$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.php — curl
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.php — social_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.php → config.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.
libs/sendmail.php — magic link emails (login_model)
$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.php — event_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.php — api_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.php — form
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.php — cache
$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.php — helper (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.php — chatBubble 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.php — spam_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_loginclass — notoauth) for Google or LinkedIn integration. Define constantsOAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_LINKEDIN_CLIENT_ID,OAUTH_LINKEDIN_CLIENT_SECRETin the app'sconfig.php. See thesocial_loginAPI reference section above for full setup instructions. - Magic Link Sign-in: POST to
login/{appName}/magicwith anemailfield. The framework sends a single-use 15-minute link; no password required. Use as an alternative to "forgot password". Seelogin_model::createMagicToken()andsendMagicLinkEmail()in the API reference above. - User Settings Page:
controllers/controller.settings.phpprovides a ready-made settings page atsettings/{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 andsession::verifyCsrfToken()in POST handlers. Theform_layoutlibrary handles this automatically.
2. Database & Models
- Standard DB: Always extend the base
modelclass and use the provided PDO wrapper methods (select,insert,update,delete). Do NOT instantiate raw PDO connections within apps. - Table Detection: Use
libs/install.phpfor 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 uniqueemailid(from theeventtable) 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 theX-API-KEYsystem to allow one app to trigger actions in another without sharing session state. - Chat & UI: Use
libs/chatBubble.phpfor consistent in-app notifications and messaging widgets.
4. Application Context
- APP_NAME: Use the
APP_NAMEconstant to generate app-relative URLs and paths. - Overrides: Leverage the app's local
config.phpto override global constants likeSENDING_EMAILorSITEADDRESSwhen 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