Simple Flat-File CMS Implementation
A comprehensive guide using procedural PHP, Bootstrap, Datatables, CKEditor, and JavaScript
Highlights
- Flat-File Storage and Theme System: Organize content into categories, pages, and articles stored as files and switch themes via a configuration file.
- Protected Admin Panel: An authentication-based admin area that restricts access to content management pages.
- Dynamic Content Management: Use procedural PHP, integrated with Bootstrap for UI, Datatables for interactive tables, CKEditor for rich text editing, and JavaScript enhancements.
Overview
This guide presents a detailed example of a flat-file Content Management System (CMS) developed with procedural PHP. It exploits a flat-file storage method by saving the content as JSON or Markdown files and supports categories, pages, and articles. The theme system is configured with a global variable defined in a configuration file. The admin panel is secured using PHP sessions and includes the ability to create, edit, and delete different types of content. The interface is styled using Bootstrap 5.3.3, and tables are rendered using Datatables 2.2.2. Rich text editing for articles and pages is enabled via CKEditor 5, while pure JavaScript assists with additional user interactions.
Project Structure
The CMS is organized into several directories to separate configuration, content, themes, and administrative files:
| Directory/File | Purpose |
|---|---|
| config.php | Holds configuration data and active theme setting. |
| /themes/ | Contains different site themes. The active theme is selected by the $theme variable. |
| /admin/ | Protected folder featuring login.php and content management pages. |
| /content/ | Contains subfolders for categories, articles, and pages using flat-file storage. |
| index.php | Front-end entry point for the website (integrates the selected theme). |
Configuration and Setup
The Config File (config.php)
The configuration file holds global settings such as the active theme, administrator credentials, and the storage directories for content. Save the following as config.php in the includes/ directory:
<?php
// Site configuration settings
$config = [
'site_title' => 'Simple Flat-File CMS',
'theme' => 'default', // Active theme name
'admin_username' => 'admin',
'admin_password' => password_hash('admin123', PASSWORD_DEFAULT), // Secure password hash
'content_dir' => __DIR__ . '/../content/',
'date_format' => 'F j, Y',
];
// Define directory constants for ease of access
define('CONTENT_DIR', $config['content_dir']);
define('CATEGORIES_DIR', CONTENT_DIR . 'categories/');
define('ARTICLES_DIR', CONTENT_DIR . 'articles/');
define('PAGES_DIR', CONTENT_DIR . 'pages/');
define('THEME_DIR', __DIR__ . '/../themes/' . $config['theme'] . '/');
// Ensure that content directories exist
foreach ([CONTENT_DIR, CATEGORIES_DIR, ARTICLES_DIR, PAGES_DIR] as $dir) {
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
}
?>
Theme System
The CMS supports multiple themes stored in the /themes/ directory. The active theme is determined by the “theme” setting in config.php. Each theme should include standard files like header.php, footer.php, and index.php for layout. For example, /themes/default/index.php can be used for the main website layout.
Admin Panel
Authentication and Protected Access
Secure administration is achieved through an authentication process using PHP sessions. Place all administrative files within the /admin directory and use the following login script.
Login Script (admin/login.php)
<?php
session_start();
require_once '../includes/config.php';
// Check if form is submitted
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Load user credentials from the configuration
if (isset($_POST['username'], $_POST['password']) &&
$_POST['username'] === $config['admin_username'] &&
password_verify($_POST['password'], $config['admin_password'])) {
$_SESSION['logged_in'] = true;
header('Location: index.php');
exit;
} else {
$error = 'Invalid username or password';
}
}
// Redirect authenticated user to dashboard
if (isset($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Admin Login</h3>
</div>
<div class="card-body">
<?php if (isset($error)): ?>
<div class="alert alert-danger"><?php echo $error; ?></div>
<?php endif; ?>
<form method="post">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Dashboard and Admin Navigation
The admin dashboard provides links to manage categories, articles, and pages. Once a user is authenticated, they can access a dashboard similar to the one below. Save this file as admin/index.php.
Admin Dashboard (admin/index.php)
<?php
session_start();
if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
require_once '../includes/config.php';
require_once '../includes/functions.php';
// Count content for dashboard display
$categories_count = count(getCategories());
$articles_count = count(getArticles());
$pages_count = count(getPages());
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Simple CMS</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
</head>
<body>
<header class="bg-dark text-white pb-2">
<div class="container">
<nav class="navbar navbar-expand-lg navbar-dark">
<a class="navbar-brand" href="index.php">Simple CMS</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="categories.php">Categories</a></li>
<li class="nav-item"><a class="nav-link" href="articles.php">Articles</a></li>
<li class="nav-item"><a class="nav-link" href="pages.php">Pages</a></li>
</ul>
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="../" target="_blank">View Site</a></li>
<li class="nav-item"><a class="nav-link" href="logout.php">Logout</a></li>
</ul>
</div>
</nav>
</div>
</header>
<div class="container py-4">
<h1 class="mb-4">Dashboard</h1>
<div class="row">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<i class="bi bi-folder" style="font-size: 3rem;"></i>
<h2 class="my-3"><?php echo $categories_count; ?></h2>
<p>Categories</p>
<a href="categories.php" class="btn btn-primary">Manage</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<i class="bi bi-file-earmark-text" style="font-size: 3rem;"></i>
<h2 class="my-3"><?php echo $articles_count; ?></h2>
<p>Articles</p>
<a href="articles.php" class="btn btn-success">Manage</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<i class="bi bi-file-earmark" style="font-size: 3rem;"></i>
<h2 class="my-3"><?php echo $pages_count; ?></h2>
<p>Pages</p>
<a href="pages.php" class="btn btn-warning">Manage</a>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Content Management
In the admin section, three main content types are supported:
- Categories – Each category is stored as a JSON file in the /content/categories/ folder.
- Articles – Articles are saved as Markdown or JSON files in /content/articles/.
- Pages – Pages are stored similarly in /content/pages/.
Core Functions (includes/functions.php)
To facilitate file operations (create, read, update, delete) for our content, create a helper file that defines these functions.
<?php
// Generate a URL-friendly slug based on a string.
function create_slug($string) {
return strtolower(trim(preg_replace('/[^a-zA-Z0-9-]+/', '-', $string), '-'));
}
// Save content as a JSON file.
function save_content($dir, $slug, $data) {
$file = $dir . $slug . '.json';
return file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
// Retrieve content data by slug.
function get_content($dir, $slug) {
$file = $dir . $slug . '.json';
if (file_exists($file)) {
return json_decode(file_get_contents($file), true);
}
return null;
}
// Delete a content file.
function delete_content($dir, $slug) {
$file = $dir . $slug . '.json';
return file_exists($file) ? unlink($file) : false;
}
// List all content files from a directory.
function list_content($dir) {
$items = [];
if (is_dir($dir)) {
foreach (glob($dir . '*.json') as $file) {
$slug = basename($file, '.json');
$items[$slug] = get_content($dir, $slug);
}
}
return $items;
}
// Specific helper functions for categories, articles, and pages.
function getCategories() { return list_content(CATEGORIES_DIR); }
function getArticles() { return list_content(ARTICLES_DIR); }
function getPages() { return list_content(PAGES_DIR); }
?>
Category Management (admin/categories.php)
The following example shows how to create and edit categories. New categories are created by providing a name; if no slug is provided, one is generated automatically.
<?php
session_start();
if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
require_once '../includes/config.php';
require_once '../includes/functions.php';
$action = $_GET['action'] ?? 'list';
$message = '';
$category = null;
// Handle category form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$description = trim($_POST['description'] ?? '');
if (empty($name)) {
$message = 'Category name is required';
} else {
$slug = $_POST['slug'] ? trim($_POST['slug']) : create_slug($name);
$data = [
'name' => $name,
'slug' => $slug,
'description' => $description,
'updated' => date('Y-m-d H:i:s')
];
if (!isset($_POST['id'])) {
$data['created'] = date('Y-m-d H:i:s');
}
if (save_content(CATEGORIES_DIR, $slug, $data)) {
header('Location: categories.php?message=Category saved successfully');
exit;
} else {
$message = 'Error saving category';
}
}
}
// Handling deletions
if ($action === 'delete' && isset($_GET['slug'])) {
if (delete_content(CATEGORIES_DIR, $_GET['slug'])) {
header('Location: categories.php?message=Category deleted successfully');
exit;
} else {
$message = 'Error deleting category';
}
}
// Editing a category
if ($action === 'edit' && isset($_GET['slug'])) {
$category = get_content(CATEGORIES_DIR, $_GET['slug']);
if (!$category) {
header('Location: categories.php?message=Category not found');
exit;
}
}
$categories = getCategories();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Categories</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.datatables.net/2.2.2/css/dataTables.bootstrap5.min.css">
</head>
<body>
<?php include 'includes/header.php'; ?>
<div class="container py-4">
<?php if (!empty($message)): ?>
<div class="alert alert-info"><?php echo $message; ?></div>
<?php endif; ?>
<h1>Categories</h1>
<a href="?action=new" class="btn btn-primary mb-3">New Category</a>
<table id="categoriesTable" class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($categories as $cat): ?>
<tr>
<td><?php echo htmlspecialchars($cat['name']); ?></td>
<td><?php echo htmlspecialchars($cat['slug']); ?></td>
<td><?php echo htmlspecialchars($cat['description']); ?></td>
<td>
<a href="?action=edit&slug=<?php echo $cat['slug']; ?>" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="?action=delete&slug=<?php echo $cat['slug']; ?>" class="btn btn-sm btn-outline-danger" onclick="return confirm('Are you sure?')">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.2.2/js/dataTables.bootstrap5.min.js"></script>
<script>
$(document).ready(function(){
$('#categoriesTable').DataTable();
});
</script>
</body>
</html>
Similar structures and methods can be applied for articles and pages. In the articles management page, integrate CKEditor 5 for the rich text editing experience, and ensure that Datatables are used for listing and managing existing articles.
Using CKEditor 5 and Datatables for Articles
When managing articles, include CKEditor 5 by adding the script in the form page. Here is an example snippet for integrating CKEditor:
<textarea name="content" id="editor"></textarea>
<script src="https://cdn.ckeditor.com/ckeditor5/35.3.2/classic/ckeditor.js"></script>
<script>
ClassicEditor.create(document.querySelector('#editor')).catch(error => { console.error(error); });
</script>
DataTables enhances the admin listings by allowing search, pagination, and sorting. Its usage is similar to the category example provided.
Front-End Integration
The front-end of the website uses the active theme defined in config.php. The index.php file in the root typically includes header and footer from the theme folder. For example:
<?php
require_once 'includes/config.php';
$theme = $config['theme'];
include THEME_DIR . 'header.php';
// Here you can route to display pages, articles, etc.
include THEME_DIR . 'index.php';
include THEME_DIR . 'footer.php';
?>
Conclusion and Final Thoughts
This comprehensive guide has detailed the construction of a simple flat-file CMS built with procedural PHP. By leveraging Bootstrap 5.3.3 for styling, Datatables 2.2.2 for interactive content management tables, CKEditor 5 for rich text editing, and pure JavaScript where necessary, the CMS supports the creation, editing, and deletion of categories, articles, and pages. With a theme system that reads the active theme from a configuration file, the system remains highly flexible for front-end customization. The admin panel is secured via session-based authentication, ensuring that only authorized users can modify the site's content. This approach is particularly beneficial in scenarios where a lightweight, database-free solution is desired. The modular design allows for easy expansion, letting developers add new features or enhance security with minimal changes to the overall structure.
References
- Grav CMS - Get Grav
- Pico CMS - PicoCMS.org
- Statamic - Flat-File CMS
- CKEditor 5 - Official
- Bootstrap 5 - Official
Recommended
Last updated February 25, 2025