Разработка каталога товаров на PHP: практические примеры и советы

Раздел: Разработка каталогов на PHP -> Товарный каталог

Введение в разработку каталога товаров на PHP

Цель данной статьи - рассмотреть различные подходы к созданию каталога товаров на PHP, от простых до промышленных, с подробными примерами кода и разбором типичных проблем.

Основное решение: каталог на PDO с архитектурой MVC (модель-представление-контроллер)

Этот вариант подходит для проектов, где требуется гибкость, безопасность и масштабируемость. Основная идея - отделить логику работы с данными (модель) от отображения (представление) и управления запросами (контроллер).

Структура файлов


project/
  index.php          (точка входа, фронт-контроллер)
  config.php         (настройки БД)
  controllers/
    ProductController.php
  models/
    Product.php
  views/
    catalog.php
    product.php

Шаг 1: настройка подключения к базе данных (config.php)


<?php
return [
    'dsn'      => 'mysql:host=localhost;dbname=shop;charset=utf8',
    'username' => 'root',
    'password' => '',
    'options'  => [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ],
];

Шаг 2: модель Product (models/Product.php)


<?php
class Product {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    // Получить все товары с пагинацией
    public function getAll(int $limit = 10, int $offset = 0): array {
        $stmt = $this->pdo->prepare('SELECT * FROM products ORDER BY id DESC LIMIT :limit OFFSET :offset');
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll();
    }

    // Получить общее количество товаров
    public function getCount(): int {
        return (int) $this->pdo->query('SELECT COUNT(*) FROM products')->fetchColumn();
    }

    // Фильтрация по категории
    public function getByCategory(int $categoryId, int $limit = 10, int $offset = 0): array {
        $stmt = $this->pdo->prepare('SELECT * FROM products WHERE category_id = :cat ORDER BY id DESC LIMIT :limit OFFSET :offset');
        $stmt->bindValue(':cat', $categoryId, PDO::PARAM_INT);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll();
    }
}

Шаг 3: контроллер (controllers/ProductController.php)


<?php
require_once __DIR__ . '/../models/Product.php';

class ProductController {
    private Product $productModel;
    private int $limit = 10;

    public function __construct(PDO $pdo) {
        $this->productModel = new Product($pdo);
    }

    public function catalogAction(): void {
        $page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
        $offset = ($page - 1) * $this->limit;

        $products = $this->productModel->getAll($this->limit, $offset);
        $total = $this->productModel->getCount();
        $totalPages = ceil($total / $this->limit);

        require __DIR__ . '/../views/catalog.php';
    }

    public function categoryAction(int $categoryId): void {
        $page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
        $offset = ($page - 1) * $this->limit;

        $products = $this->productModel->getByCategory($categoryId, $this->limit, $offset);
        $total = $this->productModel->getCount(); // в реальности нужно отдельное количество по категории
        $totalPages = ceil($total / $this->limit);

        require __DIR__ . '/../views/catalog.php';
    }
}

Шаг 4: представление (views/catalog.php)


<!DOCTYPE html>
<html>
<head><title>Каталог товаров</title></head>
<body>
<h2>Каталог товаров</h2>
<div class="products">
    <?php foreach ($products as $product): ?>
        <div class="product-item">
            <h3><?= htmlspecialchars($product['name']) ?></h3>
            <p>Цена: <?= number_format($product['price'], 2) ?> руб.</p>
            <p><?= nl2br(htmlspecialchars(substr($product['description'], 0, 100))) ?>...</p>
        </div>
    <?php endforeach; ?>
</div>
<div class="pagination">
    <?php for ($i = 1; $i <= $totalPages; $i++): ?>
        <a href="?page=<?= $i ?>"><?= $i ?></a>
    <?php endfor; ?>
</div>
</body>
</html>

Типичные ошибки и их решение

Проблема: SQL-инъекция при ручной подстановке переменных в запрос.

Решение: всегда использовать подготовленные выражения (prepare/execute) с привязкой параметров через bindValue или bindParam.

Проблема: медленная работа при большом количестве товаров из-за отсутствия индексов.

Решение: добавить индексы в БД на поля category_id, price, дату создания. Для пагинации использовать LIMIT с OFFSET (или ключевой набор при больших объёмах).

Проблема: XSS-уязвимости при выводе товаров.

Решение: все данные, полученные от пользователя или из БД, экранировать через htmlspecialchars().

Вариант 1: Каталог на чистом MySQLi (процедурный стиль)

Вопрос: как сделать простой каталог товаров без ООП, используя только встроенные функции MySQLi?


$mysqli = new mysqli('localhost', 'root', '', 'shop');
$res = $mysqli->query('SELECT * FROM products LIMIT 10');
while ($row = $res->fetch_assoc()) {
    echo '<div>' . htmlspecialchars($row['name']) . '</div>';
}

Данный подход прост для понимания, но не защищает от SQL-инъекций (необходимо ручное экранирование через mysqli_real_escape_string). Рекомендуется только для учебных проектов или очень простых скриптов.

Проблема: отсутствие автоматического экранирования данных.

Решение: для каждого пользовательского ввода применять mysqli_real_escape_string или перейти на PDO.

Вариант 2: Каталог на основе JSON-файла

Вопрос: как организовать каталог товаров без базы данных, используя файл JSON для хранения данных?


$json = file_get_contents('products.json');
$products = json_decode($json, true);
$limit = 10;
$page = $_GET['page'] ?? 1;
$offset = ($page - 1) * $limit;
$pageProducts = array_slice($products, $offset, $limit);

Вариант подходит для небольших (до нескольких тысяч) товаров, для статических сайтов или прототипов. Главный недостаток - отсутствие возможности фильтрации по категориям без полного перебора массива.

Проблема: нагрузка на память при большом JSON-файле.

Решение: использовать потоковое чтение или разбить данные на несколько файлов по категориям.

Вариант 3: Использование готовой CMS (WordPress с WooCommerce)

Вопрос: как быстро получить функциональный каталог товаров без глубокого программирования на PHP?

Установка WordPress + плагин WooCommerce даёт полный интерфейс управления товарами, корзину, оплату. Для нестандартной логики потребуется написание плагинов или доработка через хуки. Это решение для бизнеса, где скорость выхода на рынок важнее гибкости.

Проблема: сложность тонкой настройки производительности.

Решение: использовать кэширование (WP Super Cache), оптимизировать запросы через индексы и плагины для ускорения.

Вариант 4: Каталог на фреймворке Laravel (Eloquent ORM)

Вопрос: как построить каталог товаров с использованием современного MVC-фреймворка, обеспечивающего удобную работу с БД и встроенную пагинацию?


// Маршрут
Route::get('/catalog', [ProductController::class, 'index']);

// Контроллер
public function index(Request $request) {
    $products = Product::paginate(10);
    return view('catalog', compact('products'));
}

// Представление (Blade)
@foreach($products as $product)
    <div>{{ $product->name }}</div>
@endforeach
{{ $products->links() }}

Laravel предоставляет готовые решения для фильтрации, сортировки и пагинации. Однако требует изучения фреймворка и относительно тяжёл для простых задач.

Проблема: избыточность для маленьких проектов.

Решение: выбирать Laravel только если проект планируется расширять, иначе использовать более лёгкие решения, например Slim Framework.

Вариант 5: Реализация фильтрации через AJAX и JSON-ответ контроллера

Вопрос: как сделать динамическую фильтрацию товаров без перезагрузки страницы?


// PHP-обработчик (ajax.php)
$category = $_GET['category'] ?? '';
$minPrice = $_GET['min_price'] ?? 0;
$maxPrice = $_GET['max_price'] ?? PHP_INT_MAX;

$stmt = $pdo->prepare('SELECT * FROM products WHERE (category_id = :cat OR :cat = "") AND price BETWEEN :min AND :max');
$stmt->execute(['cat' => $category, 'min' => $minPrice, 'max' => $maxPrice]);
$products = $stmt->fetchAll();
echo json_encode($products);

Фронтенд отправляет GET-запрос с параметрами, получает JSON и обновляет DOM. Такой подход улучшает пользовательский опыт, но требует дополнительной работы на клиенте.

Проблема: обработка большого количества одновременных запросов.

Решение: добавить кэширование результатов фильтрации (например, через Redis) и ограничить частоту запросов.

Цели и случаи использования каждого варианта

  • PDO + MVC - основной вариант для средних и крупных проектов, где важны безопасность, поддержка и тестирование.
  • MySQLi процедурный - для быстрого прототипирования или обучения, когда не требуется ООП.
  • JSON-файл - для хобби-проектов, статических сайтов, где данные обновляются редко.
  • WordPress/WooCommerce - для коммерческих интернет-магазинов с минимальным временем разработки.
  • Laravel - для enterprise-решений, где команда знакома с фреймворком, а проект требует сложной бизнес-логики.
  • AJAX-фильтрация - улучшает UX в любом варианте, когда нужно быстро обновлять список товаров.

Расширенные примеры программного кода

Пример 1: Полноценный контроллер с фильтрацией по категории, цене и поиску

Данный пример показывает, как объединить несколько параметров в один запрос, используя PDO и динамическое построение WHERE.

Пример

<?php
// models/AdvancedProduct.php
class AdvancedProduct {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function search(array $filters, int $limit, int $offset): array {
        $where = [];
        $params = [];

        if (!empty($filters['category_id'])) {
            $where[] = 'category_id = :cat';
            $params[':cat'] = (int)$filters['category_id'];
        }
        if (!empty($filters['min_price'])) {
            $where[] = 'price >= :min';
            $params[':min'] = (float)$filters['min_price'];
        }
        if (!empty($filters['max_price'])) {
            $where[] = 'price <= :max';
            $params[':max'] = (float)$filters['max_price'];
        }
        if (!empty($filters['search'])) {
            $where[] = '(name LIKE :search OR description LIKE :search)';
            $params[':search'] = '%' . $filters['search'] . '%';
        }

        $sql = 'SELECT * FROM products';
        if ($where) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY id DESC LIMIT :limit OFFSET :offset';

        $stmt = $this->pdo->prepare($sql);
        foreach ($params as $key => $value) {
            $stmt->bindValue($key, $value);
        }
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll();
    }

    public function countSearch(array $filters): int {
        $where = [];
        $params = [];
        // аналогично формирование WHERE
        ...
        $sql = 'SELECT COUNT(*) FROM products';
        if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
        $stmt = $this->pdo->prepare($sql);
        foreach ($params as $key => $value) $stmt->bindValue($key, $value);
        $stmt->execute();
        return (int)$stmt->fetchColumn();
    }
}

Результат работы (пример данных):

Массив из 2 товаров:
[
  ['id' => 5, 'name' => 'Футболка', 'price' => 1200, 'category_id' => 1],
  ['id' => 3, 'name' => 'Шорты', 'price' => 800, 'category_id' => 1]
]

Пример 2: Пагинация с использованием курсора (keyset pagination) для больших таблиц

Классическая пагинация через LIMIT/OFFSET плохо работает на миллионах строк. Альтернатива - запрос с условием на ID.

Пример

// Получить товары с ID меньше последнего из предыдущей страницы
$lastId = $_GET['last_id'] ?? PHP_INT_MAX;
$stmt = $pdo->prepare('SELECT * FROM products WHERE id < :last_id ORDER BY id DESC LIMIT 10');
$stmt->execute([':last_id' => (int)$lastId]);
$products = $stmt->fetchAll();

// Для следующей страницы передаём ID последнего элемента
$nextLastId = end($products)['id'] ?? 0;
echo '<a href="?last_id=' . $nextLastId . '">Далее</a>';

Результат:

Страница 1: товары с ID 100..91
Страница 2: товары с ID 90..81

Пример 3: Кэширование результатов каталога с помощью файлового кэша

Для снижения нагрузки на БД можно кэшировать страницу каталога.

Пример

$cacheKey = 'catalog_page_' . ($_GET['page'] ?? 1);
$cacheFile = __DIR__ . '/cache/' . md5($cacheKey) . '.html';
$cacheTime = 3600; // 1 час

if (file_exists($cacheFile) && time() - filemtime($cacheFile) < $cacheTime) {
    readfile($cacheFile);
    exit;
}

ob_start();
// ... обычный вывод каталога ...
$html = ob_get_clean();
file_put_contents($cacheFile, $html);
echo $html;

Результат:

При первом запросе страница генерируется и сохраняется в файл. При повторных запросах в течение часа отдаётся сохранённый HTML, ускоряя загрузку в десятки раз.

Пример 4: Вывод товаров с вложенными категориями (рекурсивный запрос для дерева)

Пример

// Предполагается таблица categories с полями id, parent_id
function getCategoryTree($parentId = 0): array {
    global $pdo;
    $stmt = $pdo->prepare('SELECT * FROM categories WHERE parent_id = :pid');
    $stmt->execute([':pid' => $parentId]);
    $categories = $stmt->fetchAll();
    foreach ($categories as &$cat) {
        $cat['children'] = getCategoryTree($cat['id']);
    }
    return $categories;
}

$tree = getCategoryTree();
foreach ($tree as $cat) {
    echo '<li>' . htmlspecialchars($cat['name']);
    if (!empty($cat['children'])) {
        echo '<ul>';
        // рекурсивный вывод
        echo '</ul>';
    }
    echo '</li>';
}

Результат (структура HTML):

<ul>
  <li>Одежда
    <ul><li>Футболки</li><li>Шорты</li></ul>
  </li>
  <li>Обувь
    <ul><li>Кроссовки</li></ul>
  </li>
</ul>

Пример 5: Обработка изображений товаров (загрузка и ресайз)

Пример

// Загрузка файла
$uploadDir = __DIR__ . '/uploads/';
$filename = uniqid() . '_' . basename($_FILES['image']['name']);
move_uploaded_file($_FILES['image']['tmp_name'], $uploadDir . $filename);

// Ресайз до 300x300 (требуется GD)
$src = imagecreatefromjpeg($uploadDir . $filename);
$dst = imagescale($src, 300, 300);
imagejpeg($dst, $uploadDir . 'thumb_' . $filename, 80);
imagedestroy($src); imagedestroy($dst);

Результат:

В папке uploads появятся два файла: исходное изображение и его уменьшенная копия с префиксом thumb_.

Каталог товаров PHP - comments

En
Catalog products php (php)