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 classesDirectory 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/ # DependenciesCore 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.mdPlugin 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:
| Hook | Use case |
|---|---|
router.plugins.register | Register plugin routes (preferred for plugins) |
app.routes.register | Register application-level routes |
view.header.styles | Inject CSS into <head> |
view.footer.scripts | Inject JS before </body> |
view.footer.content | Inject HTML into footer |
view.navbar.items | Add items to the main navigation bar |
view.admin.sidebar.items | Add items to the admin sidebar |
admin.dashboard.widgets | Add widgets to the admin dashboard |
discussion.created | After a discussion is saved |
post.created | After a reply is saved |
user.registered | After a user account is created |
search.results | Filter or augment search results |
notification.before.create | Intercept notifications before they are written |
markdown.editor.config | Modify the Markdown editor configuration |
upload.image.saved | Post-process an uploaded image (compress/convert) — since 5.7.2 |
visitor.page_info | Resolve page info for unknown URLs (presence) |
presence.users | Filter/enrich active users list |
presence.datacenter_ranges | Extend 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.phpCSS 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_urlconfig value (e.g.api/plugins/my-plugin→https://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"
}| Field | Required | Description |
|---|---|---|
version | Yes | Latest available version string (compared against plugin.json version) |
changelog_url | No | URL 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.
update_url does not point to the official Flatboard registry (versions.flatboard.org) are flagged with a warning in the admin updates page. This is expected for community plugins hosted on personal or third-party servers.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:
| Command | Since | Description |
|---|---|---|
markdown:rebuild | 5.0 | Re-render every post's Markdown to rendered_html. Run after upgrading a parser-affecting plugin. |
cleanup:unverified-users [days] | 5.5.0 | Delete 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-cacert | 5.3.7 | Download 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-cacert → renewCacert()). 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]);getUsersByGroup(array $groupIds) returns the members of one or more groups in a single indexed query, covering both the primary group (users.group_id) and additional groups (user_groups) and excluding soft-deleted users. Prefer it over loading every user and filtering in PHP. The model wrapper User::byGroup([...]) exposes the same call.If you maintain a third-party StorageInterface implementation, note that getUsersByGroup() is now part of the interface and must be implemented.
JsonStorage or SqliteStorage directly in a plugin. Always go through StorageFactory::create() so your plugin works on both Community and Pro installations.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',
]);BASE_PATH . '/stockage/json/<your-plugin>/' instead, and handle cleanup in your uninstall() method.StorageFactory | AtomicFileHelper | |
|---|---|---|
| Purpose | Core Flatboard data (users, discussions…) | Plugin-specific custom files |
| Community | ✓ (returns JsonStorage) | ✓ |
| Pro | ✓ (returns SqliteStorage) | ✓ |
| Backend-agnostic | Yes — same API on both editions | N/A (JSON files only) |
| Survives uninstall | Yes (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 transactionsContributing
Code Contribution
- Fork Repository - Fork on GitHub
- Create Branch - Create feature branch
- Write Code - Follow coding standards
- Test - Write and run tests
- 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
When developing plugins, themes, or customizations:
- Target Flatboard 5 - Code for Flatboard 5 architecture
- Not Compatible with v3/v4 - Code won't work on older versions
- Use Modern PHP - Take advantage of PHP 8 features
- Follow Architecture - Follow Flatboard 5 patterns
Resources
- Plugin Guide - Plugin development
- Theme Guide - Theme development
- API Documentation - API development
- GitHub Repository - Source code
Last updated: May 31, 2026