Создание PHP-модуля для контент-менеджмента: подходы и код
Основные подходы к реализации раздела управления контентом на PHP
Наиболее эффективное решение: связка PDO + шаблонизатор Twig
Данный подход обеспечивает безопасность (защита от SQL-инъекций через параметризованные запросы), удобство поддержки и гибкость. База данных MySQL или PostgreSQL, подключение через PDO, шаблоны отделены от логики.
Как создать защищённый CRUD-контроллер для статей?
Пример структуры: файл config/database.php с настройками, ArticleRepository.php для запросов, ArticleController.php для обработки запросов, шаблоны в папке templates/.
// config/database.php
$host = 'localhost';
$dbname = 'cms_db';
$user = 'root';
$pass = '';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $user, $pass, $options);
} catch (PDOException $e) {
// Логирование ошибки, вывод сообщения пользователю
die('Ошибка подключения к базе данных');
}
// ArticleRepository.php
class ArticleRepository {
private PDO $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function getAll(): array {
$stmt = $this->pdo->query('SELECT id, title, created_at FROM articles ORDER BY created_at DESC');
return $stmt->fetchAll();
}
public function getById(int $id): ?array {
$stmt = $this->pdo->prepare('SELECT * FROM articles WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
public function create(string $title, string $content): bool {
$stmt = $this->pdo->prepare('INSERT INTO articles (title, content, created_at) VALUES (?, ?, NOW())');
return $stmt->execute([$title, $content]);
}
}
Типичные ошибки
- Использование устаревшего mysql_* - небезопасно, заменять на PDO.
- Неправильная кодировка: в PDO указывать charset=utf8mb4, иначе смайлы и спецсимволы потеряются.
- Отсутствие проверки прав доступа - любой пользователь сможет редактировать контент.
Решение: всегда применять параметризованные запросы, проверять токены CSRF и авторизацию.
Цель использования
Создание масштабируемой админ-панели с возможностью лёгкого расширения функционала (категории, теги, медиафайлы).
Как хранить контент без базы данных, только файлами?
Использование JSON-файлов для хранения статей. Подходит для небольших проектов, где нет сложных запросов.
// data/articles.json
[
{
"id": 1,
"title": "Первая статья",
"content": "Текст статьи...",
"created_at": "2025-04-01"
}
]
// ArticleFileRepository.php
class ArticleFileRepository {
private string $filePath;
public function __construct(string $filePath) {
$this->filePath = $filePath;
}
private function load(): array {
if (!file_exists($this->filePath)) {
return [];
}
$content = file_get_contents($this->filePath);
return json_decode($content, true) ?? [];
}
private function save(array $data): void {
file_put_contents($this->filePath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
public function getAll(): array {
return $this->load();
}
public function getById(int $id): ?array {
$articles = $this->load();
foreach ($articles as $article) {
if ($article['id'] === $id) {
return $article;
}
}
return null;
}
public function create(string $title, string $content): void {
$articles = $this->load();
$newId = count($articles) > 0 ? max(array_column($articles, 'id')) + 1 : 1;
$articles[] = [
'id' => $newId,
'title' => $title,
'content' => $content,
'created_at' => date('Y-m-d H:i:s')
];
$this->save($articles);
}
}
Проблемы
- Гонка состояний (race condition) при параллельных запросах - данные могут перезаписываться с потерями.
- Отсутствие поиска и фильтрации без полной загрузки файла.
- Сложность масштабирования при большом количестве записей.
Решение: использовать файловую блокировку flock() для записи, применять при малых нагрузках.
Цель использования
Быстрый старт без настройки сервера БД, прототипирование, статические сайты с простым редактированием.
Как использовать легковесную базу данных без установки сервера?
SQLite - файловая БД, подходит для проектов с одним пользователем или малым трафиком.
// config/sqlite.php
$dbPath = __DIR__ . '/../data/cms.sqlite';
$pdo = new PDO("sqlite:$dbPath");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
created_at TEXT DEFAULT (datetime("now"))
)');
// ArticleSqliteRepository.php (аналогично PDO)
class ArticleSqliteRepository {
private PDO $pdo;
public function __construct(PDO $pdo) { $this->pdo = $pdo; }
// методы одинаковы, только SQL может использовать datetime('now') вместо NOW()
}
Ошибки
- Права на запись в папку с файлом БД (обычно нужны 777 или владелец - веб-сервер).
- Одновременное изменение из нескольких потоков - SQLite блокирует всю БД при записи.
- Сложность полнотекстового поиска (требуется FTS5).
Цель использования
Портативные приложения, встроенные панели, окружения без поддержки MySQL (например, shared hosting с PHP 8).
Как упростить работу с БД, не изучая SQL?
ORM-библиотеки вроде RedBeanPHP или Eloquent (через Illuminate/Database).
// RedBeanPHP
require 'rb.php';
R::setup('mysql:host=localhost;dbname=cms', 'root', '');
// Получить все статьи
$articles = R::findAll('articles', 'ORDER BY created_at DESC');
// Создать статью
$article = R::dispense('articles');
$article->title = 'Новая статья';
$article->content = 'Текст';
$id = R::store($article);
Типичные проблемы
- Сложно отлаживать запросы - ORM генерирует SQL, который не очевиден.
- Избыточное число запросов (N+1) при связях.
- Зависимость от версии библиотеки, обновления могут ломать код.
Решение: использовать ORM осознанно, профилировать запросы, не прятать сложную логику.
Цель использования
Быстрая разработка прототипов, уменьшение шаблонного кода, автоматизация миграций.
Расширенные примеры реализации раздела управления контентом
1. Асинхронное добавление статьи через AJAX
Описание
Отправка формы без перезагрузки страницы, обработка на PHP, возврат JSON с результатом.
// save_article_ajax.php
require 'config/database.php';
require 'ArticleRepository.php';
$repo = new ArticleRepository($pdo);
$title = $_POST['title'] ?? '';
$content = $_POST['content'] ?? '';
if (!$title || !$content) {
echo json_encode(['success' => false, 'message' => 'Заполните все поля']);
exit;
}
$repo->create($title, $content);
echo json_encode(['success' => true, 'message' => 'Статья добавлена']);
// frontend.js (jQuery для краткости)
$('#articleForm').on('submit', function(e) {
e.preventDefault();
$.post('save_article_ajax.php', $(this).serialize(), function(response) {
if (response.success) {
alert('Статья сохранена');
location.reload();
} else {
alert(response.message);
}
}, 'json');
});
// Ответ сервера
{"success":true,"message":"Статья добавлена"}
2. Загрузка и привязка изображений к статье
Описание
Сохранение загруженного файла, запись пути в БД, вывод в шаблоне.
// upload.php
if ($_FILES['image']['error'] === UPLOAD_ERR_OK) {
$uploadDir = __DIR__ . '/uploads/';
$fileName = uniqid() . '_' . basename($_FILES['image']['name']);
$filePath = $uploadDir . $fileName;
if (move_uploaded_file($_FILES['image']['tmp_name'], $filePath)) {
// сохраняем путь к файлу в БД вместе с остальными данными статьи
$imagePath = '/uploads/' . $fileName;
// ... запись в таблицу articles (поле image_path)
}
}
// В шаблоне Twig
<img src="{{ article.image_path }}" alt="{{ article.title }}" class="img-fluid">
Возможные проблемы
- Проверка типа файла - не доверять расширению, использовать mime_content_type или finfo.
- Безопасность - не допускать загрузки .php файлов, хранить за пределами document_root.
- Размер файла - настроить upload_max_filesize и post_max_size в php.ini.
3. Генерация человекопонятных URL (ЧПУ) на основе заголовка
Описание
Транслитерация и создание slug, сохранение в БД, маршрутизация через mod_rewrite или PHP-роутер.
// Функция транслитерации
function translit($text) {
$ru = ['а','б','в','г','д','е','ё','ж','з','и','й','к','л','м','н','о','п','р','с','т','у','ф','х','ц','ч','ш','щ','ъ','ы','ь','э','ю','я'];
$en = ['a','b','v','g','d','e','yo','zh','z','i','y','k','l','m','n','o','p','r','s','t','u','f','h','ts','ch','sh','shch','','y','','e','yu','ya'];
$text = str_replace($ru, $en, mb_strtolower($text, 'UTF-8'));
$text = preg_replace('/[^a-z0-9]+/', '-', $text);
$text = trim($text, '-');
return $text;
}
$slug = translit($title);
// Проверить уникальность, добавить суффикс при совпадении
// .htaccess (Apache)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^article/([a-z0-9-]+)$ article.php?slug=$1 [L,QSA]
// article.php
$slug = $_GET['slug'] ?? '';
$stmt = $pdo->prepare('SELECT * FROM articles WHERE slug = ?');
$stmt->execute([$slug]);
$article = $stmt->fetch();
// отображение
4. Кэширование вывода списка статей
Описание
Сохранение сгенерированного HTML в файл для ускорения загрузки, с очисткой кэша при изменении контента.
// CacheHelper.php
class CacheHelper {
private string $cacheDir;
public function __construct(string $cacheDir) {
$this->cacheDir = rtrim($cacheDir, '/') . '/';
}
public function get(string $key): ?string {
$file = $this->cacheDir . md5($key) . '.cache';
if (file_exists($file) && filemtime($file) > time() - 3600) { // 1 час
return file_get_contents($file);
}
return null;
}
public function set(string $key, string $content): void {
file_put_contents($this->cacheDir . md5($key) . '.cache', $content);
}
public function clear(string $prefix = ''): void {
$files = glob($this->cacheDir . md5($prefix) . '*');
foreach ($files as $file) {
unlink($file);
}
}
}
// Использование
$cache = new CacheHelper(__DIR__ . '/cache');
$html = $cache->get('article_list');
if (!$html) {
ob_start();
// вывод списка
$html = ob_get_clean();
$cache->set('article_list', $html);
}
echo $html;
Результат
При повторном запросе HTML берётся из кэша, нагрузка на БД снижается.