Эта статья — не сухое руководство, а разбор реальных ошибок, которые возникают при создании CRUD для существующей таблицы в Joomla 5.4.5. Каждая ошибка — это ловушка, в которую легко попасть, если делать компонент «по образцу» без понимания того, как Joomla обрабатывает формы и сохранение.

Контекст

Есть таблица #__example_items с полями:

  • id — INT, AUTO_INCREMENT, PRIMARY KEY
  • title, alias — строки
  • description — текст
  • priority, is_published, is_processed — числа и булевы
  • item_type — ENUM
  • parent_id — INT, может быть NULL
  • keywords — TEXT

В таблице есть уникальный индекс unique_alias по первым 200 символам поля alias. Это важно для понимания первой ошибки.

Компонент com_example имеет namespace MyCompany\Component\Example, административная часть лежит в administrator/components/com_example/.

Ошибка первая: «Duplicate entry» при редактировании

Симптом: открываешь запись на редактирование, меняешь поле, нажимаешь «Сохранить» — получаешь:

Duplicate entry 'sample-alias-value' for key 'example_items.unique_alias'

При этом если ничего не менять и просто нажать «Сохранить» — ошибка та же.

Что происходило

Joomla делала INSERT вместо UPDATE. При INSERT MySQL пытался создать новую запись с тем же alias, что уже есть в базе, и падал на уникальном индексе.

Почему Joomla делала INSERT? Потому что FormController::save() в Joomla 5 берёт id записи из URL, а не из поля jform[id]. В форме action был:

<form action="<?php echo Route::_('index.php?option=com_example&task=item.save'); ?>" ...>

Параметра id в URL не было. FormController считал, что сохраняется новая запись, и передавал в модель $data['id'] = 0. Модель, получив 0, вызывала parent::save(), который делал INSERT.

Исправление

В tmpl/item/edit.php добавьте id в action формы:

<form action="<?php echo Route::_('index.php?option=com_example&task=item.save&id=' . (int) ($this->item->id ?? 0)); ?>" ...>

Теперь FormController видит id в URL, передаёт его в модель, и модель делает UPDATE.

Урок

В Joomla 5 id для сохранения берётся из URL-параметра, а не из скрытого поля формы. Если вы делаете кастомную форму редактирования — всегда добавляйте &id=... в action. Иначе Joomla будет считать, что вы создаёте новую запись.

Ошибка вторая: «Incorrect integer value: '' for column 'parent_id'»

Симптом: после исправления первой ошибки появляется новая:

Incorrect integer value: '' for column 'parent_id' at row 1

Что происходило

Поле parent_id в форме было объявлено как type="text":

<field name="parent_id" type="text" label="COM_EXAMPLE_FIELD_PARENT_ID"/>

Когда поле пустое, Joomla отправляет в POST пустую строку ''. MySQL в строгом режиме (STRICT_TRANS_TABLES) не может преобразовать '' в INT и отклоняет запрос.

Исправление

Замените тип поля на number и задайте default="0":

<field name="parent_id" type="number" default="0" label="COM_EXAMPLE_FIELD_PARENT_ID" min="0" step="1"/>

Теперь при пустом поле Joomla отправляет 0, а не '', и MySQL принимает значение.

Урок

Для числовых полей, которые могут быть пустыми, используйте type="number" с default="0". Не оставляйте type="text" для INT-колонок — пустая строка сломает сохранение в строгом режиме MySQL.

Полный набор файлов для CRUD

Вот эталонный набор, который работает после исправлений.

1. Форма: forms/item.xml

<?xml version="1.0" encoding="UTF-8"?>
<form>
    <fieldset name="details">
        <field name="id" type="text" readonly="true" class="readonly" label="JGLOBAL_FIELD_ID_LABEL"/>
        <field name="title" type="text" label="COM_EXAMPLE_FIELD_TITLE" required="true"/>
        <field name="alias" type="text" label="COM_EXAMPLE_FIELD_ALIAS" required="true"/>
        <field name="description" type="textarea" label="COM_EXAMPLE_FIELD_DESCRIPTION" rows="15" required="true"/>
        <field name="priority" type="number" label="COM_EXAMPLE_FIELD_PRIORITY" default="1" min="1" max="10"/>
        <field name="is_published" type="list" label="COM_EXAMPLE_FIELD_IS_PUBLISHED" default="1">
            <option value="1">JYES</option>
            <option value="0">JNO</option>
        </field>
        <field name="item_type" type="list" label="COM_EXAMPLE_FIELD_ITEM_TYPE" default="manual">
            <option value="manual">COM_EXAMPLE_ITEM_TYPE_MANUAL</option>
            <option value="processed">COM_EXAMPLE_ITEM_TYPE_PROCESSED</option>
            <option value="atomic">COM_EXAMPLE_ITEM_TYPE_ATOMIC</option>
        </field>
        <field name="parent_id" type="number" default="0" label="COM_EXAMPLE_FIELD_PARENT_ID" min="0" step="1"/>
        <field name="is_processed" type="list" label="COM_EXAMPLE_FIELD_IS_PROCESSED" default="0">
            <option value="1">JYES</option>
            <option value="0">JNO</option>
        </field>
        <field name="keywords" type="textarea" label="COM_EXAMPLE_FIELD_KEYWORDS" rows="3"/>
    </fieldset>
</form>

Ключевые моменты:

  • id — readonly, но всё равно выводится в форме (Joomla подставит значение из записи).
  • Числовые поля — type="number" с default.
  • ENUM — type="list" с опциями.
  • Текст — type="textarea" с rows.

2. Шаблон редактирования: tmpl/item/edit.php

<?php
defined('_JEXEC') or die;

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;

HTMLHelper::_('behavior.formvalidator');
?>
<form action="<?php echo Route::_('index.php?option=com_example&task=item.save&id=' . (int) ($this->item->id ?? 0)); ?>" method="post" name="adminForm" id="adminForm" class="form-validate">
    <?php echo $this->form->renderField('id'); ?>
    <?php echo $this->form->renderField('title'); ?>
    <?php echo $this->form->renderField('alias'); ?>
    <?php echo $this->form->renderField('description'); ?>
    <?php echo $this->form->renderField('priority'); ?>
    <?php echo $this->form->renderField('is_published'); ?>
    <?php echo $this->form->renderField('item_type'); ?>
    <?php echo $this->form->renderField('parent_id'); ?>
    <?php echo $this->form->renderField('is_processed'); ?>
    <?php echo $this->form->renderField('keywords'); ?>
    <input type="hidden" name="task" value="item.save"/>
    <?php echo HTMLHelper::_('form.token'); ?>
</form>

Ключевой момент — &id=... в action. Без него Joomla сделает INSERT.

3. Модель: src/Model/ItemModel.php

<?php
namespace MyCompany\Component\Example\Administrator\Model;

use Joomla\CMS\MVC\Model\AdminModel;

class ItemModel extends AdminModel
{
    protected $text_prefix = 'COM_EXAMPLE';

    public function getForm($data = [], $loadData = true)
    {
        $form = $this->loadForm(
            'com_example.item',
            'item',
            ['control' => 'jform', 'load_data' => $loadData]
        );

        if (empty($form)) {
            return false;
        }

        return $form;
    }

    protected function loadFormData()
    {
        $app = \Joomla\CMS\Factory::getApplication();
        $data = $app->getUserState('com_example.edit.item.data', []);

        if (empty($data)) {
            $data = $this->getItem();
        }

        return $data;
    }

    public function save($data)
    {
        if (!empty($data['id'])) {
            $table = $this->getTable();
            $table->load((int) $data['id']);
            $table->bind($data);
            if (!$table->check()) {
                $this->setError($table->getError());
                return false;
            }
            if (!$table->store()) {
                $this->setError($table->getError());
                return false;
            }
            $this->cleanCache();
            return $table->id;
        }
        return parent::save($data);
    }
}

Что здесь важно:

  • getForm() загружает XML-форму по имени item.
  • loadFormData() берёт данные из сессии (если было неудачное сохранение) или из getItem().
  • save() проверяет $data['id']. Если он есть — загружает существующую запись, привязывает данные, проверяет и сохраняет через store(). Если нет — вызывает parent::save(), который делает INSERT.

Этот save() — страховка. Даже если id не дойдёт из URL, но будет в $data, модель сделает UPDATE. Но полагаться только на него нельзя: если id нет ни в URL, ни в $data, будет INSERT.

4. Контроллер: src/Controller/ItemController.php

<?php
namespace MyCompany\Component\Example\Administrator\Controller;

use Joomla\CMS\MVC\Controller\FormController;

class ItemController extends FormController
{
    protected $option = 'com_example';
    protected $view_list = 'items';
}

Ничего лишнего. FormController уже умеет обрабатывать save, apply, cancel. $view_list указывает, куда вернуться после сохранения.

5. Таблица: src/Table/ItemTable.php

<?php
namespace MyCompany\Component\Example\Administrator\Table;

use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;

class ItemTable extends Table
{
    public function __construct(DatabaseDriver $db)
    {
        parent::__construct('#__example_items', 'id', $db);
    }
}

Ключевой момент: второй аргумент конструктора — имя первичного ключа. Если там опечатка (например, 'ID' вместо 'id'), Joomla не сможет найти запись и сделает INSERT.

6. Список: src/View/Items/HtmlView.php

<?php
namespace MyCompany\Component\Example\Administrator\View\Items;

use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\CMS\Language\Text;

class HtmlView extends BaseHtmlView
{
    public function display($tpl = null)
    {
        $this->items = $this->get('Items');
        $this->pagination = $this->get('Pagination');
        $this->state = $this->get('State');

        ToolbarHelper::title(Text::_('COM_EXAMPLE_ITEMS_TITLE'));
        ToolbarHelper::addNew('item.add');
        ToolbarHelper::editList('item.edit');
        ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'items.delete');

        parent::display($tpl);
    }
}

7. Шаблон списка: tmpl/items/default.php

<?php
defined('_JEXEC') or die;

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
?>
<form action="<?php echo Route::_('index.php?option=com_example&view=items'); ?>" method="post" name="adminForm" id="adminForm">
    <table class="table table-striped">
        <thead>
            <tr>
                <th width="1%"><?php echo HTMLHelper::_('grid.checkall'); ?></th>
                <th><?php echo Text::_('COM_EXAMPLE_FIELD_TITLE'); ?></th>
                <th><?php echo Text::_('COM_EXAMPLE_FIELD_ALIAS'); ?></th>
                <th width="10%"><?php echo Text::_('COM_EXAMPLE_FIELD_PRIORITY'); ?></th>
            </tr>
        </thead>
        <tbody>
            <?php foreach ($this->items as $i => $item) : ?>
                <tr>
                    <td><?php echo HTMLHelper::_('grid.id', $i, $item->id); ?></td>
                    <td>
                        <a href="/<?php echo Route::_('index.php?option=com_example&task=item.edit&id=' . $item->id); ?>">
                            <?php echo $this->escape($item->title); ?>
                        </a>
                    </td>
                    <td><?php echo $this->escape($item->alias); ?></td>
                    <td><?php echo (int) $item->priority; ?></td>
                </tr>
            <?php endforeach; ?>
        </tbody>
    </table>
    <?php echo $this->pagination->getListFooter(); ?>
    <input type="hidden" name="task" value=""/>
    <input type="hidden" name="boxchecked" value="0"/>
    <?php echo HTMLHelper::_('form.token'); ?>
</form>

Здесь важно:

  • HTMLHelper::_('grid.checkall') — чекбокс «выбрать все» в заголовке.
  • HTMLHelper::_('grid.id', $i, $item->id) — чекбокс для каждой строки. Без него кнопки «Редактировать» и «Удалить» не работают: Joomla не видит выбранных элементов.
  • Ссылка на редактирование содержит &id=....

Отладка: как найти причину, если сохранение не работает

Прежде чем менять код, включите отладку. В админке Joomla: System → Global Configuration → System → Debug System = Yes. После сохранения внизу страницы появится панель с вкладками Request, Session, Queries.

Что смотреть:

  • Request → POST — какие данные реально ушли на сервер. Если jform[id] нет или он пустой — проблема в форме.
  • Session → registry → com_example → edit → item → data — что Joomla сохранила в сессию после неудачной попытки. Если data → id равен "0" — значит, id потерялся до модели.
  • Queries — какие SQL-запросы выполнились. Если нет INSERT или UPDATE в вашу таблицу — значит, сохранение не дошло до базы.

Если отладочная панель не показывает POST (в Joomla 5 она иногда свёрнута), добавьте временную запись в файл прямо в методе save() модели:

file_put_contents('/tmp/debug_save.txt', print_r($data, true));

После сохранения откройте /tmp/debug_save.txt и посмотрите, есть ли в массиве $data ключ id и какое у него значение. Если id нет — проблема в контроллере или форме. Если есть, но не тот — смотрите, что приходит в POST.

Не забудьте удалить отладочную строку после диагностики.

Чек-лист: что проверить, если сохранение не работает

  1. Action формы содержит &id=...? Если нет — Joomla сделает INSERT.
  2. Поле id в XML-форме есть? Оно должно быть, даже если readonly. Joomla подставит значение из записи.
  3. Числовые поля объявлены как type="number" с default="0"? Иначе пустая строка сломает MySQL в строгом режиме.
  4. В Table правильно указан первичный ключ? Второй аргумент конструктора.
  5. $view_list в контроллере совпадает с именем папки в tmpl/? Например, items → tmpl/items/default.php.
  6. В шаблоне списка есть grid.id и boxchecked? Иначе кнопки редактирования и удаления не работают.
  7. Кэш автозагрузки сброшен? После правок в PHP-файлах удалите administrator/cache/autoload_psr4.php и перезагрузите PHP-FPM.

Почему это неочевидно

Joomla 5 следует MVC, но с несколькими нестандартными решениями:

  • id для сохранения берётся из URL, а не из формы.
  • FormController не передаёт id в модель, если его нет в URL.
  • MySQL в строгом режиме не прощает пустых строк в INT-колонках.
  • Чекбоксы в списке — не украшение, а обязательный элемент для работы тулбара.

Эти вещи не описаны в базовых туториалах по Joomla, и именно на них спотыкаются при создании CRUD для существующих таблиц. Зная их, можно избежать часов отладки.


Автор статьи: https://jedig.ru

Спонсор проекта — магазин ANDROID55: https://mi55.ru

Отдыхайте в Сукко на Чёрном море: https://sukkograd.ru