Пишем свой лёгкий магазин на нативном MVC Joomla 5. Без F0F, без ORM-прослоек, без «комбайнов». Только то, что реально нужно для каталога на тысячу SKU.
Когда я смотрю на VirtueMart или HikaShop, у меня возникает смешанное чувство. С одной стороны — это гигантские комбайны, которые решают всё. С другой — для магазина на 1000 товаров их архитектура часто оказывается избыточной. Ты платишь за это скоростью админки, весом запросов и сложностью кастомизации.
Основные причины:
Сразу оговорюсь: то, что мы делаем — это компонент, а не плагин в чистом виде Joomla. Плагин в Joomla — это обработчик событий. Магазин — это сложная логика с моделями, контроллерами и представлениями. Поэтому мы делаем компонент, но с минималистичной архитектурой.
Ключевая идея — не тащить в Joomla лишние абстракции. Мы будем использовать нативный MVC Joomla 5 и прямые запросы к БД через DatabaseDriver. Никаких прослоек вроде F0F или тяжёлых ORM.
Принцип: одна таблица товаров, одна таблица заказов, нативные контроллеры и модели Joomla. Всё остальное — по мере необходимости, а не «на всякий случай».
MyCompany\Component\Shop (важно для автозагрузки PSR-4)admin/ и site/ внутри корня компонентаshop.xml в корне с секцией <namespace>admin/sql/Вот как выглядит дерево компонента. Это стандартная раскладка Joomla 5: всё, что относится к админке — в admin/, всё, что к фронтенду — в site/.
com_shop/ ├── shop.xml # манифест установки ├── admin/ │ ├── access.xml # права доступа │ ├── config.xml # конфиг компонента │ ├── sql/ │ │ ├── install.mysql.sql │ │ └── uninstall.mysql.sql │ ├── src/ │ │ ├── Controller/ │ │ │ └── ProductsController.php │ │ ├── Model/ │ │ │ ├── ProductsModel.php │ │ │ └── ProductModel.php │ │ ├── Table/ │ │ │ └── ProductTable.php │ │ └── View/ │ │ └── Products/ │ │ └── HtmlView.php │ └── tmpl/ │ └── products/ │ └── default.php └── site/ ├── src/ │ ├── Controller/ │ │ ├── DisplayController.php │ │ └── CartController.php │ ├── Model/ │ │ └── CatalogModel.php │ └── View/ │ └── Catalog/ │ └── HtmlView.php └── tmpl/ └── catalog/ └── default.php
Этот файл говорит Joomla, что мы устанавливаем. Обрати внимание на <namespace> — без него Joomla 5 не поймёт, где искать твои классы.
<?xml version="1.0" encoding="utf-8"?>
<extension type="component" method="upgrade">
<name>COM_SHOP</name>
<creationDate>2026-10-19</creationDate>
<version>0.1.0</version>
<description>Лёгкий магазин для 1000 товаров</description>
<!-- Критически важно для Joomla 5 -->
<namespace path="src">MyCompany\Component\Shop</namespace>
<install>
<sql>
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
</sql>
</install>
<uninstall>
<sql>
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
</sql>
</uninstall>
<administration>
<menu img="class:cart">COM_SHOP</menu>
<files folder="admin">
<folder>services</folder>
<folder>src</folder>
<folder>sql</folder>
<folder>tmpl</folder>
<filename>access.xml</filename>
<filename>config.xml</filename>
</files>
</administration>
<files folder="site">
<folder>src</folder>
<folder>tmpl</folder>
</files>
</extension>
<namespace> компонент просто не загрузится. Это самая частая ошибка при портировании старых расширений.
Создаём таблицу товаров. Минимум полей для скорости — расширять будем по мере необходимости.
CREATE TABLE IF NOT EXISTS `#__shop_products` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`alias` varchar(255) NOT NULL,
`price` decimal(10,2) NOT NULL DEFAULT '0.00',
`sku` varchar(100) DEFAULT NULL,
`stock` int(11) NOT NULL DEFAULT '0',
`state` tinyint(1) NOT NULL DEFAULT '1',
`catid` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_state` (`state`),
KEY `idx_catid` (`catid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Индексы по state и catid — это то, что чаще всего используется в WHERE. Для 1000 товаров этого достаточно, чтобы выборка была мгновенной.
Здесь мы показываем, как достать 1000 товаров без тормозов. Используем нативный ListModel и getListQuery.
<?php
namespace MyCompany\Component\Shop\Administrator\Model;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
class ProductsModel extends ListModel
{
public function __construct($config = [])
{
if (empty($config['filter_fields'])) {
$config['filter_fields'] = [
'id', 'a.id',
'title', 'a.title',
'price', 'a.price',
'state', 'a.state'
];
}
parent::__construct($config);
}
protected function getListQuery()
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
// Выбираем только нужные поля
$query->select([
$db->quoteName(['a.id', 'a.title', 'a.price', 'a.sku', 'a.stock', 'a.state'])
])
->from($db->quoteName('#__shop_products', 'a'));
// Фильтр по статусу (публикация)
$state = $this->getState('filter.state');
if (is_numeric($state)) {
$query->where($db->quoteName('a.state') . ' = :state')
->bind(':state', $state, ParameterType::INTEGER);
}
// Поиск по названию
$search = $this->getState('filter.search');
if (!empty($search)) {
$search = '%' . str_replace(' ', '%', trim($search)) . '%';
$query->where($db->quoteName('a.title') . ' LIKE :search')
->bind(':search', $search);
}
// Сортировка
$orderCol = $this->state->get('list.ordering', 'a.id');
$orderDirn = $this->state->get('list.direction', 'DESC');
$query->order($db->escape($orderCol . ' ' . $orderDirn));
return $query;
}
}
Обрати внимание: мы не делаем SELECT *. Только те поля, которые реально нужны в списке. Это даёт выигрыш уже на этапе выборки.
Пример логики добавления в корзину. Сессии и оплату добавим позже, но структура должна быть такой.
<?php
namespace MyCompany\Component\Shop\Site\Controller;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Factory;
use Joomla\CMS\Response\JsonResponse;
use Joomla\CMS\Session\Session;
class CartController extends BaseController
{
public function add()
{
// Проверка токена (CSRF) — обязательно!
Session::checkToken() or jexit('Invalid Token');
$app = Factory::getApplication();
$input = $app->getInput();
$productId = $input->getInt('id', 0);
$qty = $input->getInt('qty', 1);
// Логика: берём сессию, добавляем товар
// $session = Factory::getSession();
// ... логика ...
// Возвращаем JSON (если это AJAX) или редиректим
echo new JsonResponse(['status' => 'ok', 'message' => 'Товар добавлен']);
$app->close();
}
}
Session::checkToken() — это не опция, а обязательное требование. Без него любая форма с POST становится уязвимой к CSRF.
Список товаров — один запрос к одной таблице. Связанные сущности подтягиваются только по требованию.
Никаких SELECT *. Выбираем ровно то, что отображается в списке.
state и catid проиндексированы — фильтрация по ним не сканирует таблицу.
Кэшируем явно и точечно, а не «всё подряд на всякий случай».
Для 1000 товаров это даёт разницу в разы: админка открывается мгновенно, фронтенд-каталог не грузит сотни килобайт JSON'а на каждый запрос.
Это скелет. Дальше — по шагам:
ProductModel + ProductTable).Если хочешь — в следующей статье разберём форму редактирования товара и Table-класс, а потом и корзину с оформлением заказа. Пиши в комментариях, что интереснее в первую очередь. Ах да, комментарии отключил, никто туда не пишет ) так что пиши на почту. адрес найдешь в разделе "о блоге"
| Что делаем | Вход / выход | Днём | Ночью |
|---|---|---|---|
|
1. Скелет из статьи каталог, корзина в сессии, заказ в БД, список товаров в админке |
4M / 120k | $0.29 | $0.14 |
|
2. Рабочий магазин категории, карточки, оформление заказа, статусы в админке, импорт CSV, вёрстка под шаблон |
15M / 500k | $1.12 | $0.56 |
|
3. Полный магазин оплата, доставка, обмен с 1С, SEO-разметка, тесты и круг правок по замечаниям |
50M / 2M | $3.92 | $1.96 |
|
4. То же в 3–4 захода каждая новая сессия заново читает код и файлы — вход растёт почти вдвое |
90M / 3M | $6.70 | $3.35 |