Практическая реализация меню WordPress — от кода к результату

В предыдущих статьях цикла мы разобрали теорию WordPress Settings API и систему меню административной панели. Теперь настало время применить эти знания на практике — собрать работающую страницу опций с меню, секциями, полями и санитизацией. Эта статья целиком посвящена практическому подходу: мы возьмём код из предыдущих уроков, очистим его от мусора и построим полноценную панель настроек темы.

Если вы следовали примерам из прошлых статей, в вашем файле functions.php темы-песочницы скопилось множество функций — меню верхнего уровня, подменю, страницы плагинов, тестовые колбэки. Часть этого кода была написана для демонстрации и больше не нужна. Перед тем как двигаться дальше, проведём ревизию и удалим всё лишнее.

Не бойтесь удалять код. Всё, что мы удалим из functions.php, мы потом перепишем — но в правильной архитектуре и с лучшей организацией. Чистый functions.php — залог поддерживаемой темы. Представьте, что вы вернулись к проекту через полгода — что легче понять: файл из 50 строк или из 500 с мёртвым кодом?

Ревизия кода: что оставить, что удалить

Загляните в ваш functions.php. Вы увидите функции, которые вы добавляли в процессе изучения меню: создание пункта в меню «Плагины» через add_plugins_page, создание меню верхнего уровня через add_menu_page, тестовые подменю, разрозненные колбэки. Весь этот код был учебным — теперь он нам мешает.

Удалите следующие блоки:

  • add_plugins_page — страница в меню «Плагины» нам больше не нужна, опции темы должны быть в меню «Внешний вид» или в собственном разделе
  • add_menu_page — меню верхнего уровня из демонстрационного примера будет переписано с правильными slug и структурой
  • Все колбэки, которые просто выводят тестовый текст вроде «Hello World» или «This is a test page»
  • Дублирующиеся вызовы register_setting, если вы экспериментировали с разными группами опций

После очистки в вашем functions.php должны остаться:

  • Функция регистрации настроек sandbox_register_settings() с хуком admin_init
  • Функция sandbox_sanitize_options() для санитизации массива опций
  • Колбэки полей: sandbox_text_field_callback(), sandbox_checkbox_field_callback(), sandbox_textarea_field_callback()
  • Колбэк описания секции sandbox_general_section_callback()

Всё остальное удаляем без сожаления. Теперь у нас чистый лист для построения правильной архитектуры.

Не удаляйте саму директорию темы и style.css. Тема должна оставаться активной. Мы редактируем только functions.php.

Планирование структуры опций

Прежде чем писать код, спланируем, какие настройки будут у нашей темы и как они будут организованы. Хаотичное добавление полей приводит к нечитаемой странице опций, которую пользователи будут обходить стороной.

На данный момент у нас есть базовый функционал — пользователь может управлять видимостью трёх основных контейнеров темы (шапка, боковая панель, подвал). Эти опции логично разместить на странице «Настройки темы». План структуры:

Группа опцийСтраницаПоляТип полей
Отображение (Display)Настройки темыВидимость шапки, видимость сайдбара, видимость футераЧекбоксы (вкл/выкл)
Социальные сети (Social)Настройки темыURL Facebook, URL Twitter, URL InstagramТекстовые поля (валидация URL)
Общие (General)Настройки темыНазвание сайта, URL логотипа, текст футераТекстовые поля + textarea

Каждая группа будет отдельной секцией (section) в терминах Settings API. Мы создадим меню в разделе «Внешний вид» через add_theme_page() — это стандартный подход для тем WordPress, и пользователи ожидают найти настройки темы именно там.

Регистрация опций для всех трёх групп

Теперь зарегистрируем настройки и создадим секции с полями. Вместо того чтобы использовать три отдельные опции (по одной на группу), объединим всё в один массив sandbox_theme_options — это упростит санитизацию и сократит количество запросов к базе данных:

function sandbox_register_settings() { register_setting( 'sandbox_options_group', 'sandbox_theme_options', array( 'type' => 'array', 'sanitize_callback' => 'sandbox_sanitize_options', 'default' => array( 'show_header' => 1, 'show_sidebar' => 1, 'show_footer' => 1, 'facebook_url' => '', 'twitter_url' => '', 'instagram_url' => '', 'site_title' => '', 'logo_url' => '', 'footer_text' => '', ), ) ); add_settings_section( 'sandbox_display_section', 'Display Options', 'sandbox_display_section_callback', 'sandbox_theme_options_page' ); add_settings_field( 'sandbox_show_header', 'Show Header', 'sandbox_checkbox_field_callback', 'sandbox_theme_options_page', 'sandbox_display_section', array( 'label_for' => 'sandbox_show_header', 'option' => 'sandbox_theme_options', 'key' => 'show_header' ) ); add_settings_field( 'sandbox_show_sidebar', 'Show Sidebar', 'sandbox_checkbox_field_callback', 'sandbox_theme_options_page', 'sandbox_display_section', array( 'label_for' => 'sandbox_show_sidebar', 'option' => 'sandbox_theme_options', 'key' => 'show_sidebar' ) ); add_settings_field( 'sandbox_show_footer', 'Show Footer', 'sandbox_checkbox_field_callback', 'sandbox_theme_options_page', 'sandbox_display_section', array( 'label_for' => 'sandbox_show_footer', 'option' => 'sandbox_theme_options', 'key' => 'show_footer' ) ); add_settings_section( 'sandbox_social_section', 'Social Network Links', 'sandbox_social_section_callback', 'sandbox_theme_options_page' ); $social_fields = array( 'facebook_url' => 'Facebook URL', 'twitter_url' => 'Twitter URL', 'instagram_url' => 'Instagram URL', ); foreach ( $social_fields as $key => $label ) { add_settings_field( 'sandbox_' . $key, $label, 'sandbox_url_field_callback', 'sandbox_theme_options_page', 'sandbox_social_section', array( 'label_for' => 'sandbox_' . $key, 'option' => 'sandbox_theme_options', 'key' => $key ) ); } add_settings_section( 'sandbox_general_section', 'General Settings', 'sandbox_general_section_callback', 'sandbox_theme_options_page' ); add_settings_field( 'sandbox_site_title', 'Site Title', 'sandbox_text_field_callback', 'sandbox_theme_options_page', 'sandbox_general_section', array( 'label_for' => 'sandbox_site_title', 'option' => 'sandbox_theme_options', 'key' => 'site_title' ) ); add_settings_field( 'sandbox_logo_url', 'Logo URL', 'sandbox_url_field_callback', 'sandbox_theme_options_page', 'sandbox_general_section', array( 'label_for' => 'sandbox_logo_url', 'option' => 'sandbox_theme_options', 'key' => 'logo_url' ) ); add_settings_field( 'sandbox_footer_text', 'Footer Text', 'sandbox_textarea_field_callback', 'sandbox_theme_options_page', 'sandbox_general_section', array( 'label_for' => 'sandbox_footer_text', 'option' => 'sandbox_theme_options', 'key' => 'footer_text' ) ); } add_action( 'admin_init', 'sandbox_register_settings' );[/codeblock]
Обратите внимание на использование цикла foreach для регистрации полей социальных сетей. Вместо того чтобы писать три почти идентичных блока add_settings_field, мы определяем массив $social_fields с ключами и метками и проходим по нему в цикле. Это DRY-подход: меньше кода, меньше шансов на ошибку, легче добавить четвёртую сеть.

Колбэки секций и полей

Определим колбэки для трёх секций — каждая выводит пояснительный текст:

function sandbox_display_section_callback() { echo '

Choose which containers are visible on your site. Unchecking a container hides it completely from all pages.

'; } function sandbox_social_section_callback() { echo '

Enter the full URLs to your social media profiles. Leave a field empty to hide the corresponding icon.

'; } function sandbox_general_section_callback() { echo '

Basic site identity settings. The Site Title overrides the default WordPress site name in the theme header.

'; }[/codeblock]

И отдельный колбэк для URL-полей — он использует type="url" для встроенной валидации браузера:

function sandbox_url_field_callback( $args ) { $options = get_option( $args['option'], array() ); $value = isset( $options[ $args['key'] ] ) ? $options[ $args['key'] ] : ''; ?> Санитизация с учётом разных типов данных

Наша функция санитизации должна обрабатывать три разных типа данных: чекбоксы (1/0), URL-адреса и текстовые поля. Каждое поле требует своего санитайзера:

function sandbox_sanitize_options( $input ) { $sanitized = array(); $checkbox_fields = array( 'show_header', 'show_sidebar', 'show_footer' ); foreach ( $checkbox_fields as $field ) { $sanitized[ $field ] = isset( $input[ $field ] ) && $input[ $field ] ? 1 : 0; } $url_fields = array( 'facebook_url', 'twitter_url', 'instagram_url', 'logo_url' ); foreach ( $url_fields as $field ) { if ( isset( $input[ $field ] ) && ! empty( $input[ $field ] ) ) { $sanitized[ $field ] = esc_url_raw( $input[ $field ] ); } else { $sanitized[ $field ] = ''; } } $text_fields = array( 'site_title' ); foreach ( $text_fields as $field ) { $sanitized[ $field ] = isset( $input[ $field ] ) ? sanitize_text_field( $input[ $field ] ) : ''; } if ( isset( $input['footer_text'] ) ) { $sanitized['footer_text'] = sanitize_textarea_field( $input['footer_text'] ); } return $sanitized; }[/codeblock]

Группировка полей по типу данных делает санитизацию предсказуемой. Добавление нового чекбокса сводится к добавлению ключа в массив $checkbox_fields — функция санитизации автоматически подхватит его.

Рендеринг страницы опций через settings_fields() и do_settings_sections()

Теперь создадим страницу, на которой будут отображаться все зарегистрированные секции и поля. Для этого используются две ключевые функции Settings API: settings_fields() выводит скрытые поля (nonce, option_group) для защиты формы, а do_settings_sections() рендерит все секции и поля, привязанные к указанной странице:

function sandbox_theme_options_page_html() { if ( ! current_user_can( 'manage_options' ) ) { return; } ?>

Разберём, что здесь происходит:

  • current_user_can( 'manage_options' ) — страница доступна только администраторам. Без этой проверки любой авторизованный пользователь, угадавший URL, увидит страницу (но не сможет сохранить настройки из-за проверок в Settings API)
  • settings_fields( 'sandbox_options_group' ) — выводит скрытые поля: nonce для защиты от CSRF, action=update, идентификатор option_group. Без этого вызова форма не пройдёт проверку при сохранении
  • do_settings_sections( 'sandbox_theme_options_page' ) — рендерит все секции и поля, зарегистрированные для страницы sandbox_theme_options_page. Порядок рендеринга соответствует порядку вызовов add_settings_section()
  • submit_button() — стандартная кнопка сохранения WordPress с правильными классами и текстом
Параметр в settings_fields() должен совпадать с первым аргументом register_setting(). Если они не совпадают — форма не сохранит данные, и WordPress не покажет ошибку. Молчаливый отказ — самая коварная ошибка Settings API.

Добавление меню через add_theme_page()

Осталось добавить пункт меню, чтобы пользователь мог попасть на страницу опций. Используем add_theme_page() — страница появится в меню «Внешний вид»:

function sandbox_add_theme_options_menu() { add_theme_page( 'Sandbox Theme Options', 'Theme Options', 'manage_options', 'sandbox-theme-options', 'sandbox_theme_options_page_html' ); } add_action( 'admin_menu', 'sandbox_add_theme_options_menu' );[/codeblock]

Теперь зайдите в админку: Appearance → Theme Options. Вы увидите страницу с тремя секциями и всеми полями, которые мы зарегистрировали. Заполните поля, нажмите Save — и данные сохранятся в wp_options.

Полный код functions.php на этом этапе смотрите ниже — он включает регистрацию опций, колбэки полей, санитизацию, страницу опций и меню. Скопируйте его в свой файл и проверьте результат.

Тестирование сохранения опций

После сохранения настроек важно убедиться, что данные действительно попали в базу и правильно оттуда извлекаются. WordPress хранит опцию sandbox_theme_options как сериализованный массив в wp_options. Проверить содержимое можно несколькими способами.

Способ 1: через phpMyAdmin. Откройте таблицу wp_options, найдите строку с option_name = 'sandbox_theme_options' и просмотрите значение option_value. Вы увидите сериализованный PHP-массив с ключами show_header, facebook_url, site_title и значениями, которые вы ввели в форму.

Способ 2: через временный отладочный код. Добавьте в footer.php темы:

[/codeblock]

После этого откройте любую страницу сайта как администратор и загляните в HTML-код. В футере будет закомментированный дамп массива настроек.

Способ 3: через WP-CLI. Если у вас есть доступ к командной строке сервера: wp option get sandbox_theme_options --format=json выведет значение опции в формате JSON.

Проверка санитизации: как убедиться, что данные чистые

Санитизация работает невидимо — пользователь вводит данные, нажимает «Сохранить», и WordPress пропускает их через sanitize_callback перед записью в базу. Чтобы убедиться, что санитайзер действительно работает, проведите тест с заведомо грязными данными:

  1. В поле Site Title введите: <script>alert("XSS")</script>Test
  2. В поле Facebook URL введите: javascript:alert(1)
  3. Нажмите «Сохранить»
  4. Проверьте значения в базе — Site Title должен содержать только «Test», Facebook URL должен быть пустой строкой

Если sanitize_text_field удалил скрипт, а esc_url_raw отфильтровал опасный URL — санитизация работает правильно. Если в базе оказался скрипт — вы где-то забыли вызвать санитайзер для конкретного поля.

Проверяйте санитизацию на каждом новом поле сразу после его добавления. Найти пропущенный sanitize_text_field в момент добавления поля — дело пяти секунд. Найти его через месяц, когда поле уже используется в продакшене — головная боль и потенциальная уязвимость.

Полный код functions.php

Ниже приведён полный код functions.php темы-песочницы на данном этапе. Он включает регистрацию всех трёх групп опций, санитизацию, страницу опций через settings_fields() и do_settings_sections(), а также меню через add_theme_page():

'array', 'sanitize_callback' => 'sandbox_sanitize_options', 'default' => array( 'show_header' => 1, 'show_sidebar' => 1, 'show_footer' => 1, 'facebook_url' => '', 'twitter_url' => '', 'instagram_url' => '', 'site_title' => '', 'logo_url' => '', 'footer_text' => '', ), ) ); add_settings_section( 'sandbox_display_section', 'Display Options', 'sandbox_display_section_callback', 'sandbox_theme_options_page' ); $display_fields = array( 'show_header' => 'Show Header', 'show_sidebar' => 'Show Sidebar', 'show_footer' => 'Show Footer' ); foreach ( $display_fields as $key => $label ) { add_settings_field( 'sandbox_' . $key, $label, 'sandbox_checkbox_field_callback', 'sandbox_theme_options_page', 'sandbox_display_section', array( 'label_for' => 'sandbox_' . $key, 'option' => 'sandbox_theme_options', 'key' => $key ) ); } add_settings_section( 'sandbox_social_section', 'Social Network Links', 'sandbox_social_section_callback', 'sandbox_theme_options_page' ); $social_fields = array( 'facebook_url' => 'Facebook URL', 'twitter_url' => 'Twitter URL', 'instagram_url' => 'Instagram URL' ); foreach ( $social_fields as $key => $label ) { add_settings_field( 'sandbox_' . $key, $label, 'sandbox_url_field_callback', 'sandbox_theme_options_page', 'sandbox_social_section', array( 'label_for' => 'sandbox_' . $key, 'option' => 'sandbox_theme_options', 'key' => $key ) ); } add_settings_section( 'sandbox_general_section', 'General Settings', 'sandbox_general_section_callback', 'sandbox_theme_options_page' ); add_settings_field( 'sandbox_site_title', 'Site Title', 'sandbox_text_field_callback', 'sandbox_theme_options_page', 'sandbox_general_section', array( 'label_for' => 'sandbox_site_title', 'option' => 'sandbox_theme_options', 'key' => 'site_title' ) ); add_settings_field( 'sandbox_logo_url', 'Logo URL', 'sandbox_url_field_callback', 'sandbox_theme_options_page', 'sandbox_general_section', array( 'label_for' => 'sandbox_logo_url', 'option' => 'sandbox_theme_options', 'key' => 'logo_url' ) ); add_settings_field( 'sandbox_footer_text', 'Footer Text', 'sandbox_textarea_field_callback', 'sandbox_theme_options_page', 'sandbox_general_section', array( 'label_for' => 'sandbox_footer_text', 'option' => 'sandbox_theme_options', 'key' => 'footer_text' ) ); } add_action( 'admin_init', 'sandbox_register_settings' ); function sandbox_display_section_callback() { echo '

Choose which containers are visible on your site.

'; } function sandbox_social_section_callback() { echo '

Enter the full URLs to your social media profiles.

'; } function sandbox_general_section_callback() { echo '

Basic site identity settings.

'; } function sandbox_text_field_callback( $args ) { $options = get_option( $args['option'], array() ); $value = isset( $options[ $args['key'] ] ) ? $options[ $args['key'] ] : ''; echo ''; } function sandbox_url_field_callback( $args ) { $options = get_option( $args['option'], array() ); $value = isset( $options[ $args['key'] ] ) ? $options[ $args['key'] ] : ''; echo ''; } function sandbox_checkbox_field_callback( $args ) { $options = get_option( $args['option'], array() ); $checked = isset( $options[ $args['key'] ] ) ? (bool) $options[ $args['key'] ] : false; echo ''; echo ' '; } function sandbox_textarea_field_callback( $args ) { $options = get_option( $args['option'], array() ); $value = isset( $options[ $args['key'] ] ) ? $options[ $args['key'] ] : ''; echo ''; } function sandbox_sanitize_options( $input ) { $sanitized = array(); $checkbox_fields = array( 'show_header', 'show_sidebar', 'show_footer' ); foreach ( $checkbox_fields as $field ) { $sanitized[ $field ] = isset( $input[ $field ] ) && $input[ $field ] ? 1 : 0; } $url_fields = array( 'facebook_url', 'twitter_url', 'instagram_url', 'logo_url' ); foreach ( $url_fields as $field ) { $sanitized[ $field ] = isset( $input[ $field ] ) && ! empty( $input[ $field ] ) ? esc_url_raw( $input[ $field ] ) : ''; } $sanitized['site_title'] = isset( $input['site_title'] ) ? sanitize_text_field( $input['site_title'] ) : ''; $sanitized['footer_text'] = isset( $input['footer_text'] ) ? sanitize_textarea_field( $input['footer_text'] ) : ''; return $sanitized; } function sandbox_theme_options_page_html() { if ( ! current_user_can( 'manage_options' ) ) { return; } ?>

\u{201c}

Хороший код — это не тот, который работает. Хороший код — это тот, который работает, и другой разработчик понимает его за пять минут, а не за час. Группировка полей по типу, циклы вместо копипасты, самодокументированные имена функций — именно это отличает профессиональный код от учебного.

Часто задаваемые вопросы

Зачем удалять старый код перед написанием нового?

Старый учебный код содержит демонстрационные функции, которые конфликтуют с новой архитектурой. Оставленный add_plugins_page будет создавать дублирующее меню. Оставленные тестовые колбэки занимают место и сбивают с толку. Чистый functions.php — основа поддерживаемого проекта.

Почему все опции хранятся в одном массиве, а не по отдельности?

Один массив на все настройки темы означает один вызов get_option() при загрузке страницы вместо трёх. Санитизация тоже в одной функции, что упрощает поддержку. WordPress автоматически загружает опции с autoload=yes при старте, так что разница в производительности минимальна, но архитектурно массив чище.

Что делает settings_fields()?

settings_fields() выводит три скрытых поля формы: nonce для защиты от CSRF-атак, action=update для указания WordPress, что это форма сохранения настроек, и option_group для связи формы с зарегистрированной группой опций. Без этого вызова форма не сохранит данные.

Что делает do_settings_sections()?

do_settings_sections() рендерит все секции и поля, зарегистрированные для указанной страницы (page slug). Порядок вывода соответствует порядку вызовов add_settings_section(). Для каждой секции выводится заголовок, описание и все привязанные поля.

Обязательно ли проверять current_user_can в колбэке страницы?

Да. WordPress проверяет capability при показе пункта меню, но не при прямом переходе по URL. Пользователь, угадавший admin.php?page=sandbox-theme-options, увидит страницу. current_user_can() в начале колбэка блокирует несанкционированный доступ.

Чем отличается сохранение массива опций от одиночных полей?

При сохранении массива все поля обновляются одновременно одним вызовом update_option(). При одиночных полях каждое поле — отдельный вызов. Сравним подходы:

ХарактеристикаМассив (один ключ)Одиночные опции
АтомарностьВсе поля сохраняются или ни одногоПоля могут сохраниться частично
Количество запросов1 запрос на сохранениеN запросов по числу полей
Откат при ошибкеЛегко — возвращаем старый массивСложно — нужно отслеживать каждое поле
Удобство отладкиОдин дамп показывает всёНужно проверять N строк wp_options
в существующую секцию?

Добавьте ключ поля в массив $text_fields внутри sandbox_sanitize_options() и вызовите add_settings_field() с новым ключом внутри sandbox_register_settings(). Если новая секция не требуется, поле появится на странице автоматически после добавления одного вызова add_settings_field().

Как убедиться, что санитизация работает для URL-полей?

Введите javascript:alert(1) в поле Facebook URL, сохраните настройки и проверьте значение опции через get_option(). Корректно работающий esc_url_raw() вернёт пустую строку. Если вернулся javascript:alert(1) — санитайзер не отработал или не добрался до этого поля.

Почему используется add_theme_page(), а не add_menu_page()?

add_theme_page() помещает настройки в меню «Внешний вид» — стандартное место для опций темы. Пользователи ожидают найти настройки темы именно там. add_menu_page() создаёт отдельный пункт верхнего уровня, что оправдано для крупных плагинов, но для темы избыточно.

Что будет, если option_group в settings_fields() не совпадает с register_setting()?

Форма не сохранит данные. WordPress сверит option_group из запроса с зарегистрированными настройками, не найдёт совпадений и молча отклонит запрос. Никаких ошибок в интерфейсе не появится — это самая сложная для отладки ошибка Settings API.

Можно ли объединить настройки нескольких плагинов в одну страницу?

Да, но каждый плагин должен регистрировать свою группу опций и вызывать settings_fields() с разными параметрами. На одной странице не может быть двух вызовов settings_fields() — форма поддерживает только одну группу опций за раз. Используйте вкладки (tabs) для разделения групп на одной странице.

Нажмите для реакции