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

Питання на співбесіду WordPress

Питання для WordPress-розробників: хуки й фільтри, цикл запиту, кастомні типи записів, REST API, продуктивність і безпека плагінів.

Тема
Рівень
8 питань
WP
WordPress·Junior ·хуки ·actions ·filters

Action виконує побічну дію в певній точці життєвого циклу й нічого не повертає; filter отримує значення, змінює його й обовʼязково повертає.

Що станеться, якщо фільтр нічого не поверне?
Як контролювати порядок виконання кількох хуків?
Чому після активації плагіна зник контент сторінок?

Хуки — це спосіб WordPress дозволити чужому коду втрутитись у свій без правки ядра. Action виконує побічну дію в певній точці: щось вивести, записати в лог, надіслати лист. Він нічого не повертає. Filter отримує значення, наприклад заголовок або контент, змінює його й повертає далі по ланцюжку. Внутрішньо обидва живуть в одному реєстрі WP_Hook, і add_action є обгорткою над add_filter; різниця лише в тому, чи використовується результат.

Найчастіша помилка новачків — фільтр без return. Колбек повертає null, null іде далі по ланцюжку і в код, який викликав apply_filters, і на сайті зникають заголовки, контент чи ціни. WordPress не перевіряє тип результату, тому помилка тиха.

Порядок задається пріоритетом: менше число виконується раніше, за замовчуванням 10. Якщо два плагіни фільтрують the_content з однаковим пріоритетом, результат залежить від порядку завантаження, і саме звідси більшість конфліктів. Четвертий аргумент accepted_args визначає, скільки параметрів отримає колбек; без нього другий аргумент фільтра не прийде.

Щоб зняти чужий хук, remove_filter потребує те саме імʼя, той самий колбек і той самий пріоритет. Тому у власних плагінах хуки краще реєструвати іменованими функціями або методами з доступним екземпляром, а не анонімними замиканнями.

// Action: побічна дія, нічого не повертає
add_action('wp_footer', function (): void {
    echo '<!-- rendered at '.date('c').' -->';
});

// Filter: отримує значення й ОБОВʼЯЗКОВО повертає
add_filter('the_title', function (string $title, int $postId): string {
    return is_admin() ? $title : trim($title).' ✦';
}, 10, 2); // пріоритет 10, колбек приймає 2 аргументи

// Класична помилка: контент зникає, бо повертається null
add_filter('the_content', function (string $content): void {
    str_replace('[year]', date('Y'), $content); // немає return
});

// Свій код стає розширюваним
function shop_shipping_cost(int $cents): int
{
    return (int) apply_filters('shop_shipping_cost', $cents);
}
do_action('shop_order_shipped', $orderId);

// Знімаємо чужий хук: імʼя + колбек + пріоритет мають збігатись
add_action('init', fn () => remove_filter('the_content', 'wpautop', 10), 20);
Що обидва механізми побудовані на одному реєстрі: add_filter і add_action це та сама функція, різниця в тому, чи використовується повернене значення.
Що забутий return у фільтрі повертає null, і саме так зникають контент, заголовки чи ціни після активації плагіна.
Що пріоритет визначає порядок: менше число виконується раніше, за замовчуванням 10, і конфлікти плагінів часто саме через однакові пріоритети.
Що четвертий аргумент accepted_args обмежує, скільки параметрів отримає колбек, і без нього другий аргумент фільтра просто не прийде.
Що remove_action і remove_filter потребують той самий колбек і пріоритет, тому анонімні функції в хуках не можна зняти.
Казати, що це синоніми або що різниця лише в назві.
Забувати return у фільтрі й ламати вивід усього сайту.
Використовувати action там, де треба змінити значення: писати в глобальну змінну замість повернення з фільтра.
Вішати хуки на анонімні функції або в неправильний момент, наприклад до plugins_loaded, коли потрібні класи ще не завантажені.
Не вказувати accepted_args і дивуватись, що $post чи $context у колбеку відсутній.
ПОРАДА

Додайте, що пріоритет хука визначає порядок, і конфлікти плагінів найчастіше саме через однакові пріоритети. Згадайте apply_filters і do_action як точки, де ваш код сам стає розширюваним.

Сторінка питання →
WP
WordPress·Junior ·ієрархія шаблонів ·child theme ·template-loader

Після резолву запиту wp-includes/template-loader.php перебирає умовні теги (is_404, is_search, is_front_page, is_home, is_singular, is_archive…) і для першого збігу будує список кандидатів від найконкретнішого до index.php; locate_template() шукає кожного кандидата спершу в child theme, потім у parent, тому перекриття — це файл із такою самою назвою в дочірній темі.

Я поклав у тему файл page-contacts.php, а сторінка «Контакти» все одно рендериться через page.php. Чому?
Який файл теми відповість за URL /category/news/, якщо в темі є index.php, archive.php і category.php?
Чому після оновлення батьківської теми зникли всі мої правки в single.php?
У чому різниця між get_template_directory() і get_stylesheet_directory()?

Коли WordPress відпрацював WP_Query і знає, що саме запитали, керування отримує wp-includes/template-loader.php. Він послідовно перевіряє умовні теги — is_embed(), is_404(), is_search(), is_front_page(), is_home(), is_post_type_archive(), is_tax(), is_attachment(), is_single(), is_page(), is_singular(), is_category(), is_tag(), is_author(), is_date(), is_archive() — і для першого збігу викликає відповідну get_*_template(). Ця функція не шукає файл сама: вона будує масив імен-кандидатів від найконкретнішого до найзагальнішого і передає його в get_query_template(), а той у locate_template(). Тобто «ієрархія» — це буквально впорядкований масив рядків, і будь-який запит завершується щонайпізніше на index.php, який тому й обовʼязковий для валідної теми.

Конкретність кандидатів рахується від даних поточного обʼєкта. Для одного запису це single-{post_type}-{post_name}.php, single-{post_type}.php, single.php, singular.php, index.php; для сторінки — спершу кастомний шаблон із метаполя _wp_page_template, далі page-{slug}.php, page-{id}.php, page.php, singular.php; для категорії — category-{slug}.php, category-{id}.php, category.php, archive.php. Звідси дві типові пастки джуна: слаг береться з post_name, а не з заголовка, тому сторінка «Контакти» з URL /kontakty/ шукає page-kontakty.php; і ID/слаг беруться саме поточного терміна, без успадкування від батьківської категорії. Окремо стоїть головна: front-page.php перекриває все, а якщо його немає, вибір залежить від налаштування «Головна сторінка відображає» — статична сторінка йде в page-гілку, стрічка постів у home.php.

Дочірня тема виграє не тому, що має якийсь пріоритет, а через порядок пошуку всередині locate_template(): спершу STYLESHEETPATH (активна, тобто дочірня, тема), потім TEMPLATEPATH (батьківська), потім wp-includes/theme-compat. Тому перекриття будь-якого шаблону — це просто файл з такою самою назвою в теці дочірньої теми, без жодної реєстрації. Сама дочірня тема — це тека з style.css, у заголовку якого є рядок Template: з іменем теки батьківської теми. Стилі батьківської теми дочірня має підключити сама через wp_enqueue_style на хуку wp_enqueue_scripts — застарілий @import у CSS сповільнює завантаження і його давно не використовують.

Виняток один, але важливий: functions.php через locate_template() не проходить. WordPress підключає обидва файли — спершу дочірній, потім батьківський, — тому копіювати батьківський цілком не можна, це фатальна помилка про повторне оголошення. Замінити функцію батьківської теми вдасться лише тоді, коли вона обгорнута в if (! function_exists()); інакше залишаються remove_action, remove_filter або передбачені темою фільтри. Так само не перекриється частина шаблону, яку батьківська тема підключає жорстким include get_template_directory().'/...' замість get_template_part() — це найчастіша причина «чому мій файл ігнорується».

Коли файлу створювати не хочеться або код живе в плагіні, у вибір можна втрутитися фільтрами: {$type}_template_hierarchy (зʼявився у WP 4.7) міняє список кандидатів до пошуку, {$type}_template — уже знайдений шлях, а template_include спрацьовує останнім і перекриває всіх. Обмеження теж варто памʼятати: у блокових темах (WP 5.9 і новіші) та сама логіка додатково шукає HTML-файли в теці templates/ і шаблони, збережені користувачем у базі як записи wp_template, і знайдений блоковий шаблон має перевагу над однойменним PHP-файлом. І практична порада замість зубріння схеми: повісьте на template_include логер або поставте Query Monitor — він показує і перелік кандидатів, і файл-переможець.

// wp-content/themes/parent-child/style.css має містити заголовок:
// Theme Name: Parent Child
// Template: parent      <- імʼя ТЕКИ батьківської теми, не її назва

// functions.php дочірньої теми: він не перекриває батьківський, а додається до нього
add_action('wp_enqueue_scripts', function (): void {
    // get_template_directory_uri() = батьківська тема, get_stylesheet_* = активна (дочірня)
    wp_enqueue_style('parent-style', get_template_directory_uri().'/style.css');

    wp_enqueue_style(
        'child-style',
        get_stylesheet_uri(),
        ['parent-style'], // вантажимо після батьківського, щоб правила перебивали
        wp_get_theme()->get('Version')
    );
});

// Перекриття файлом: wp-content/themes/parent-child/single-product.php
// Достатньо однакової назви — locate_template() перевіряє дочірню тему першою.

// Частини шаблону підключаємо так, щоб їх теж можна було перекрити з дочірньої теми
get_template_part('parts/product', 'card'); // шукає parts/product-card.php, потім parts/product.php
// а НЕ: include get_template_directory().'/parts/product-card.php'; — це жорстко батьківська тема

// Додати власного кандидата перед стандартними, без правки ядра теми
add_filter('single_template_hierarchy', function (array $templates): array {
    if (has_term('sale', 'product_cat')) {
        array_unshift($templates, 'single-product-sale.php');
    }

    return $templates; // фільтр мусить повернути масив
});

// Діагностика: який саме файл виграв
add_filter('template_include', function (string $template): string {
    error_log('template: '.$template); // спрацьовує останнім, після всіх *_template

    return $template;
}, PHP_INT_MAX);
Що ієрархія — це не магія, а список кандидатів: get_query_template() віддає масив імен від конкретного до загального, а locate_template() бере перший наявний файл.
Що child theme виграє не через «пріоритет теми», а через порядок пошуку в locate_template(): STYLESHEETPATH (дочірня), потім TEMPLATEPATH (батьківська), потім theme-compat.
Що functions.php — виняток: він не перекривається, а завантажується додатково, причому дочірній раніше за батьківський.
Що index.php обовʼязковий і є останнім кандидатом для будь-якого запиту, тому «білий екран» через ієрархію не буває — буває неправильний файл.
Що для front page і блогу правила різні: front-page.php перекриває обидва варіанти, а далі все залежить від налаштування «Головна сторінка відображає» — статична сторінка йде в page-ієрархію, стрічка постів у home.php.
Що перекрити вибір можна й без файлу: фільтрами {$type}_template_hierarchy (з WP 4.7), {$type}_template і template_include, який спрацьовує останнім.
Плутати page-{slug}.php і page-{id}.php з назвою заголовка: слаг береться з post_name, тому сторінка «Контакти» з URL /kontakty/ шукає page-kontakty.php, а не page-contacts.php.
Правити файли батьківської теми напряму й втрачати зміни при оновленні.
Копіювати в дочірню тему functions.php батьківської цілком — отримуєте фатальну помилку «Cannot redeclare function», бо обидва файли виконуються.
Підключати частини шаблону через include get_template_directory().'/parts/card.php' замість get_template_part('parts/card') — жорсткий шлях у батьківську тему вимикає перекриття з дочірньої.
Вважати, що category-5.php спрацює для дочірньої категорії: ієрархія бере слаг і ID саме поточного терміна, без успадкування від батьківського.
Забувати рядок Template: у style.css дочірньої теми або писати туди назву теми замість імені теки батьківської.
ПОРАДА

Скажіть, що будь-яку сторінку можна продіагностувати за пів хвилини: у дочірній тезі повісити на template_include функцію, яка логує обраний файл, або поставити Query Monitor — він показує і список кандидатів, і переможця. Це сильніше за перелік файлів напамʼять.

Сторінка питання →
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, а не з встановлення кеш-плагіна навмання. І назвіть, який саме запит зазвичай виявляється винним.

Сторінка питання →
WP
WordPress·Senior ·headless ·REST API ·WPGraphQL

Headless виправданий, коли контент споживає більше ніж один клієнт або команда вже живе в JS-екосистемі; платити за це доводиться прев'ю, редиректами, формами, CSS блоків, меню, SEO-мета й власною інвалідацією кешу — усім, що раніше безкоштовно робила тема.

Замовник хоче Next.js поверх WordPress — які аргументи ви наведете за і проти?
Чому після переїзду на headless редактори скаржаться, що прев'ю не працює?
Ми зробили headless, а сторінки виглядають зламано — чому CSS блоків не застосувався?
Хто інвалідує кеш фронтенду, коли редактор змінює пост?

Headless WordPress означає, що тема більше не рендерить сторінки: WP лишається редакторським інтерфейсом і сховищем, а HTML будує окремий застосунок, який тягне дані через REST (ядро, з WP 4.7) або WPGraphQL. Ключове рішення тут не технічне, а продуктове — скільки різних клієнтів споживає той самий контент. Якщо клієнт один і це сайт, ви обмінюєте готовий рендер на власний і майже нічого не отримуєте. Якщо клієнтів кілька (веб, мобільний застосунок, екрани в залі, партнерська стрічка), або продукт — інтерактивний застосунок, де WP лише одне з джерел даних, або команда фронтенду вже живе в React і має дизайн-систему, — розділення окупається.

Аргумент «headless заради швидкості» майже завжди хибний. Повільність типового WP створюють запити в WP_Query, autoload-опції та десяток плагінів на кожен хіт, і все це нікуди не зникає — воно просто переїжджає з рендера сторінки в рендер JSON. Різниця в тому, що перед темою повносторінковий кеш ставиться однією настройкою, а перед API його треба проєктувати: REST-відповіді залежать від автентифікації, per_page обмежений сотнею, а без _fields кожен пост тягне за собою рендерений контент, мета й _links. Перш ніж пропонувати headless, чесніше показати профіль запитів і спробувати кеш на рівні edge.

Приховані витрати — це рівно те, що безкоштовно робила тема. Прев'ю чернеток вимагає автентифікованих запитів (Application Passwords з WP 5.6 або JWT) і роботи з wp/v2/posts/{id}/autosaves, бо незбережені зміни лежать саме там. CSS блоків генерується на сервері функціями wp_get_global_stylesheet() і wp_enqueue_block_style() (обидві з WP 5.9), а wp_head() на фронтенді ніхто не викличе. Меню через wp/v2/menus анонімно не читаються: ендпоінти вимагають edit_theme_options. Редиректи плагінів застосовуються на template_redirect, якого немає. Форми, шорткоди, srcset, related posts, канонічні URL і _wp_old_slug_redirect — той самий список. Кожен пункт треба або відтворити, або свідомо викинути.

Окремо живе інвалідація. У класичному WP кеш чистило саме ядро й плагін кешування; у headless це ваш код: transition_post_status або save_post → вебхук на фронтенд, який робить revalidatePath/revalidateTag (Next.js App Router, 13.4+). Пастка не в самому вебхуку, а в повноті: зміна поста впливає на його сторінку, на архіви, на головну, на сусідні пости в «схожих», іноді на меню. Тому інвалідують не URL, а теги, і запит роблять неблокуючим чи через wp_cron, інакше кожне збереження в редакторі затримується на час відповіді фронтенду.

Є ще операційна ціна: два деплої, два середовища, два набори логів і два домени. Бекенд лишається повноцінним сайтом, який віддає ті самі тексти за власними URL, тож його закривають від індексації (X-Robots-Tag: noindex, basic auth, обмеження за IP) — інакше отримаєте дублі контенту. Для SEO треба одне джерело правди: або фронтенд рахує мета-теги сам, або бере yoast_head_json з Yoast (з 14.x) і не сперечається з ним. І окремо — команда: підтримувати headless без постійного фронтенд-розробника не вийде, а більшість «просто контентних» сайтів такого розробника не мають. Проміжні варіанти теж існують: блочна тема з theme.json для контентної частини плюс окремий SPA-розділ для інтерактивного, — і для багатьох проєктів це чесніша відповідь, ніж повний headless.

// 1. Інвалідація фронтенду: тему знято, тож кеш чистить хук, а не WP
add_action('transition_post_status', function (string $new, string $old, WP_Post $post): void {
    if ($new !== 'publish' && $old !== 'publish') {
        return; // чернетка → чернетка фронтенду не стосується
    }
    if (wp_is_post_revision($post->ID) || $post->post_type === 'nav_menu_item') {
        return;
    }
    // HTTP-запит просто в хуку сповільнить збереження в редакторі — відкладаємо
    wp_schedule_single_event(time(), 'shop_revalidate', [get_permalink($post), $post->post_type]);
}, 10, 3);

add_action('shop_revalidate', function (string $url, string $type): void {
    wp_remote_post(SHOP_FRONT_URL.'/api/revalidate', [
        'timeout' => 5,
        'blocking' => false,
        'headers' => ['X-Revalidate-Secret' => SHOP_REVALIDATE_SECRET],
        // Окрім самої сторінки — архіви й головна, інакше список лишиться старим
        'body' => wp_json_encode([
            'path' => wp_parse_url($url, PHP_URL_PATH),
            'tags' => ['home', "archive:{$type}"],
        ]),
    ]);
}, 10, 2);

// 2. CSS блоків генерує сервер: без wp_head() фронтенд його не отримає
add_action('rest_api_init', function (): void {
    register_rest_route('shop/v1', '/block-styles', [
        'methods' => 'GET',
        'permission_callback' => '__return_true', // публічні стилі, не дані
        'callback' => static fn (): array => [
            // з WP 5.9: змінні й пресети з theme.json + стилі блоків
            'css' => wp_get_global_stylesheet(['variables', 'presets', 'styles']),
        ],
    ]);
});
Що headless — це рішення про кількість споживачів контенту, а не про швидкість: звичайний сайт майже завжди швидше вивести повносторінковим кешем перед PHP, ніж переписати фронтенд.
Що разом з темою зникає весь її функціонал: прев'ю чернеток, шорткоди, форми, related posts, srcset, меню, канонічні URL і `wp_head()` — кожен пункт треба відтворити руками.
Що прев'ю потребує автентифікації (Application Passwords з WP 5.6 або JWT) і читання `wp/v2/posts/{id}/autosaves`, бо незбережені зміни редактора живуть саме в автозбереженні, а не в пості.
Що CSS блоків генерує сервер: `wp_get_global_stylesheet()` і `wp_enqueue_block_style()` (обидва з WP 5.9) на фронтенді ніхто не викличе, тому стилі треба віддавати окремим ендпоінтом або збирати на боці JS.
Що інвалідацію кешу тепер пише розробник: хук `transition_post_status` → вебхук на фронтенд (`revalidatePath`/`revalidateTag` у Next.js App Router 13.4+), і що забути про батьківські сторінки, архіви та меню — типово.
Що бекенд лишається публічним сайтом і його треба закрити від індексації, інакше отримаєте дублі контенту на двох доменах.
Аргументувати headless продуктивністю, не спробувавши повносторінковий кеш і CDN: більшість «повільних» WP гальмують не через рендер теми.
Планувати бюджет на API-шар і забути про прев'ю та ролі редакторів — саме на цьому проєкти зупиняються після здачі.
Вважати, що `wp/v2/menus` віддасть меню анонімно: ендпоінти меню (з WP 5.9) вимагають капабіліті `edit_theme_options`, публічного читання там немає.
Тягнути `wp/v2/posts` без `_fields` і дивуватись мегабайтним відповідям, а потім упиратись у ліміт `per_page=100`.
Забувати, що `content.rendered` уже містить розгорнуті шорткоди й oEmbed, а `content.raw` — ні, і рендерити на фронтенді сирий HTML із `[gallery]` всередині.
Лишати два джерела правди для SEO: фронтенд генерує свої title/canonical, а Yoast віддає `yoast_head_json` — і вони розходяться.
ПОРАДА

Сформулюйте критерій одним реченням: headless окупається, коли споживачів контенту більше одного (сайт + мобільний застосунок + екрани в залі) або коли продукт — це застосунок, а WP у ньому лише CMS. Для контентного сайту з блогом чесніше сказати «ні» і показати, скільки коштує відтворити прев'ю, форми й меню.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти
ПІДТЕМИ
Хуки і фільтри Цикл запиту Custom post types REST API Продуктивність Безпека
НА ЧОМУ ВАЛЯТЬСЯ

Тут перевіряють не знання документації, а розуміння порядку виконання хуків і того, як не покласти сайт на 200 тисяч записів.