Data Storage & Security
Data storage
Never write into the CMS core's /data/. Mirror the core's own split-file pattern under your own folder:
plugins/my-plugin/data/ ← your JSON data files
plugins/my-plugin/private/ ← secrets — .htaccess-protected
Create both on demand — not shipped in your distributed ZIP — with .htaccess protection written alongside them. Always rewrite .htaccess unconditionally; don't skip the write with a file_exists() check, since a directory that was deleted and manually recreated would otherwise be left unprotected:
function my_plugin_ensure_dirs(): void
{
$dir = __DIR__ . '/data';
if (!is_dir($dir)) mkdir($dir, 0755, true);
// Always rewrite — never skip with file_exists()
file_put_contents($dir . '/.htaccess',
"<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n" .
"<IfModule !mod_authz_core.c>\n Deny from all\n</IfModule>\n"
);
}
Use atomic writes for anything written after install-time setup, to avoid corrupting a file if the PHP process is interrupted mid-write:
function my_plugin_write_json(string $path, array $data): bool
{
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) return false;
$tmp = $path . '.tmp';
if (file_put_contents($tmp, $json, LOCK_EX) === false) return false;
return rename($tmp, $path);
}
Session and authentication
Don't build a separate login system for your plugin's admin screens — reuse the CMS's own admin session:
function my_plugin_admin_is_logged_in(): bool
{
return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
}
Anyone authenticated in the CMS admin is authenticated for your plugin's admin actions too, and logging out of one logs out of both — it's the same PHP session. If your plugin has a standalone POST endpoint (e.g. admin/actions.php) that doesn't go through the ?action=plugin_page router, start the session yourself with the same cookie hardening the core uses:
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'httponly' => true,
'samesite' => 'Lax',
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
]);
session_start();
}
CSRF protection
Any state-changing action (form submission, admin action) needs its own CSRF token. For admin-only actions — already behind the login check — reusing $_SESSION['csrf_token'], the same token the core admin panel uses, is simpler and sufficient.
Public-facing forms (anonymous visitors, no session to rely on) need a stateless HMAC-based token instead:
function my_plugin_generate_csrf(): string
{
$secret = my_plugin_get_secret(); // random bytes, generated once, stored in private/
$timestamp = time();
$signature = hash_hmac('sha256', (string)$timestamp, $secret);
return base64_encode($timestamp . '|' . $signature);
}
function my_plugin_verify_csrf(string $token, int $ttlSeconds = 7200): bool
{
$decoded = base64_decode($token, true);
if ($decoded === false) return false;
[$timestamp, $signature] = array_pad(explode('|', $decoded, 2), 2, '');
if ($timestamp === '' || $signature === '') return false;
if ((time() - (int)$timestamp) > $ttlSeconds) return false;
return hash_equals(hash_hmac('sha256', $timestamp, my_plugin_get_secret()), $signature);
}
Internationalization
Follow the CMS's active_language (front-end) and admin_language (admin panel) settings automatically. Read config.json directly rather than depending on the core's __t() being loaded in every context your plugin runs in — public endpoints don't load the full front-end bootstrap:
function my_plugin_current_locale(string $context = 'front'): string
{
$configFile = dirname(__DIR__, 2) . '/config.json'; // adjust to your depth
if (file_exists($configFile)) {
$decoded = json_decode(file_get_contents($configFile), true);
if (is_array($decoded)) {
if ($context === 'admin' && !empty($decoded['admin_language'])) return $decoded['admin_language'];
if (!empty($decoded['active_language'])) return $decoded['active_language'];
}
}
return 'en';
}
function my_plugin_t(string $key, string $fallback = '', string $context = 'front'): string
{
static $cache = [];
$locale = my_plugin_current_locale($context);
if (!isset($cache[$context][$locale])) {
$path = __DIR__ . '/lang/' . ($context === 'admin' ? 'admin/' : '') . $locale . '.json';
$cache[$context][$locale] = file_exists($path)
? (json_decode(file_get_contents($path), true) ?: [])
: [];
}
return $cache[$context][$locale][$key] ?? $fallback;
}
Ship translations for at least English, French, and Spanish, matching what the core itself ships with — never hardcode a user-visible string.
