Практики PHP-разработки в WordPress

Ниже рассмотрим основные моменты, на которые стоит обращать внимание при написании PHP-кода для WordPress. Эти рекомендации помогут сократить количество лишних запросов, снизить нагрузку на сервер и сделать код безопаснее и понятнее.

Материал актуален для WordPress 7.0.2. Код рассчитан на PHP 7.4 и новее.

Эффективные запросы к базе данных

Для получения записей обычно используются WP_Query и get_posts(). Внутри get_posts() тоже работает WP_Query, поэтому по производительности эти способы близки.

get_posts() удобен, когда нужно просто получить массив записей. Для отдельного цикла и работы с пагинацией лучше использовать WP_Query. Чтобы изменить главный запрос страницы, используйте хук pre_get_posts, а не query_posts().

Отключайте ненужную работу

Кроме основного SQL-запроса, WP_Query может подсчитывать общее количество записей и заполнять кэши метаполей и терминов. Управлять этим можно с помощью аргументов:

  • 'no_found_rows' => true - не считать общее количество записей, если пагинация не нужна.
  • 'update_post_meta_cache' => false - не загружать метаполя записей в кэш, если они дальше не используются.
  • 'update_post_term_cache' => false - не загружать термины записей в кэш, если они не нужны.
  • 'fields' => 'ids' - вернуть только ID записей.

Например, получим ID последних 20 записей без подсчета страниц:

$post_ids = get_posts( [
	'post_type'      => 'post',
	'posts_per_page' => 20,
	'fields'         => 'ids',
] );

get_posts() сам включает no_found_rows, поэтому передавать этот аргумент здесь не нужно.

Если нужны объекты записей, но не нужны их метаполя и термины:

$query = new WP_Query( [
	'post_type'                 => 'post',
	'posts_per_page'            => 20,
	'no_found_rows'             => true,
	'update_post_meta_cache'    => false,
	'update_post_term_cache'    => false,
] );

Не отключайте кэши автоматически во всех запросах. Если дальше вызываются get_post_meta(), get_the_terms() или похожие функции, включенный кэш обычно уменьшает количество запросов к БД.

Начиная с WordPress 6.1, одинаковые запросы WP_Query кэшируются. При постоянном объектном кэше повторный SQL-запрос может вообще не выполняться до сброса соответствующего кэша.

Ограничивайте количество результатов

Не используйте 'posts_per_page' => -1, если количество записей может расти. Такой запрос однажды попробует загрузить в память десятки тысяч объектов.

Указывайте разумный лимит и используйте пагинацию или пакетную обработку. Для фоновой обработки большого числа записей выбирайте небольшие порции, например по 100-500 записей.

Осторожнее с мета-запросами

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

Для единичных запросов это нормально. Если по значению нужно постоянно фильтровать тысячи записей, лучше использовать таксономию, отдельную колонку или собственную таблицу.

То же относится к запросам с большим количеством условий и JOIN. Не переносите фильтрацию в PHP автоматически: сначала измерьте оба варианта. PHP-фильтрация может потребовать загрузить намного больше записей и сломать пагинацию.

Кэширование

Кэш нужен для данных, получение которых заметно нагружает базу, процессор или внешний сервис. Не кэшируйте все подряд: простой запрос к уже прогретому кэшу WordPress может быть дешевле собственного слоя кэширования.

Объектный кэш и транзиенты

Функции wp_cache_get() и wp_cache_set() работают с объектным кэшем. Без Redis, Memcached или другого постоянного хранилища такой кэш живет только в пределах одного запроса к сайту.

Transients API хранит временные данные между запросами даже без постоянного объектного кэша. При наличии Redis или Memcached транзиенты обычно хранятся там.

Кэш может исчезнуть раньше указанного срока, поэтому код всегда должен уметь создать данные заново:

$items = get_transient( 'my_plugin_items' );

if ( false === $items ) {
	$items = my_plugin_load_items();

	set_transient( 'my_plugin_items', $items, HOUR_IN_SECONDS );
}

В ключ кэша добавляйте параметры, влияющие на результат. Сбрасывайте кэш в момент изменения исходных данных, а срок хранения используйте как дополнительную страховку.

Полностраничный кэш

Полностраничный кэш сохраняет готовый HTML и отдает его без обычного запуска WordPress. Его может обеспечивать хостинг, веб-сервер, CDN или плагин.

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

Внешние запросы

Запрос к стороннему API не должен без необходимости выполняться при каждой загрузке страницы. Используйте wp_remote_get(), задавайте разумный timeout, проверяйте is_wp_error() и код ответа. Успешный результат кэшируйте.

Если внешний сервис временно недоступен, по возможности отдавайте предыдущие данные, а не задерживайте или ломайте всю страницу.

Правильное хранение данных

Выбирайте хранилище по тому, как данные будут использоваться:

  • Опции - настройки плагина или темы.
  • Метаполя - данные, относящиеся к конкретной записи, пользователю, комментарию или термину.
  • Таксономии - группировка и частая фильтрация записей.
  • Типы записей - отдельные сущности с заголовком, статусом, автором и другими возможностями WordPress.
  • Собственные таблицы - большие объемы однотипных данных, частые выборки, сортировка и агрегация по отдельным колонкам.

Не перегружайте опции

Количество строк в wp_options само по себе мало о чем говорит. Важнее размер автоматически загружаемых опций. Они читаются при запуске WordPress на каждом запросе.

Начиная с WordPress 6.6, значение аргумента $autoload по умолчанию равно null, и WordPress может определить его автоматически. Если выбор очевиден, передавайте true только для небольшой опции, которая нужна почти на каждой странице. Для опции, используемой на одной админ странице, передавайте false:

add_option( 'my_plugin_settings', $settings, '', false );

Строковые значения 'yes' и 'no' для $autoload устарели. Используйте true, false или null.

Размер автозагружаемых опций можно проверить в разделе "Инструменты -> Здоровье сайта". WordPress считает потенциальной проблемой общий размер более 800 КБ, но это ориентир, а не универсальный предел.

REST API и AJAX

Для нового публичного API используйте register_rest_route(), а не собственные rewrite rules. REST API уже умеет разбирать параметры, возвращать ошибки и JSON-ответы.

У каждого маршрута должен быть permission_callback. В нем проверяется, имеет ли пользователь право выполнить действие:

add_action( 'rest_api_init', 'my_plugin_register_rest_routes' );

function my_plugin_register_rest_routes() {
	register_rest_route(
		'my-plugin/v1',
		'/settings',
		[
			'methods'             => 'POST',
			'callback'            => 'my_plugin_update_settings',
			'permission_callback' => static function () {
				return current_user_can( 'manage_options' );
			},
		]
	);
}

Для полностью публичного маршрута можно использовать __return_true, но это должно быть осознанное решение.

admin-ajax.php по-прежнему подходит для существующего кода и простых действий в админке. Для нового API REST-маршрут обычно понятнее и удобнее.

Безопасность

Подробнее про безопасность читайте в отдельном разделе.

Безопасный обработчик обычно выполняет четыре отдельные проверки:

  • проверяет права пользователя;
  • проверяет nonce;
  • валидирует или очищает входные данные;
  • экранирует данные при выводе.

Одна проверка не заменяет другую.

Проверяйте права и nonce

Nonce защищает запрос от CSRF, но не подтверждает право пользователя выполнить действие. Поэтому вместе с nonce проверяйте current_user_can().

Для обычной формы в админке можно использовать wp_nonce_field() и check_admin_referer():

if ( ! current_user_can( 'manage_options' ) ) {
	wp_die( 'Access denied.' );
}

check_admin_referer( 'my_plugin_save_settings' );

Для AJAX есть check_ajax_referer(). Для REST API права проверяются в permission_callback.

Обрабатывайте входные данные

Данные из $_GET, $_POST и $_REQUEST в WordPress могут содержать добавленные слэши. Сначала используйте wp_unslash(), затем подходящую функцию очистки или валидации:

$title = isset( $_POST['title'] )
	? sanitize_text_field( wp_unslash( $_POST['title'] ) )
	: '';

Если допустимы только заранее известные значения, проверяйте их по белому списку со строгим сравнением:

$layout = isset( $_POST['layout'] )
	? sanitize_key( wp_unslash( $_POST['layout'] ) )
	: '';

if ( ! in_array( $layout, array( 'grid', 'list' ), true ) ) {
	$layout = 'grid';
}

Экранируйте при выводе

Функция экранирования зависит от места, куда вставляется значение:

  • esc_html() - обычный текст внутри HTML.
  • esc_attr() - значение HTML-атрибута.
  • esc_url() - URL в href, src и похожих атрибутах.
  • wp_kses_post() - HTML, в котором разрешены безопасные теги для содержимого записи.
  • wp_json_encode() - передача данных в JavaScript.

Экранируйте как можно ближе к месту вывода:

<a href="<?php echo esc_url( $url ); ?>">
	<?php echo esc_html( $title ); ?>
</a>

Подготавливайте SQL-запросы

По возможности используйте API WordPress. Если нужен прямой SQL-запрос, подставляйте значения через $wpdb->prepare():

global $wpdb;

$post = $wpdb->get_row( $wpdb->prepare(
	"SELECT * FROM $wpdb->posts WHERE ID = %d",
	$post_id
) );

Плейсхолдеры не нужно заключать в кавычки:

  • %d - целое число;
  • %f - число с плавающей точкой;
  • %s - строка;
  • %i - имя таблицы или колонки.

Для LIKE сначала используйте $wpdb->esc_like(), а затем передайте полученную строку в $wpdb->prepare() через %s.

Структура кода

Разделяйте ответственность темы и плагина

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

Если тема и плагин дополняют друг друга, проверяйте наличие нужной функции, класса или поддержки темы. Отсутствие одной части не должно приводить к фатальной ошибке.

Избегайте конфликтов имен

Для классов используйте уникальный namespace. Глобальные функции, константы, хуки, опции и ключи метаполей снабжайте уникальным префиксом проекта.

namespace WPKama\My_Plugin;

function register_hooks() {
	add_action( 'init', __NAMESPACE__ . '\\register_post_types' );
}

Делайте зависимости явными

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

Класс не должен одновременно регистрировать административную страницу, выполнять SQL-запросы, отправлять HTTP-запросы и выводить HTML. Разделяйте такие задачи только там, где это действительно упрощает код.

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

Подключение скриптов и стилей

Подключайте файлы через wp_enqueue_script() и wp_enqueue_style(). Указывайте зависимости и загружайте файл только на тех страницах, где он нужен.

Для независимых скриптов можно использовать стратегию defer или async. WordPress сам учитывает дерево зависимостей:

wp_enqueue_script(
	'my-plugin-front',
	plugins_url( 'assets/front.js', __FILE__ ),
	array(),
	MY_PLUGIN_VERSION,
	array(
		'strategy'  => 'defer',
		'in_footer' => true,
	)
);

async используйте только для скрипта, который не зависит от порядка выполнения других файлов.

Версию файла меняйте при изменении его содержимого. В опубликованном плагине обычно используется версия самого плагина. Во время разработки удобно использовать filemtime().

Переводы

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

printf(
	/* translators: %d: Number of remaining items. */
	esc_html( _n( '%d item left', '%d items left', $count, 'my-plugin' ) ),
	$count
);

Текстовый домен плагина должен совпадать с его slug. Заголовок Text Domain необязателен с WordPress 4.6, если домен совпадает со slug, но его можно оставить для ясности.

Переведенные строки экранируются по тем же правилам, что и остальные данные. Для простого текста удобно использовать esc_html__() и esc_html_e().

Качество и совместимость

WordPress 7.0 требует PHP 7.4 или новее, а рекомендуемая версия PHP - 8.3 или новее. Если плагин поддерживает только более новую версию PHP, явно укажите это в Requires PHP и проверяйте код на всех заявленных версиях.

Используйте WordPress Coding Standards и проверяйте код через PHP_CodeSniffer с пакетом WordPress/WordPress-Coding-Standards. Для поиска ошибок типов полезен статический анализатор, например PHPStan.

Автоматическими тестами в первую очередь покрывайте расчеты, права доступа, сохранение данных и исправленные ошибки. Для кода, тесно связанного с WordPress, интеграционные тесты обычно полезнее изолированных юнит-тестов.

Комментируйте причины неочевидных решений. Не пересказывайте комментариями то, что и так видно из кода.

PHP-сессии используйте только когда без них действительно нельзя. Они усложняют полностраничное кэширование и работу сайта на нескольких серверах. Для краткоживущего состояния часто подходят cookie, клиентское хранилище, метаданные пользователя или отдельное серверное хранилище.

Проверяйте результат измерениями

Не каждая дополнительная функция или SQL-конструкция создает заметную проблему. Сначала найдите узкое место, затем меняйте код.

Количество и время SQL-запросов удобно смотреть через Query Monitor. Нагрузку PHP измеряйте профилировщиком, а поведение постоянного объектного и полностраничного кэша проверяйте на окружении, похожем на рабочее.

--

За основу взят материал 10up Engineering Best Practices, но рекомендации переработаны с учетом современных API WordPress.