Анатомия модуля
Разбираем модуль по файлам на примере Banner. Поняв этот шаблон, можно писать свой модуль с нуля или править существующий.
Структура файлов #
Banner/ ├── module.json # манифест: name, version, icon, requires ├── Banner.php # публичный фасад (run() для /banner/*) ├── BannerRouter.php # админ-роутер: match action → controller ├── migration.sql # CREATE TABLE + INSERT routes/menu ├── uninstall.sql # откат — на случай удаления модуля ├── Controllers/ │ ├── BaseController.php # общий layout() + flash() │ ├── BannerListController.php │ ├── BannerFormController.php │ └── BannerUploadController.php ├── Services/ │ └── BannerService.php # SQL + logAction + notify ├── FlagsPanel/ │ └── BannerFlagsPanel.php # декларация actions для /flags-settings ├── AdminApi/ # опционально: REST для мобильного клиента │ └── BannerAdminApi.php ├── Widget/ # опционально: виджет на дашборде │ └── BannerWidget.php ├── lang/ │ ├── ru/banner.php # __('banner.title') и т.д. │ └── en/banner.php └── views/ ├── admin/banner/ # шаблоны админки └── default/banner/ # фолбэк-шаблоны публичной части
Публичный фасад #
Точка входа для URL /banner/*. Ядро вызовет Banner::run($action, $request):
final class Banner { public static function run(string $action, Request $r): Response { if (!ModuleManager::isEnabled('Banner')) return Response::redirect('/'); return match ($action) { 'click' => (new ClickController())->handle($r), default => Response::error404(), }; } }
Админ-роутер #
Все URL /s-panel/banner/* попадают в один роутер. Он первым делом гасит запросы без прав через module_require():
final class BannerRouter { public function run(string $action, Request $r): Response { if (!Auth::check()) return Response::redirect('/s-panel/login'); if ($resp = module_require('Banner', 'view', '/s-panel/')) return $resp; return match ($action) { 'list' => (new BannerListController())->index(), 'create' => (new BannerFormController())->create($r), 'edit' => (new BannerFormController())->edit($r), 'delete' => (new BannerFormController())->delete($r), default => Response::error404(), }; } }
Service-слой #
Бизнес-логика и SQL — в Service'ах, а не в контроллерах. Контроллер валидирует и отдаёт ответ, а Service ходит в БД, пишет логи и шлёт уведомления:
public function create(array $data): int { DB::insert('banner__items', $data); $id = (int)DB::lastInsertId(); logAction('banner.create', "id={$id}"); notify('Создан баннер', $data['title'], '/s-panel/banner', 'success', 'fa-image'); return $id; }
Миграция и сидинг #
migration.sql модуля выполняется при установке. В нём — DDL своих таблиц плюс INSERT в routes и settings__menu, чтобы URL и пункты меню сразу появились в админке.
CREATE TABLE banner__items ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, image_path VARCHAR(512), url VARCHAR(512), is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO routes (id, slug, module, action, method) VALUES (300, 's-panel/banner', 'banner', 'list', 'GET'), (301, 's-panel/banner-create', 'banner', 'create', 'POST'); INSERT INTO settings__menu (id, title, url, icon, parent_id, order_num, is_visible, module_name) VALUES (100, 'Баннеры', '/s-panel/banner', 'fa fa-image', NULL, 60, 1, 'Banner');
Важно: id'ы пишите вручную, потому что routes.id и settings__menu.id объявлены без AUTO_INCREMENT в схеме ядра.
Локализация #
Каталог lang/ модуля — это словари для __(). Имя файла даёт префикс ключа:
return [ 'title' => 'Баннеры', 'create' => 'Добавить баннер', 'fields' => [ 'title' => 'Заголовок', 'image' => 'Изображение', ], ]; // в шаблоне: // __('banner.fields.title') → 'Заголовок'