Developer Guide

Developer Guide

Architecture Overview

MVC Pattern

Flatboard 5 follows the Model-View-Controller (MVC) pattern:

app/
├── Controllers/    # Handle requests and logic
├── Models/         # Data models and business logic
├── Views/          # Template files
├── Core/           # Core framework classes
├── Helpers/        # Helper functions
├── Middleware/     # Request middleware
└── Services/      # Service classes

Directory Structure

Flatboard5/
├── app/                    # Application code
│   ├── Controllers/       # Controllers
│   ├── Models/            # Models
│   ├── Views/             # Views
│   ├── Core/              # Core classes
│   ├── Helpers/           # Helpers
│   ├── Middleware/        # Middleware
│   └── Services/          # Services
├── public/                # Public files
│   └── index.php         # Entry point
├── stockage/             # Data storage
├── uploads/              # User uploads
├── plugins/              # Plugins
├── themes/               # Themes
└── vendor/               # Dependencies

Core Components

Autoloader

PSR-4 autoloading:

use App\Core\Autoloader;

Autoloader::register(BASE_PATH);

Router

Route registration (the Router requires Request and Response instances):

use App\Core\Router;
use App\Core\Request;
use App\Core\Response;

$router = new Router($request, $response);
$router->get('/path', 'Controller@method');
$router->post('/path', 'Controller@method');

// Named routes
$router->get('/forum', 'ForumController@index')->name('forum.index');
$url = $router->url('forum.index');                      // Generate URL
$url = $router->url('discussion.show', ['id' => 42]);    // With params

// Route groups (shared prefix + middleware)
$router->group(['prefix' => '/admin', 'middleware' => ['App\Middleware\AuthMiddleware']], function($router) {
    $router->get('/dashboard', 'Admin\DashboardController@index');
});

// Parameter constraints
$router->get('/user/{id}', 'UserController@show')->where('id', '[0-9]+');

// RESTful resource routes (generates GET list, GET show, POST, PUT, DELETE)
$router->resource('/posts', 'PostController');

// Regex-based routes
$router->regex('GET', '#^/custom/(.+)$#', function($matches) { /* ... */ });

// Global before/after hooks (for monitoring, logging)
$router->beforeEach(function($request) { /* ... */ });
$router->afterEach(function($request, $response) { /* ... */ });

// Route cache for production (cached to stockage/cache/routes.php)
$router->enableCache();

Controller

Base controller:

namespace App\Controllers;

use App\Core\Controller;

class MyController extends Controller
{
    public function index()
    {
        return $this->view('template', ['data' => $data]);
    }
}

Model

Base model:

namespace App\Models;

class MyModel
{
    public static function find($id)
    {
        // Load from storage
    }

    public static function create($data)
    {
        // Create new record
    }
}

Coding Standards

PSR Standards

Follow PSR standards:

  • PSR-1 - Basic coding standard
  • PSR-4 - Autoloading standard
  • PSR-12 - Extended coding style

Code Style

<?php
namespace App\Controllers;

use App\Core\Controller;
use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        $users = User::all();
        return $this->view('users.index', ['users' => $users]);
    }

    public function show($id)
    {
        $user = User::find($id);
        if (!$user) {
            return $this->notFound();
        }
        return $this->view('users.show', ['user' => $user]);
    }
}

Naming Conventions

  • Classes: PascalCase - UserController
  • Methods: camelCase - getUserData()
  • Variables: camelCase - $userData
  • Constants: UPPER_SNAKE_CASE - MAX_FILE_SIZE
  • Files: Match class name - UserController.php

Plugin Development

Plugin Structure

plugins/my-plugin/
├── plugin.json
├── MyPluginPlugin.php
├── assets/
├── views/
└── README.md

Plugin Class

<?php
namespace App\Plugins\MyPlugin;

use App\Core\Plugin;

class MyPluginPlugin
{
    public function boot()
    {
        // Register plugin routes
        Plugin::hook('router.plugins.register', [$this, 'registerRoutes']);
        // Add CSS to page header
        Plugin::hook('view.header.styles', [$this, 'addStyles']);
    }

    public function registerRoutes($router)
    {
        $router->get('/my-plugin', function() {
            return 'Hello from plugin!';
        });
    }

    public function addStyles(&$styles)
    {
        $styles[] = \App\Helpers\PluginAssetHelper::loadCss('my-plugin', 'css/style.css');
    }
}

Available Hooks

Below are the most commonly used hooks for plugin development:

HookUse case
router.plugins.registerRegister plugin routes (preferred for plugins)
app.routes.registerRegister application-level routes
view.header.stylesInject CSS into <head>
view.footer.scriptsInject JS before </body>
view.footer.contentInject HTML into footer
view.navbar.itemsAdd items to the main navigation bar
view.admin.sidebar.itemsAdd items to the admin sidebar
admin.dashboard.widgetsAdd widgets to the admin dashboard
discussion.createdAfter a discussion is saved
post.createdAfter a reply is saved
user.registeredAfter a user account is created
search.resultsFilter or augment search results
notification.before.createIntercept notifications before they are written
markdown.editor.configModify the Markdown editor configuration
upload.image.savedPost-process an uploaded image (compress/convert) — since 5.7.2
visitor.page_infoResolve page info for unknown URLs (presence)
presence.usersFilter/enrich active users list
presence.datacenter_rangesExtend the cloud/datacenter IP ranges used to filter crawler IPs out of the visitors panel — since 5.7.4

Theme Development

Theme Structure

themes/my-theme/
├── theme.json
├── assets/
│   ├── css/
│   ├── js/
│   └── img/
└── views/

Template Overrides

Override default templates:

themes/my-theme/views/
├── layouts/
│   └── main.php
└── discussions/
    └── list.php

CSS Variables

Use CSS variables for customization:

:root {
  --primary-color: #007bff;
  --secondary-color: #6c757d;
  --background-color: #ffffff;
  --text-color: #212529;
}

Update Endpoint for Plugins and Themes

Since 5.3.7, Flatboard can check for updates on any plugin or theme that declares an update_url in its plugin.json or theme.json. When an update is available, it appears in Admin Panel > Tools > Updates alongside the core update, with the installed version, the latest available version, and a link to the changelog.

Declaring an update_url

Add the update_url field at the root of your plugin.json (or theme.json):

{
    "name": "My Plugin",
    "id": "my-plugin",
    "version": "1.2.0",
    "update_url": "https://example.com/api/my-plugin/version",
    ...
}

The value can be:

  • An absolute URL — used directly (https://example.com/api/...)
  • A relative path — prefixed with the forum's update_check_url config value (e.g. api/plugins/my-pluginhttps://versions.flatboard.org/api/plugins/my-plugin)

If the URL is relative and no update_check_url is configured, the check is silently skipped.

Required API response format

Your endpoint must return a JSON object with at least a version field:

{
    "version": "1.3.0",
    "changelog_url": "https://example.com/my-plugin/changelog"
}
FieldRequiredDescription
versionYesLatest available version string (compared against plugin.json version)
changelog_urlNoURL to the changelog or release notes, shown as a link in the updates page

The check is performed via a simple GET request (cURL). Results are cached for 1 hour — no need to worry about hammering the endpoint.

Minimal server-side example (PHP)

<?php
header('Content-Type: application/json');
echo json_encode([
    'version'       => '1.3.0',
    'changelog_url' => 'https://example.com/my-plugin/releases/1.3.0',
]);

Plugin Settings API

Plugin settings live in the "plugin" section of plugin.json. Always use Plugin::getData/setData/saveData — never Config::get/set — for plugin-specific values:

use App\Core\Plugin;

// Read a setting (third arg is default value)
$apiKey = Plugin::getData('my-plugin', 'api_key', '');

// Dot-notation for nested keys
$host = Plugin::getData('my-plugin', 'smtp.host', 'localhost');

// Write a setting (in-memory only)
Plugin::setData('my-plugin', 'api_key', 'abc123');

// Persist all settings to plugin.json
Plugin::saveData('my-plugin', ['api_key' => 'abc123', 'enabled' => true]);

// Get plugin stats (for monitoring)
$stats = Plugin::getStats();
// Returns: ['total' => int, 'active' => int, 'inactive' => int, 'hooks' => int]

Presence Service

App\Services\PresenceService provides a unified API for querying who is currently on the forum (all methods are static):

use App\Services\PresenceService;

// All presence data (anonymous visitors + bots + logged-in users)
$all = PresenceService::getAllPresence(minutes: 15, includeBots: true);
// Returns: ['visitors' => [...], 'bots' => [...], 'users' => [...], 'all' => [...], 'stats' => [...]]

// Presence on a specific page
$page = PresenceService::getPresenceByPage('/d/123', minutes: 15);

// Aggregate stats only
$stats = PresenceService::getPresenceStats(minutes: 15);
// Returns: ['total' => int, 'anonymous' => int, 'authenticated' => int, 'bots' => int]

// Filter helpers (work on any presence array)
$filtered = PresenceService::filterByPageType($all['all'], 'discussion');
$filtered = PresenceService::filterByCategory($all['all'], 'general');
$filtered = PresenceService::filterByUserGroup($all['users'], 'moderator');

// Sorting
$sorted = PresenceService::sortPresence($all['all'], sortBy: 'last_activity', order: 'desc');

Router::trackVisitor() runs automatically on every non-AJAX HTML request. It skips: authenticated users, paths under /api/, /favicon.ico, /robots.txt, /presence/update, and any request with a static-asset extension. It fires visitor.before_track before writing each record.

Translation System

Global helper

// Both are equivalent
$text = Translator::trans('key', ['var' => 'value'], 'domain');
$text = __('key', ['var' => 'value'], 'domain');

Advanced methods

// Get current language code
$lang = Translator::getLanguage();   // e.g., 'fr', 'en'

// Change language for the current request
Translator::setLanguage('en');

// Reload all translations from disk
Translator::reload();

// Reload only theme translation overrides
Translator::reloadThemeTranslations();

// Get all keys for a domain (useful for debugging)
$all = Translator::getAll('main');

// Register plugin translations programmatically
Translator::addPluginTranslations('my-plugin', ['key' => 'value']);

Locale-aware date formatting (since 5.4.0)

DateHelper::format() and DateHelper::human() replace the F (month long), M (month short), l (day long), and D (day short) PHP format specifiers with translated names read from the active language file, instead of using PHP's English-only date() output.

Every language file (languages/{fr,en,de,pt,zh,pl}/main.json) carries a top-level datetime section with:

{
  "datetime": {
    "months_long":  ["janvier", "février", "mars", ...],
    "months_short": ["janv.", "févr.", "mars", ...],
    "days_long":    ["dimanche", "lundi", ...],
    "days_short":   ["dim.", "lun.", ...],
    "format_date":      "d/m/Y",
    "format_datetime":  "d/m/Y H:i",
    "format_long":      "l j F Y"
  }
}

Escaped characters (\F, \l, etc.) in the format string are passed through unchanged. Example:

use App\Helpers\DateHelper;

DateHelper::format($timestamp, 'l j F Y');   // "samedi 31 mai 2026" in fr_FR
DateHelper::format($timestamp, 'D d M Y');   // "Sat 31 mai. 2026" with a literal "Sat" if the formatter sees \D
DateHelper::human($timestamp);               // localized "Today at 14:32", "Yesterday at 09:15", …

Always use DateHelper::format() instead of raw date() when building views — otherwise users get English day/month names regardless of their selected language. This was the root cause of the locale leaks fixed in 5.4.0 (FlatHome blog cards, ForumMonitoring activity bars, PrivateMessaging admin chart).

CLI Commands

Flatboard ships a command-line entry point at app/Cli/console.php. Run it from the project root:

php app/Cli/console.php <command> [args...]

Run it with no argument to print the full list of available commands. A few of the maintenance/update commands worth highlighting:

CommandSinceDescription
markdown:rebuild5.0Re-render every post's Markdown to rendered_html. Run after upgrading a parser-affecting plugin.
cleanup:unverified-users [days]5.5.0Delete users with email_verified = false whose created_at is older than days (default 7). Logged to the security log. Suitable for a daily cron.
update:renew-cacert5.3.7Download the latest Mozilla CA bundle from curl.se and overwrite stockage/certs/cacert.pem. Runs automatically at most once every 30 days; this command forces an immediate renewal.

See the Advanced guide for the full command list.

console.php refuses to run if PHP_SAPI !== 'cli' — even though app/ is already blocked at the web-server level (.htaccess / nginx.conf), this code-level guard acts as a second line of defence in case of server misconfiguration.

Writing a new CLI command

CLI commands live in app/Cli/Commands/. console.php resolves a group:subcommand invocation by convention, with no registration step: it instantiates the class App\Cli\Commands\{Ucfirst(group)}Command and calls the method named after the subcommand (hyphens are camel-cased, e.g. renew-cacertrenewCacert()). So a command file is a plain class with one public method per subcommand:

namespace App\Cli\Commands;

// Resolves `my:command` → MyCommand::command()
class MyCommand
{
    public function command(array $args = []): void
    {
        // $args is the raw argv slice after the command name
        echo "Hello from my:command\n";
    }
}

Drop the file into app/Cli/Commands/ and the command is available immediately as php app/Cli/console.php my:command — the class name ({group}Command) and method name (subcommand) are the only contract.

Storage Development

Two storage APIs serve different purposes. Choose the right one for your use case.

StorageFactory — Core Flatboard Data

Use StorageFactory::create() to read or write core Flatboard entities (users, discussions, posts, categories…). It returns the active StorageInterface implementation — JsonStorage on Community, SqliteStorage on Pro — so the same plugin code works on both editions without any change.

use App\Storage\StorageFactory;

$storage = StorageFactory::create();

// Examples of StorageInterface methods
$user        = $storage->getUserById($userId);
$discussions = $storage->getDiscussionsByCategory($categoryId);
$post        = $storage->getPostById($postId);

// Group membership in a single batched query (since 5.7.0)
$staff       = $storage->getUsersByGroup([$adminGroupId, $modGroupId]);

AtomicFileHelper — Plugin Custom Data

Use AtomicFileHelper when your plugin needs to store its own data files (not Flatboard core entities). It provides atomic read/write operations backed by file locking — never use file_get_contents / file_put_contents directly.

Plugin data is typically stored inside the plugin's own directory, under a data/ subfolder. Use Plugin::getPath() to resolve the path safely regardless of the plugin's installation location:

use App\Core\AtomicFileHelper;
use App\Core\Plugin;

$dataDir  = Plugin::getPath('my-plugin') . '/data';
$dataFile = $dataDir . '/records.json';

// Read plugin data (returns array or null if file absent)
$data = AtomicFileHelper::readAtomic($dataFile);

// Write plugin data (returns bool)
AtomicFileHelper::writeAtomic($dataFile, $data);

// Batch read multiple files in one pass
$results = AtomicFileHelper::readAtomicBatch([
    $dataDir . '/records.json',
    $dataDir . '/settings.json',
]);
StorageFactoryAtomicFileHelper
PurposeCore Flatboard data (users, discussions…)Plugin-specific custom files
Community✓ (returns JsonStorage)
Pro✓ (returns SqliteStorage)
Backend-agnosticYes — same API on both editionsN/A (JSON files only)
Survives uninstallYes (core data)Only if stored in stockage/

Security Best Practices

Input Validation

Always validate input:

use App\Core\Validator;

// Pass request data to the constructor
$validator = new Validator($this->request->all());
$validator->required('email')->email('email');
$validator->required('username')->min('username', 3)->max('username', 30);

if (!$validator->isValid()) {
    $errors = $validator->getErrors(); // ['field' => 'error message', ...]
    Session::flash('errors', $errors);
    $this->redirect(\App\Helpers\UrlHelper::to('/register'));
    return;
}

Output Sanitization

Sanitize all output:

use App\Core\Sanitizer;

// Strip dangerous HTML, keep safe tags (for rich content)
$clean = Sanitizer::sanitizeHtml($userInput);

// Strip all HTML tags (for plain text fields)
$clean = Sanitizer::sanitizeText($userInput);

// Escape for HTML output
echo Sanitizer::escape($value);

// Escape for use in an HTML attribute
echo Sanitizer::sanitizeForAttribute($value);

CSRF Protection

Use CSRF tokens:

use App\Core\Csrf;

// Generate a token for the current session
$token = Csrf::token();

// Render a hidden input field (shortcut for use in views)
echo Csrf::field(); // <input type="hidden" name="csrf_token" value="...">

// Validate the token submitted with a form or API request
if (!Csrf::validate($token)) {
    return $this->error('Invalid CSRF token');
}

Testing

Unit Tests

Write unit tests:

use PHPUnit\Framework\TestCase;

class UserTest extends TestCase
{
    public function testUserCreation()
    {
        $user = User::create([
            'username' => 'testuser',
            'email' => 'test@example.com'
        ]);
        $this->assertNotNull($user);
    }
}

Integration Tests

Test integrations:

public function testApiEndpoint()
{
    $response = $this->get('/api/discussions');
    $this->assertEquals(200, $response->getStatusCode());
}

Performance

Caching

Use caching:

use App\Core\Cache;

// Set cache
Cache::set('key', $data, 3600);

// Get cache
$data = Cache::get('key');

// Clear cache
Cache::clear('key');

Database Optimization

Optimize queries:

// Use indexes
// Limit results
// Avoid N+1 queries
// Use transactions

Contributing

Code Contribution

  1. Fork Repository - Fork on GitHub
  2. Create Branch - Create feature branch
  3. Write Code - Follow coding standards
  4. Test - Write and run tests
  5. Submit PR - Submit pull request

Documentation

  • Code Comments - Add helpful comments
  • PHPDoc - Document functions and classes
  • README - Update README if needed
  • Changelog - Update changelog

Version Compatibility

Resources

Last updated: May 31, 2026