Рассмотрим полученный опыт разработки разного рода модулей для OpenCart версий 2.3 и 3.0.
В ходе работы с OpenCart у одного из клиентов был странный баг, который долгое время не могли решить, рекомендую ознакомиться.
Файловая структура OpenCart устроена таким образом, что конкретный модуль не имеет своей директории, однако может иметь один единственный файл, например admin/controller/extension/module/modulename.php.
В большинстве случаев файлы модуля расположены в различных директориях среди файлов других модулей. При этом файлы могут быть как в admin так и в catalog контекстах. Контроллеры, модели, шаблоны, вся файловая структура компонуется по типу файлов, в отличии от многих других CMS.
Как правило я веду разработку модулей через git репозиторий, файлы модуля складываю в директорию src, а в директорию cms-name располагаю исходники CMS. Поднимается все это в docker. Пример структуры проекта:

В ocStore3.x лежат исходники CMS, в которую необходимо прокинуть файлы изsrc.
Более добробно можно посмотреть в недавнем модуле интеграции Opencart => Salesman.
Для удобства получения итогового zip архива, который можно устанавливать через админку, создадим в корне репозитория build.bash:
#!/bin/sh
rm -f mymodule.ocmod.zip
cp -R src upload
zip -rm mymodule.ocmod.zip upload
Теперь при его запуске в корне всегда будет лежать свежая сборка модуля, готовая к установке.
Для запуска проекта использую docker образы php-fpm и apache2, конфигурацию которых можно посмотреть все в том же репозитории в директории .env. В качестве СУБД mysql:5.7 с дефолтными настройками.
OpenCart построен по MVCl архитектуре, в нем есть 4 вида файлов:
admin/model или catalog/modeladmin/view или catalog/viewadmin/controller или catalog/controlleradmin/language или catalog/language
OpenCart имеет 2 контекста:
admin - администраторский, вход возможен только по специальному доступуcatalog - пользовательский, вход свободен, но не для всех endpointКроме перечисленных видов файлов в OpenCart есть библиотеки расположенные по пути system/library/, библиотеки могут быть представлены как одним единственным файлом или целой директорией.
Сам файл библиотеки должен содержать класс, имя которого должно совпадать в именем файла, например system/library/FileName.php:
class FileName
{
/**
* @param Registry $registry
*/
public function __construct($registry)
{
// ...
}
}
На стороне контроллеров/моделей его можно загрузить, а затем использовать так:
$this->load->library('FileName');
// ...
$this->FileName->method();
Если библиотека состоит из нескольких связных файлов, то удобнее ее разместить в директории. Загружаться может также один файл, но подключать другие внутри. Например system/library/dir/FileName.php:
<?php
namespace Dir;
class FileName
{
/**
* @param Registry $registry
*/
public function __construct($registry)
{
// ...
}
}
Модуль обязательно состоит из контроллера в admin, может иметь модель, вид и перевод. Кроме того контроллер/модель/вид/перевод могут быть и в catalog части.
Для примера рассмотрим абстратный модуль MyModule.
Файл перевода admin/language/ru-ru/extension/module/mymodule.php должен содержать минимально необходимый набор переводов (а при необходимости все переводы):
$_['doc_title'] = 'Мой модуль';
$_['heading_title'] = 'Мой модуль';
$_['settings_success'] = 'Настройки успешно изменены!';
$_['settings_edit'] = 'Настройки модуля';
$_['entry_setting1'] = 'Введите настройку 1';
$_['entry_setting2'] = 'Введите настройку 2';
Без ключа
heading_titleмодуль появится в списке модулей с неправильным именем.
Контроллер модуля admin/controller/extension/module/mymodule.php должен содержать методы:
index - вывод страницы настроек, обработки и сохраненияvalidate - для валидации настроек из метода index (вообще-то необязательно)install - установка модуляuninstall - деинсталяция/удаление (из системы движка) модуляПримерно так выглядит шаблон контроллера модуля без реализации:
class ControllerExtensionModuleMyModule extends Controller
{
// страница настроек модуля
public function index() {}
// установка модуля
public function install() {}
// деинсталяция модуля
public function uninstall() {}
// валидация настроек модуля
protected function validate() {}
// линейный массив с ошибками
private $m_aErrors = [];
};
Метод контроллера позволяющий вывести интерфейс страницы настроек (метод по умолчанию):
public function index()
{
...
}
Метод сам по себе не выведет интерфейс страницы. Помещаем в этот метод код. Осуществляем первичные действия:
// загрузка файла перевода модуля
$this->load->language('extension/module/mymodulesettings');
// установка title страницы
$this->document->setTitle($this->language->get('doc_title'));
// загрузка модели настроек
$this->load->model('setting/setting');
// создаем пустой массив, позже заполним его данными для шаблона
$data = [];
Теперь нужно показать страницу с формой редактирования настроек модуля. Продолжаем заполнять массив данными для шаблона представления.
Установка основных данных для страницы редактирования модуля в админке:
// загрузка представления головной части страницы
$data['header'] = $this->load->controller('common/header');
// загрузка сайдбара
$data['column_left'] = $this->load->controller('common/column_left');
// загрузка подвала админки
$data['footer'] = $this->load->controller('common/footer');
// заголовок h1 (но не title)
$data['heading_title'] = $this->language->get('heading_title');
$data['button_save'] = $this->language->get('button_save');
$data['settings_edit'] = $this->language->get('settings_edit');
// плейхолдеры для настроек
$data['entry_setting1'] = $this->language->get('entry_setting1');
$data['entry_setting2'] = $this->language->get('entry_setting2');
Теперь настройки модуля:
// получаем массив настроек модуля (ранее мы загружали модель setting/setting)
$moduleSettings = $this->model_setting_setting->getSetting("mymodule");
$data = array_merge($moduleSettings, $data);
/* или
$data['mymodule_setting1'] = $moduleSettings["mymodule_setting1"];
$data['mymodule_setting2'] = $moduleSettings["mymodule_setting2"];
*/
//...
Еще нужны хлебные крошки:
$data['breadcrumbs'] = [];
$data['breadcrumbs'][] = [
'text' => $this->language->get('text_home'),
'href' => $this->url->link('common/dashboard', 'token=' . $this->session->data['token'], true)
];
$data['breadcrumbs'][] = [
'text' => $this->language->get('text_extension'),
'href' => $this->url->link('marketplace/extension', 'token=' . $this->session->data['token'] . '&type=module', true)
];
$data['breadcrumbs'][] = [
'text' => $this->language->get('heading_title'),
'href' => $this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'], true)
];
В заключение нужно еще добавить ссылку на отправку формы редактирования настроек нашего модуля:
if (!array_key_exists('module_id', $this->request->get)) {
$data['action'] = $this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'], true);
} else {
$data['action'] = $this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'] . '&module_id=' . $this->request->get['module_id'], true);
}
И наконец - все это отправляем в шаблон:
$this->response->setOutput($this->load->view('extension/module/mymodulesettings', $data));
Этот раздел нужно делать перед рендером настроек.
Если текущий метод POST, тогда нужно обработать входящие данные и сохранить настройки модуля если проверка корректности данных прошла успешно (если нет, то останутся старые данные).
if ($this->request->server['REQUEST_METHOD'] == 'POST') {
// если валидация прошла успешно
if($this->validate()) {
// сохранение настроек модуля
$this->model_setting_setting->editSetting('mymodulesettings', $this->request->post);
// записываем в сессию статус успеха сохранения настроек
$this->session->data['settings_success'] = $this->language->get('settings_success');
} else {
// валидация закончилась ошибкой, запишем информацию об этом в сессию
$this->session->data['settings_error'] = $this->m_aErrors;
}
// перенаправляем на страницу настроек модуля
$this->response->redirect($this->url->link('extension/module/mymodulesettings', 'token=' . $this->session->data['token'] . '&type=module', true));
}
Нам как-то надо иметь информацию о статусе сохранения (удалось или нет) и показывать ее в интерфейсе. А у нас тут перенаправление при сохранении ведь нам нужно сбросить текущий метод запроса страницы, а он сбрасывается редиректом, если не сбросить то при обновлении страницы на сервер будет отправляться POST запрос.
Самый простой вариант, который пришел на ум это сохранять статус в сессию, а при показе страницы показывать статус и удалять его из сессии.
Статус мы уже сохранили в сессию ранее, теперь обработаем сохраненные данные и вставим в массив для шаблона то, что нам нужно:
// если было успешное изменение настроек -
// показываем сообщение и удаляем из сессии чтобы больше не показывать
if (array_key_exists("settings_success", $this->session->data)) {
$data['settings_success'] = $this->language->get('settings_success');
unset($this->session->data["settings_success"]);
} else {
$data['settings_success'] = false;
}
// если есть ошибки - показываем и удаляем из сессии чтобы больше не показывать
if(array_key_exists("settings_error", $this->session->data)) {
$data['error_warning'] = implode("<br/>", $this->session->data["settings_error"]);
unset($this->session->data["settings_error"]);
} else {
$data['error_warning'] = false;
}
Удаляем данные статуса из сессии для того чтобы не показывать его повторно, при обновлении страницы.
Один из вариантов обработки статуса сохранения настроек - это сохранение его в сессии и удаление при первом обращении.
public function install()
{
// ...
}
В методе install могут устанавливаться обработчики событий и создаваться все необходимые данные для работы модуля.
В данном методе нам нужно создать настройки по умолчанию:
$this->load->model('setting/setting');
$this->model_setting_setting->editSetting('mymodule', [
'mymodule_setting1' => '',
'mymodule_setting2' => ''
]);
Имена параметров настроек должны иметь префикс имени модуля, например
modulename_settingname.
Здесь можно удалить настройки модуля:
$this->load->model('setting/setting');
$this->model_setting_setting->deleteSetting('mymodule');
А также произвести нужные манипуляция с базой данных через тот же объект $this->db.
Файл admin/model/extension/module/mymodule.php содержит все что касается работы с базой данных. В нем нужно объявить класс ModelExtensionModuleMyModule:
class ModelExtensionModuleMyModule extends Model
{
...
}
Внутри каждого контроллера и каждой модели есть объект класса DB расположенного по пути system/library/db.php (а по пути system/library/db/ лежат классы-адапторы для конкретной низкой реализации), который доступен как $this->db.
У этого объекта нас интересуют следующие методы:
query($sql, $params = array()) - выполнить SQL запрос в переменной sql, подставив параметры из массива paramsescape($value) - экранирует строку запроса valuecountAffected() - возвращает количество строк затронутых последним запросомgetLastId() - возвращает последний вставленный IDconnected() - установлено ли соединение с БДДля детального разбора, знакомым с pdo можно посмотреть файл system/library/db/mpdo.php.
А теперь посмотрим как сделаны другие файлы представления из директории admin/view/template/extension/module/ и частично возьмем оттуда код шаблона.
В OpenCart 2.3 используется шаблонизация на основе php кода в файле шаблона (.tpl), а в OpenCart 3.0 появился Twig
Создаем файл admin/view/template/extension/module/mymodule.tpl, в который помещаем адаптированный код для нашего модуля подсмотренный в других файлах шаблонов.
Пропустим здесь этот момент, лучше посмотреть файл в репозитории.
Система событий OpenCart построена вокруг загрузки файлов. Например существует такое событие admin/model/customer/customer/addCustomer/after это событие после добавления нового пользователя через админку OpenCart.
Разберем подробнее:
admin/model/customer/customer - путь до php файла в котором есть класс ModelCustomerCustomeraddCustomer - метод класса ModelCustomerCustomerafter - после того как метод выполнитьсяСистема событий OpenCart это генерируемые события до и после загрузки файлов движка или модулей.
Более подробно про события можно прочитать в статье События OpenCart.
Рекомендую ознакомится со статьей работа с заказом через админку OpenCart.
Клиентская часть OpenCart работает с использованием jquery, а значит можно использовать $.ajax. Примеры ajax запросов на клиентской части можно посмотреть в admin/view/template/sale/order_form.tpl (.twig для OpenCart 3.0).
Просматривая все тот же файл admin/view/template/sale/order_form.tpl (для OpenCart 2.3) можно понять, что в качестве адреса вызова используется классическая схема роутинга OpenCart. Посмотрим на один из запросов:
$.ajax({
url: 'index.php?route=customer/customer/autocomplete&token=<?php echo $token; ?>&filter_name=' + encodeURIComponent(request),
...
То есть, нам нужно создать класс контроллера, затем из файлов представления можно вызывать методы этого контроллера ajax запросами.
Создадим контроллер нашего нового тестового модуля по пути admin/controller/extension/module/myajax.php:
class ControllerExtensionModuleMyAjax extends Controller
{
public function index()
{
$this->response->addHeader('Content-Type: application/json');
$this->response->setOutput(json_encode(
[
"success" => true,
"message" => "ok",
"data" => []
]
));
}
}
В классе контроллера есть объект response, это экземпляр класса Response который расположен по пути system/library/response.php. Он позволяет управлять ответом сервера. Нас интересуют только 2 метода:
addHeader($header) - добавить http заголовок, header строковый аргументsetOutput($output) - установить данные для вывода, output строковый аргументДля формирования ответа на запрос, в методе контроллера можно использовать
$this->response.
Так как OpenCart имеет 2 режима доступа/контекста (admin, catalog), то передаваемые данные в запросах разные:
admin - требует токен в get параметре (получить можно из объекта класса контроллера):
token, который берется из $this->session->data['token']user_token, который берется из $this->session->data['user_token']catalog - в общем случае не требует токена, но есть нюансы, о которых позжеТеперь чтобы осуществить ajax запрос достаточно в файл представления подставить JavaScript (код для OpenCart 2.3):
$.ajax({
url: '<?php echo $admin; ?>index.php?route=extesion/module/myajax&token=<?php echo $token; ?>',
type: 'get',
dataType: 'json',
success: function(json) {
alert("success: "+json["success"]+"\n"+"message: "+json["message"]);
},
error: function(xhr, ajaxOptions, thrownError) {
alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
}
});
В этом коде в url admin это путь указывающий контекст запроса (admin или catalog). Для контекста есть 2 дефайна определенных в admin/config:
HTTP_SERVER или HTTPS_SERVER - путь до директории admin, где будет осуществлен поиск контроллера для выполнения запросаHTTP_CATALOG или HTTPS_CATALOG - корень сайта, однако контроллеры будут браться из директории catalog
$this->request->post != $_POSTтак какsystem/library/request.phpпроизводит обработку данных черезhtmlspecialchars. Это касается и других суперглобальных переменныхphp:$_GET,$_REQUEST,$_COOKIE,$_FILES,$_SERVER.
Просматривая файл представления admin/view/template/sale/order_form.tpl (OpenCart 2.3), можно увидеть что из админки осуществляются ajax запросы на catalog контекст, с использованием особого токена.
Сначала объявляется глобальная переменная token, затем ajax запрос на адрес /index.php?route=api/login, который отвечает json данными в которых есть ключ token:
var token = '';
// Login to the API
$.ajax({
url: '<?php echo $catalog; ?>index.php?route=api/login',
type: 'post',
data: 'key=<?php echo $api_key; ?>',
dataType: 'json',
crossDomain: true,
success: function(json) {
//...
if (json['token']) {
token = json['token'];
}
},
error: function(xhr, ajaxOptions, thrownError) {
alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
}
});
Контроллер этого запроса находится в catalog/controller/api/login.php ControllerApiLogin::index. Он:
catalog/model/account/api.php - ModelAccountApi::addApiSession) иtoken находится в system/helper/general.php),
который возвращается в json этого ajax запроса, если доступ по api (api_key) разрешен для текущего пользователя (Админка-Система-API).Дальше разбирая представление admin/view/template/sale/order_form.tpl можно увидеть что последующие ajax запросы, которые по адресу route=api/... используют этот самый token для определения права доступа, таким образом (в каждом api файле, в каждом методе) сущетвует такой кусок кода для определения права осуществлять запрос:
if (!isset($this->session->data['api_id'])) {
$json['error']['warning'] = $this->language->get('error_permission');
} else {
...
}
Ajax запросы через
catalogконтекст можно осуществлять с использованиемtokenдля безопасного доступа.
А теперь копнем глубже и выясним как это происходит внутри движка, ведь можно отправлять ajax запросы и без токена.
Просматривая код файла index.php отправляемя в system/startup.php, оттуда следуем в system/framework.php в самый конец и видим такое вот:
// Front Controller
$controller = new Front($registry);
// Pre Actions
if ($config->has('action_pre_action')) {
foreach ($config->get('action_pre_action') as $value) {
$controller->addPreAction(new Action($value));
}
}
Здесь видим новое понятие front controller, код которого находится в system/engine/front.php в классе Front. Он запускает общий контроллер startup/router относительно директории controller контекста (admin/controller или catalog/controller), который выполняет первичные контроллеры указанные в $_['action_pre_action']; в файле system/config/catalog.php.
В коде выше происходит только добавление первичных контроллеров во front controller, а их исполнение осуществляется кодом ниже в методе dispatch (внутри метода перед выполнением action указанного в $config->get('action_router')):
// Dispatch
$controller->dispatch(new Action($config->get('action_router')), new Action($config->get('action_error')));
Среди первичных контроллеров есть startup/session относительно catalog/controller где в ControllerStartupSession::index находится интересующий нас код для авторизации в api через токен. Вкратце:
api/ и наличия get параметра token$_COOKIE["api"]Теперь когда исполнение кода дойдет до целевого контроллера,
$this->session->data['api_id']уже будет иницилизировано если указана актуальная комбинация токена и ip адреса.
Разбирая админку OpenCart 3.0 (в 2.3 такой кнопки нет), в разделе Панель состояния была найдена кнопка, при клике по которой показалось всплывающее окно. То что нужно, начинаем разбор ...

Модальное окно в админке OpenCart Настройки разработчика:

Заходим на страницу Панель состояния, открываем ее исходный код и смотрим в конце скрипт:
$('#button-setting').on('click', function() {
$.ajax({
url: 'index.php?route=common/developer&user_token=D9aTD65JQVdyOY9pcVxcRUx0M3eTefnr',
dataType: 'html',
beforeSend: function() {
$('#button-setting').button('loading');
},
complete: function() {
$('#button-setting').button('reset');
},
success: function(html) {
$('#modal-developer').remove();
$('body').prepend('<div id="modal-developer" class="modal">' + html + '</div>');
$('#modal-developer').modal('show');
},
error: function(xhr, ajaxOptions, thrownError) {
alert(thrownError + "\r\n" + xhr.statusText + "\r\n" + xhr.responseText);
}
});
});
Как видно, на кнопку с id button-setting на клик вешается ajax запрос, успешный результат которого показывается в popup окне. А для показа этого окна используется:
$('#modal-developer').modal('show');
Похоже на библиотеку jquerymodal. Однако в bootstrap тоже есть поддержка модальных окон. Опытным путем было выявлено что это bootstrap.
OpenCart достаточно интересная и не сложная CMS. Большая часть изучения основывалась на исходниках, которые в отличии от некоторых других CMS интуитивно понятны, это обусловлено просто архитектурой и единообразными правилами.