<? phpukraine СПІВБЕСІДИ
Пошук по платформі

WordPress для Middle: питання на співбесіду

5 питань рівня Middle з теми WordPress з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
5 питань
WP
WordPress·Middle ·безпека ·nonce ·capabilities

Nonce (wp_nonce_field + check_admin_referer) захищає від CSRF, current_user_can з конкретною здатністю — від перевищення прав, esc_* на виводі — від XSS, $wpdb->prepare — від SQL-інʼєкції. Це чотири різні шари, і жоден не замінює інший.

Nonce ви перевірили — навіщо тоді ще current_user_can?
У формі є wp_nonce_field, а дію все одно виконує передплатник. Як таке можливо?
Ми санітизуємо все на вході через sanitize_text_field. Чи потрібно щось робити на виводі?
У $wpdb->prepare підставляємо назву таблиці змінною — де тут проблема?
Форма на фронтенді під повносторінковим кешем: чому nonce «протухає» і що з цим робити?

Безпека плагіна — це чотири окремі шари, які закривають чотири різні атаки, і на співбесіді перевіряють саме те, чи людина їх не змішує. Nonce закриває CSRF: wp_nonce_field('crm_save_city') кладе у форму токен, обчислений з імені дії, id користувача, його токена сесії та поточного тіку часу, а check_admin_referer цей токен звіряє й у разі невдачі завершує запит через wp_die(-1) зі статусом 403. Capability закриває перевищення прав: current_user_can('manage_options') питає про конкретну здатність, а для окремого обʼєкта — мета-здатність з ідентифікатором, current_user_can('edit_post', $id), яка проходить через map_meta_cap і враховує авторство та статус запису. Ці дві перевірки не взаємозамінні: валідний nonce отримає будь-який залогінений користувач, що відкрив сторінку, а сама лише перевірка прав лишає адміністратора вразливим до запиту, ініційованого чужим сайтом його ж кукою.

Найпоширеніша діра в цьому місці — довіра до UI. Аргумент capability в add_menu_page лише вирішує, показувати пункт меню чи ні; функція-колбек, обробник admin_post_{action} і wp_ajax_{action} викликаються за прямим URL і мусять перевіряти права самі. Так само wp_ajax_nopriv_, доданий «щоб працювало для всіх», перетворює адмінську дію на публічну. Nonce для розлогінених користувачів рахується з user_id 0 і порожнього токена сесії, тобто однаковий для всіх гостей і CSRF-захисту не дає — це варто памʼятати перед тим, як будувати на ньому безпеку публічної форми.

Робота з даними ділиться на дві незалежні операції. На вході — санітизація: wp_unslash (WordPress історично додає слеші в $_POST і $_GET), потім sanitize_text_field, absint, sanitize_email, wp_kses_post — залежно від того, що це за поле. На виході — екранування, і воно має бути пізнім: esc_html у тексті, esc_attr в атрибуті, esc_url у href і src, wp_kses_post там, де розмітка дозволена. Контекст відомий лише в точці виводу, тому екранувати перед записом у базу — помилка: у полях накопичується &amp;#039;, а при зміні контексту захист усе одно не спрацює. Дані з бази, з опцій чи від іншого плагіна теж екрануються — «ми ж санітизували на вході» не аргумент, бо значення могло потрапити туди в обхід вашого коду.

SQL закривається $wpdb->prepare з плейсхолдерами %s, %d, %f, а з WP 6.2 — ще й %i для ідентифікаторів. Найтиповіша напівміра — підставити значення плейсхолдером, а назву таблиці або ORDER BY вклеїти в рядок конкатенацією. До 6.2 імена таблиць збирали з $wpdb->prefix, напрямок сортування й колонки звіряли з білим списком, і цей підхід лишається робочим для сумісності зі старими версіями. Для IN (...) генерують рядок плейсхолдерів потрібної довжини, для LIKE — спершу $wpdb->esc_like, і лише потім обгортають у %. Прості вставки й оновлення краще робити через $wpdb->insert, $wpdb->update і $wpdb->delete: вони екранують значення за переданим форматом і не дають забути про prepare.

Межа цих інструментів у тому, що вони працюють тільки там, де їх викликали. Прохід власного коду через phpcs з набором WordPress-Coding-Standards ловить неекранований вивід і запити без prepare автоматично й коштує дешевше за ручний рев'ю. Для REST-маршрутів роль nonce і capability бере на себе permission_callback разом із заголовком X-WP-Nonce, а для завантаження файлів жодна з чотирьох перевірок не допомагає — там потрібні wp_check_filetype_and_ext і wp_handle_upload, які перевіряють реальний тип, а не розширення.

// Форма в адмінці: nonce іде прихованим полем, дія має власне імʼя
function crm_city_form(): void
{
    ?>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="crm_save_city">
        <?php wp_nonce_field('crm_save_city'); // додає _wpnonce і _wp_http_referer ?>
        <input type="text" name="city"
               value="<?php echo esc_attr(get_option('crm_city', '')); ?>">
        <?php submit_button(); ?>
    </form>
    <?php
}

add_action('admin_post_crm_save_city', function (): void {
    // 1. CSRF: невірний або протухлий nonce -> wp_die(-1) зі статусом 403
    check_admin_referer('crm_save_city');

    // 2. Права: nonce каже «форма наша», а не «цьому користувачу можна»
    if (! current_user_can('manage_options')) {
        wp_die(esc_html__('Недостатньо прав', 'crm'), 403);
    }

    // 3. Вхід: WP слешить суперглобали, тому спершу wp_unslash, потім санітизація
    $city = sanitize_text_field(wp_unslash($_POST['city'] ?? ''));
    update_option('crm_city', $city);

    global $wpdb;
    $rows = $wpdb->get_results($wpdb->prepare(
        // %i — імʼя таблиці (WP 6.2+), %s — значення, %d — число
        'SELECT id, name FROM %i WHERE city = %s AND name LIKE %s LIMIT %d',
        $wpdb->prefix.'crm_leads',
        $city,
        '%'.$wpdb->esc_like($city).'%', // esc_like екранує _ і % у шаблоні
        20
    ));

    // 4. Екранування на виводі й під контекст: url у href, html у тексті
    foreach ($rows as $row) {
        printf('<a href="%s">%s</a>',
            esc_url(admin_url('admin.php?page=crm&lead='.(int) $row->id)),
            esc_html($row->name));
    }
});
Що nonce і capability відповідають на різні питання: nonce — «чи цей запит справді з нашої форми», capability — «чи цьому користувачу взагалі можна»; перевіряти треба обидва.
Що права перевіряють через конкретну здатність (manage_options, edit_others_posts), а для окремого обʼєкта — через мета-здатність з id: current_user_can('edit_post', $id), а не через is_user_logged_in() чи перевірку ролі.
Що аргумент capability в add_menu_page лише ховає пункт меню — сама функція-колбек лишається доступною за прямим URL і мусить перевіряти права самостійно.
Що екранують пізно, на самому виводі, і під контекст: esc_html у тексті, esc_attr в атрибуті, esc_url у href/src, wp_kses_post там, де HTML дозволений.
Що санітизація на вході (sanitize_text_field, absint) і екранування на виводі — різні речі; одне не скасовує іншого, бо дані можуть прийти з бази, з опції чи з іншого плагіна.
Що $wpdb->prepare підставляє лише значення (%s, %d, %f) та ідентифікатори (%i з WP 6.2), а $wpdb->insert/update/delete екранують самі за форматом.
Що перед санітизацією даних із $_POST/$_GET потрібен wp_unslash, бо WordPress історично додає слеші в суперглобали.
Вважати nonce перевіркою прав: «check_admin_referer пройшов, значить користувач має право» — nonce валідний у будь-якого залогіненого користувача, який відкрив сторінку.
Перевіряти роль замість здатності: current_user_can('administrator') працює випадково, ламається на кастомних ролях і не враховує map_meta_cap.
Покладатися на capability в add_menu_page чи на приховану кнопку в UI, лишивши обробник admin_post_ / wp_ajax_ без власної перевірки.
Реєструвати дію на wp_ajax_nopriv_ «щоб працювало у всіх» і відкрити запис у базу анонімам.
Екранувати на вході (esc_html перед update_post_meta) і зберігати в базі вже екранований текст: у полях зʼявляються &amp;#039; і подвійне екранування.
Ставити esc_html у href або esc_attr в URL: esc_html не відріже javascript:, а esc_url ще й фільтрує дозволені протоколи.
Писати $wpdb->query("SELECT ... WHERE id = $id") і виправдовувати це тим, що «$id все одно int» — без явного приведення чи %d це інʼєкція.
Використовувати prepare лише для частини аргументів: інтерполювати назву таблиці або ORDER BY у рядок запиту, а значення підставляти плейсхолдером.
Забувати $wpdb->esc_like перед LIKE: символи % і _ у введенні перетворюють пошук на повний перебір і міняють логіку умови.
ПОРАДА

Скажіть одним реченням, що це чотири незалежні шари: nonce — від CSRF, capability — від перевищення прав, escaping — від XSS, prepare — від SQL-інʼєкції, і що падіння будь-якого з них не компенсується іншими. Додайте, що екранування має бути «пізнім» — просто перед echo, бо тільки там відомий контекст виводу.

Сторінка питання →
WP
WordPress·Middle ·CPT ·таксономії ·wp_postmeta

CPT — це рядок у wp_posts із власним post_type, таксономія — нормалізована багато-до-багатьох звʼязка через term_relationships; таксономія годиться для фільтрів і категоризації, meta — для атрибутів запису, а власна таблиця потрібна, коли даних мільйони або потрібні складні фільтри й свої індекси.

Чому після реєстрації CPT сторінки записів віддають 404?
У нас 300 тисяч записів з двадцятьма meta-полями і фільтр по них вішає базу — що не так із моделлю даних?
Чим таксономія відрізняється від meta-поля, і як обрати між ними?
Коли ви б відмовились від post type і зробили окрему таблицю?

Custom post type не створює жодної таблиці. register_post_type('property', …) лише каже ядру, що в спільній wp_posts бувають рядки зі значенням post_type = 'property', і що для них треба показати пункт меню, застосувати правила перезапису й підключити потрібні шаблони. Тому питання «скільки типів реєструвати» майже не має ціни: у стандартній схемі є індекс type_status_date по (post_type, post_status, post_date, ID), і вибірка по типу лишається індексованою. Ціна ховається в іншому — усі типи ділять одну wp_posts і одну wp_postmeta, тож роздутий meta одного типу гальмує запити до решти.

Таксономія — це нормалізована звʼязка багато-до-багатьох через три таблиці: terms (назва й слаг), term_taxonomy (термін у контексті конкретної таксономії, з parent і count) і term_relationships (object_id, term_taxonomy_id). Остання має первинний ключ по обох колонках і окремий індекс по term_taxonomy_id, тобто запит «дай усі записи цього терміна» — це звичайний індексований JOIN. Meta влаштована інакше: wp_postmeta — це EAV з індексами лише по post_id і по meta_key (перші 191 символ), а по meta_value індексу немає взагалі. Кожна умова в meta_query додає ще один JOIN до тієї самої таблиці, тому фільтр каталогу з пʼяти meta-умов на 300 тисячах записів — це пʼять самозʼєднань по колонці без індексу.

Звідси практичне правило проєктування. Якщо за значенням фільтрують, воно повторюється між записами й для нього доречний архів з власним URL — це таксономія (місто, бренд, тип матеріалу). Якщо значення описує один конкретний запис і читається лише разом із ним — це meta (SKU, вага, координати). Ознака помилки очевидна: якщо термінів буде приблизно стільки ж, скільки записів, таксономія вироджується й тільки роздуває term_taxonomy. Реєструвати обидва треба на init — раніше ядро ще не готове, пізніше типу вже не побачить частина функцій — і робити це в плагіні, а не в темі: після зміни теми записи лишаться в базі, але без реєстрації типу стануть недоступними. Правила перезапису WordPress кешує в опції rewrite_rules, тому після появи нового типу з has_archive або власним rewrite потрібен один flush_rewrite_rules() у register_activation_hook — саме його відсутність дає 404 на сторінках записів. Викликати flush на кожному init не можна: це запис в options на кожен запит.

Власну таблицю варто робити за чотирма ознаками, не за однією. Обсяг: сотні тисяч рядків, що ростуть, особливо коли це не контент, а події — перегляди, ціни постачальників, лог доставок. Інтенсивність запису: wp_posts тягне за собою revisions, autosave, save_post і чужі плагіни на кожен UPDATE. Потреба у власних складених індексах під конкретні фільтри й сортування, чого над EAV зробити неможливо. І відсутність потреби в інфраструктурі поста: ні редактора, ні статусів, ні коментарів, ні прав. Схему створюють через dbDelta() у хуку активації, зберігаючи версію схеми в опції для наступних міграцій, а запити пишуть через $wpdb->prepare().

Компроміс тут прямий і його варто назвати вголос: із власною таблицею ви втрачаєте WP_Query, кеш обʼєктів, адмінку, REST, права, ревізії, багатомовність і сумісність з плагінами, що працюють з постами. Усе це доведеться писати руками — список через WP_List_Table, ендпоїнти через register_rest_route. Тому в реальних проєктах частіше виграє гібрид: дані й фільтрація живуть у власній таблиці, а CPT лишається вітриною зі шаблоном, URL і SEO, звʼязаною по post_id. А перед тим, як іти в цей бік, дешевший крок — перевести фільтровані атрибути з meta в таксономії й перевірити, чи цього вже досить.

add_action('init', function (): void {
    // Службовий тип: без фронтенду, керується лише з адмінки
    register_post_type('property', [
        'labels'       => ['name' => 'Обʼєкти', 'singular_name' => 'Обʼєкт'],
        'public'       => true,
        'has_archive'  => true,          // потребує flush після реєстрації
        'rewrite'      => ['slug' => 'nerukhomist', 'with_front' => false],
        'supports'     => ['title', 'editor', 'thumbnail', 'custom-fields'],
        'show_in_rest' => true,          // без цього не працює Gutenberg і REST
        'menu_icon'    => 'dashicons-building',
    ]);

    // Фільтрований атрибут -> таксономія, бо значення повторюються
    register_taxonomy('property_city', ['property'], [
        'hierarchical' => true,          // як категорії, а не як теги
        'public'       => true,
        'show_in_rest' => true,
        'rewrite'      => ['slug' => 'misto'],
    ]);
});

// Flush лише при активації плагіна, ніколи не на кожному запиті
register_activation_hook(__FILE__, function (): void {
    do_action('init');                   // типи мають бути вже зареєстровані
    flush_rewrite_rules();
});

// Так робити не треба: три JOIN до wp_postmeta без індексу по meta_value
$slow = new WP_Query(['post_type' => 'property', 'meta_query' => [
    ['key' => 'city', 'value' => 'Львів'],
    ['key' => 'rooms', 'value' => 3, 'type' => 'NUMERIC'],
    ['key' => 'floor', 'value' => 5, 'compare' => '<=', 'type' => 'NUMERIC'],
]]);

// Те саме через таксономію + одну meta: звʼязка йде по term_relationships
$fast = new WP_Query([
    'post_type' => 'property',
    'tax_query' => [['taxonomy' => 'property_city', 'terms' => 'lviv', 'field' => 'slug']],
    'meta_query' => [['key' => 'rooms', 'value' => 3, 'type' => 'NUMERIC']],
]);
Що CPT не створює таблицю: всі записи лежать в одному wp_posts, а post_type це просто колонка, тому кількість типів на продуктивність не впливає, а кількість рядків впливає.
Що таксономія вже індексована під запит «дай усі записи терміна» (term_relationships з ключем по term_taxonomy_id), а meta — ні, бо wp_postmeta має індекс лише по meta_key(191) і post_id.
Що register_post_type і register_taxonomy треба викликати на init, а не раніше й не пізніше, і після зміни rewrite-правил потрібен flush_rewrite_rules — саме звідси 404 на сторінках записів.
Що meta_query по кількох ключах перетворюється на кілька JOIN до wp_postmeta по одній таблиці, і на сотнях тисяч записів це головна причина повільного каталогу.
Що критерій для власної таблиці — це обсяг, частота запису, потреба у власних складених індексах і в тому, щоб дані не тягли за собою revisions, autosave й адмінку wp_posts.
Реєструвати post type у файлі теми поза хуком init або на after_setup_theme і потім дивуватись, що частина функцій ядра типу не бачить.
Викликати flush_rewrite_rules() на кожному завантаженні сторінки замість register_activation_hook — це перезапис опції rewrite_rules на кожен запит.
Тримати CPT у темі: після зміни теми записи лишаються в базі, але стають недоступними, бо тип більше ніде не зареєстрований.
Робити таксономію з даних, які не групують записи: ціна, дата, SKU, рейтинг — це meta, бо термінів буде стільки ж, скільки записів.
Будувати фільтр каталогу на meta_query з п'яти умов і лікувати повільність кешем сторінки, замість того щоб винести атрибути в таксономії або власну таблицю.
Забувати 'public' => false для службових типів і отримувати сторінки-порожняки в sitemap та у видачі.
ПОРАДА

Скажіть просте правило: якщо за значенням будуть фільтрувати або потрібен архів і URL — це таксономія; якщо значення описує один конкретний запис і його лише читають разом із записом — це meta; якщо рядків мільйони або потрібен свій складений індекс — це власна таблиця з $wpdb, а CPT лишається лише вітриною.

Сторінка питання →
WP
WordPress·Middle ·REST API ·register_rest_route ·permission_callback

register_rest_route на хуку rest_api_init: namespace/версія, methods, callback, обовʼязковий permission_callback з current_user_can і args зі sanitize_callback та validate_callback; помилки повертаються як WP_Error зі status.

Куди вішати register_rest_route і чому виклик у плагіні «просто так» не працює?
Ендпоінт віддає 401 з браузера, хоча користувач залогінений — у чому річ?
Ми написали type: integer в args, але приходить рядок і код падає. Чому WordPress не перевірив?
Чим permission_callback відрізняється від перевірки прав усередині callback?

REST API в ядрі з WP 4.7, і власний маршрут реєструється однією функцією — register_rest_route($namespace, $route, $args). Ключове обмеження: викликати її можна лише на хуку rest_api_init, бо саме там ядро створює WP_REST_Server і збирає таблицю маршрутів. Виклик у файлі плагіна або на init мовчки нічого не дасть — маршрут просто не зʼявиться у /wp-json/. Namespace має вигляд vendor/v1: версія живе в namespace, а не в шляху, щоб згодом можна було випустити vendor/v2 поруч зі старим. Динамічні сегменти описуються іменованою групою регулярного виразу, /leads/(?P<id>\d+), і потрапляють у $request['id']. Якщо на сайті plain-перміалінки, /wp-json/ не працює й потрібен запасний вигляд ?rest_route=/crm/v1/leads — про це варто памʼятати, коли ендпоінт «не існує» лише на одному стенді.

Права перевіряє permission_callback, і з WP 5.5 це обовʼязковий аргумент: без нього ядро пише _doing_it_wrong, але маршрут усе одно реєструється і лишається публічним. Тому забутий permission_callback — це не помилка розробки, а відкритий ендпоінт у продакшені; для навмисно публічного маршруту пишуть явне 'permission_callback' => '__return_true', щоб намір було видно з коду. Усередині перевіряють здатність через current_user_can з конкретною capability, а для дії над конкретним записом — мета-здатність з id: current_user_can('edit_post', (int) $request['id']), бо вона проходить через map_meta_cap і враховує авторство та статус. is_user_logged_in() як перевірка прав означає, що будь-який передплатник дістає доступ до адмінської дії. Повертати з колбеку можна true, false або WP_Error; зручний хелпер rest_authorization_required_code() дає 401 для гостя й 403 для залогіненого.

Валідація описується в масиві args. Тут є пастка, на якій валяться на співбесіді: у рукописному args ключі type, enum, format, minimum самі по собі нічого не перевіряють — вони лише потрапляють у схему, яку віддає запит OPTIONS. Реальну перевірку робить validate_callback, тому в кожен параметр підставляють 'validate_callback' => 'rest_validate_request_arg', і лише тоді enum чи minimum починають відхиляти запит з кодом rest_invalid_param і статусом 400. Контролери, успадковані від WP_REST_Controller, отримують це безкоштовно: rest_get_endpoint_args_for_schema() будує args з get_item_schema() і сам додає rest_validate_request_arg та rest_sanitize_request_arg. Порядок теж важливий: ядро спершу валідує всі параметри (has_valid_params), потім санітизує (sanitize_params), тож у валідатор приходить сире значення. Санітизація без валідації небезпечна тим, що absint('abc') тихо перетворить сміття на 0 і запит виконається не з тими даними.

Автентифікація — окремий шар від авторизації. Запит із браузера на тому ж домені йде під cookie, але cookie в REST довіряють лише разом із nonce дії wp_rest: його передають у заголовку X-WP-Nonce або параметром _wpnonce, інакше ядро повертає rest_cookie_invalid_nonce, і current_user_can у вашому колбеку бачить гостя. Класичний антипатерн — «полікувати» цей 401 заміною перевірки на __return_true. Для зовнішніх клієнтів cookie не підходять взагалі: з WP 5.6 у ядрі є Application Passwords, які працюють як Basic Auth поверх HTTPS і не дають доступу до адмінки.

Відповідь формують поверненням масиву, WP_REST_Response або WP_Error — жодних echo і wp_die(), які ламають JSON і віддають 200 замість потрібного коду. WP_Error з ['status' => 4xx] ядро саме серіалізує у {code, message, data}. Для колекцій варто повторити ядрову поведінку: заголовки X-WP-Total і X-WP-TotalPages, параметри page і per_page з minimum/maximum, інакше per_page=100000 стане найдешевшим способом покласти базу. І межа розумності: власний маршрут виправданий там, де ресурс не мапиться на наявний, — агрегації, дії, інтеграції. Якщо треба лише додати поле до поста, дешевше register_rest_field() або register_post_meta() з 'show_in_rest' => true, ніж дублювати половину WP_REST_Posts_Controller.

// Маршрути реєструються ЛИШЕ на rest_api_init: раніше WP_REST_Server ще не існує
add_action('rest_api_init', function (): void {
    register_rest_route('crm/v1', '/leads', [
        'methods'  => WP_REST_Server::CREATABLE, // POST
        'callback' => 'crm_create_lead',
        // Обовʼязковий з WP 5.5; для публічного ендпоінта пишуть явне '__return_true'
        'permission_callback' => static function (WP_REST_Request $request): bool|WP_Error {
            if (! current_user_can('edit_others_posts')) {
                return new WP_Error(
                    'crm_forbidden',
                    'Недостатньо прав для створення ліда',
                    ['status' => rest_authorization_required_code()] // 401 гостю, 403 залогіненому
                );
            }

            return true;
        },
        'args' => [
            'email' => [
                'required'          => true,
                'type'              => 'string',
                'format'            => 'email',
                // Без validate_callback ключі type і format лишаються лише документацією схеми
                'validate_callback' => 'rest_validate_request_arg',
                'sanitize_callback' => 'sanitize_email',
            ],
            'source' => [
                'type'              => 'string',
                'enum'              => ['form', 'phone', 'import'],
                'default'           => 'form',
                'validate_callback' => 'rest_validate_request_arg',
            ],
        ],
    ]);
});

function crm_create_lead(WP_REST_Request $request): WP_REST_Response|WP_Error
{
    $postId = wp_insert_post([
        'post_type'   => 'crm_lead',
        'post_status' => 'private',
        'post_title'  => $request['email'],       // вже санітизоване
        'meta_input'  => ['source' => $request['source']],
    ], true); // true — повертати WP_Error замість 0

    if (is_wp_error($postId)) {
        $postId->add_data(['status' => 500]);

        return $postId; // ядро саме перетворить WP_Error на JSON з потрібним кодом
    }

    return new WP_REST_Response(['id' => $postId], 201);
}
Що маршрути реєструються лише на хуку rest_api_init, і namespace має вигляд vendor/v1 — версія в namespace, а не в шляху.
Що permission_callback обовʼязковий з WP 5.5: без нього ядро кидає _doing_it_wrong, а ендпоінт лишається публічним; для справді публічного треба явне '__return_true'.
Що права перевіряють через current_user_can з конкретною здатністю, а для конкретного обʼєкта — з його id: current_user_can('edit_post', $id), а не is_user_logged_in().
Що в args працюють sanitize_callback і validate_callback, а сам по собі ключ type у рукописному масиві args нічого не перевіряє — потрібен явний rest_validate_request_arg.
Що помилку повертають як WP_Error з ['status' => 4xx], а не echo/wp_die, і що rest_authorization_required_code() дає 401 для гостя й 403 для залогіненого.
Що cookie-автентифікація в REST вимагає nonce wp_rest у заголовку X-WP-Nonce, а для зовнішніх клієнтів є Application Passwords (WP 5.6+).
Викликати register_rest_route одразу при завантаженні плагіна, а не на rest_api_init — маршрут не зареєструється, бо WP_REST_Server ще не створений.
Ставити permission_callback => '__return_true' на ендпоінт, що пише в базу, «щоб не заважало», і отримати відкритий запис для анонімів.
Перевіряти is_user_logged_in() замість здатності: будь-який передплатник отримує доступ до адмінських дій.
Описати 'type' => 'integer' в args без validate_callback і вважати, що ядро перевірить тип; насправді валідація запускається лише за наявності validate_callback.
Повертати з callback echo json_encode(...) або wp_die() — відповідь ламає JSON і виходить 200 замість коректного статусу.
Забувати X-WP-Nonce у fetch з фронтенду й лікувати 401/403 тим, що ставлять '__return_true'.
Тестувати ендпоінт лише на /wp-json/ і дивуватись 404 на сайті з plain-перміалінками, де працює ?rest_route=.
ПОРАДА

Скажіть, що permission_callback — це не «додаткова опція», а обовʼязковий аргумент з WP 5.5, і що ключ type в args — документація для схеми, а не валідація: перевірку вмикає rest_validate_request_arg. Додайте, що для складніших ресурсів варто успадкувати WP_REST_Controller, бо там args генеруються з get_item_schema через rest_get_endpoint_args_for_schema і валідатори підставляються автоматично.

Сторінка питання →
WP
WordPress·Middle ·Gutenberg ·блоки ·block.json

Сучасний шлях — block.json і register_block_type з шляхом до директорії блоку: метадані, скрипти й стилі описуються декларативно, а PHP-рендер задається через render або render_callback.

Динамічний чи статичний блок: у чому різниця?
Як передати дані з PHP у редактор блоку?
Чому блок є в редакторі, але не рендериться на фронтенді?

Сучасна реєстрація блоку починається з block.json. У ньому описано все: назва й категорія, схема атрибутів, supports, скрипт редактора, стилі, скрипт фронтенду і, з WordPress 6.1, файл render.php для серверного рендеру. PHP-код зводиться до register_block_type зі шляхом до директорії: WordPress сам прочитає метадані, зареєструє ассети й підключить їх лише там, де блок реально використаний.

Ключове рішення при проєктуванні — статичний чи динамічний блок. Статичний зберігає готовий HTML у post_content через save(): швидко, не залежить від плагіна, але контент застигає. Динамічний повертає з save() null, а розмітку будує PHP на кожен показ: правильний вибір для списків, цін, будь-чого, що змінюється або залежить від користувача.

Атрибути статичних блоків живуть у коментарях розмітки, тому зміна схеми чи save() без масиву deprecated ламає всі вже вставлені блоки. Дані з PHP у редактор передають через REST API або inline-скрипт до editorScript, а вивід у render.php обовʼязково екранують. Для інтерактивності на фронтенді замість власного React-бандла зараз використовують Interactivity API з viewScriptModule.

// build/pricing/block.json (генерується з src через @wordpress/scripts)
// {
//   "apiVersion": 3, "name": "shop/pricing", "title": "Тарифи",
//   "attributes": { "plan": { "type": "string", "default": "pro" } },
//   "supports": { "align": ["wide"], "color": { "background": true } },
//   "editorScript": "file:./index.js", "style": "file:./style-index.css",
//   "viewScriptModule": "file:./view.js",
//   "render": "file:./render.php"
// }

// plugin.php: реєструємо директорію, WordPress читає block.json сам
add_action('init', function (): void {
    register_block_type(__DIR__.'/build/pricing');
});

// build/pricing/render.php: динамічний рендер, $attributes і $content доступні
$plan = sanitize_key($attributes['plan'] ?? 'pro');
$price = shop_plan_price($plan);
?>
<div <?php echo get_block_wrapper_attributes(['class' => 'pricing pricing--'.$plan]); ?>>
    <strong><?php echo esc_html(shop_plan_title($plan)); ?></strong>
    <span><?php echo esc_html(number_format_i18n($price / 100, 2)); ?> грн</span>
</div>
Що block.json є єдиним джерелом метаданих: назва, атрибути, supports, editorScript, style, viewScript, а PHP лише реєструє директорію.
Різницю між статичним блоком, у якого HTML зберігається в post_content із save(), і динамічним, який рендериться PHP на кожен показ.
Що атрибути блоку зберігаються в коментарі-розмітці, тому зміна їхньої схеми потребує deprecations у JS, інакше редактор покаже block validation error.
Що дані з PHP у редактор передаються через REST API, wp_localize_script або wp_add_inline_script на editorScript, а не через глобальні змінні в шаблоні.
Що збірка йде через @wordpress/scripts, а render.php у block.json з WP 6.1 замінює render_callback.
Реєструвати блок лише в JS через registerBlockType без block.json: WordPress не знає про ассети й не може лениво їх завантажити.
Робити статичний блок для контенту, який змінюється: список останніх постів, курси, ціни, бо збережений HTML застаріває.
Змінювати атрибути або розмітку save() без deprecated-версій і ламати всі вже вставлені блоки.
Виводити через render_callback необроблений HTML з атрибутів без esc_html і wp_kses_post.
Підключати editorScript на фронтенді або viewScript в адмінці, роздуваючи обидва бандли.
ПОРАДА

Уточніть, що туторіали з wp.blocks.registerBlockType у JS без block.json уже вважаються застарілими. Згадайте render.php і Interactivity API як актуальний напрям.

Сторінка питання →
WP
WordPress·Middle ·продуктивність ·object cache ·WP_Query

Спершу профілювання через Query Monitor, потім persistent object cache у Redis, правильні аргументи WP_Query, індекси або власні таблиці замість meta_query і повне кешування сторінок на рівні nginx.

Чому сторінка каталогу з фільтрами відкривається 6 секунд?
Чим transients відрізняються від object cache?
З чого почати оптимізацію WooCommerce на 50 000 товарів?

Оптимізація починається з вимірювання. Query Monitor показує кожен SQL-запит сторінки з часом і стеком викликів, і в каталозі майже завжди винен один тип запиту: WP_Query з meta_query по кількох ключах. Таблиця wp_postmeta має EAV-структуру, кожен ключ у фільтрі додає JOIN на її копію, а індексу по meta_value немає. На 50 000 товарів такий запит триває секунди.

Далі йдуть три рівні. Перший — persistent object cache у Redis: без нього WordPress на кожен запит перечитує опції, мету й терми, а transients живуть у wp_options і самі стають повільними запитами. Другий — правильні аргументи WP_Query: no_found_rows там, де не потрібна пагінація, fields => ids для списків, вимкнені update_post_meta_cache і update_post_term_cache, коли мета не читається. Третій — структура даних: атрибути з обмеженим набором значень переносяться в таксономії, числові діапазони й багатовимірні фільтри у власну таблицю з індексами або в зовнішній пошуковий рушій.

Останній шар — повне кешування сторінок на nginx або Varnish для всього, що не персоналізоване, з інвалідацією по save_post. Кошик, чекаут і кабінет із цього кешу виключаються, а їхні дорогі фрагменти кешуються через wp_cache з контекстом у ключі.

// Повільно: meta_query по трьох ключах = три JOIN на wp_postmeta без індексу по meta_value
$q = new WP_Query([
    'post_type'  => 'product',
    'meta_query' => [
        ['key' => '_color', 'value' => 'red'],
        ['key' => '_size',  'value' => 'M'],
        ['key' => '_price', 'value' => [100, 500], 'compare' => 'BETWEEN', 'type' => 'NUMERIC'],
    ],
]);

// Швидше: атрибути в таксономіях, лише id, без підрахунку found_rows і зайвих кешів
$ids = (new WP_Query([
    'post_type'              => 'product',
    'fields'                 => 'ids',
    'posts_per_page'         => 24,
    'no_found_rows'          => true,   // без SQL_CALC_FOUND_ROWS
    'update_post_meta_cache' => false,
    'update_post_term_cache' => false,
    'tax_query'              => [
        ['taxonomy' => 'pa_color', 'field' => 'slug', 'terms' => 'red'],
        ['taxonomy' => 'pa_size',  'field' => 'slug', 'terms' => 'm'],
    ],
]))->posts;

// Дорогий результат у object cache (Redis), а не в wp_options
$facets = wp_cache_get('catalog_facets', 'shop');
if ($facets === false) {
    $facets = shop_build_facets();
    wp_cache_set('catalog_facets', $facets, 'shop', 10 * MINUTE_IN_SECONDS);
}
Що починаєте з вимірювання: Query Monitor або New Relic показують найповільніший запит, а не здогадки про кеш-плагіни.
Що головний ворог каталогу це meta_query по кількох ключах: wp_postmeta з EAV-структурою робить JOIN на кожен ключ і не має індексу по значенню.
Що persistent object cache у Redis чи Memcached кешує результати wp_cache і get_option між запитами, без нього WordPress перечитує опції та мету щоразу.
Що аргументи WP_Query мають значення: no_found_rows, fields => ids, update_post_meta_cache і update_post_term_cache вимкнені там, де не потрібні.
Що для фільтрів каталогу масштабується лише власна таблиця з індексами або зовнішній пошуковий рушій, а повне сторінкове кешування знімає навантаження з усього, що не персоналізоване.
Ставити ще один кеш-плагін замість пошуку повільного запиту.
Плутати transients і object cache: transient без persistent cache живе в wp_options і сам стає повільним запитом.
Використовувати posts_per_page => -1 і потім фільтрувати в PHP.
Робити фільтри каталогу через meta_query з пʼятьма ключами й дивуватись JOIN на пʼять копій wp_postmeta.
Кешувати сторінку цілком разом із кошиком чи іменем користувача й показувати чужі дані.
ПОРАДА

Скажіть, що починаєте з профілювання Query Monitor, а не з встановлення кеш-плагіна навмання. І назвіть, який саме запит зазвичай виявляється винним.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти