Цикл WordPress (The Loop) є синхронною реалізацією поведінкових патернів «Ітератор» (Iterator) та «Перемикання контексту» (Context Switching) у процедурно-орієнтованій архітектурі ядра. Його ключове завдання — послідовний обхід результуючого набору записів WP_Post, вилученого з MySQL об'єктом WP_Query, з одночасною синхронізацією глобального стану середовища виконання PHP.
Внутрішній стан ітератора WP_Query управляється трьома ключовими властивостями:
$posts: індексований масив об'єктів WP_Post, отриманих із бази даних.$post_count: загальна кількість записів у поточній вибірці сторінки (довжина масиву $posts).$current_post: внутрішній цілочисельний вказівник (курсор) поточної позиції, що ініціалізується значенням -1.На кожному кроці циклу відбувається атомарний перехід між методами:
have_posts(): виконує предикативну перевірку ($this->current_post + 1) < $this->post_count. При першому виклику також ініціалізує вказівник.the_post(): інкрементує вказівник $this->current_post++, витягує запис у властивість $this->post = $this->posts[$this->current_post] та делегує керування низькорівневій процедурі ядра setup_postdata($post).Процедура setup_postdata($post) виконує фундаментальну операцію: вона перезаписує глобальну змінну $post та розпаковує в глобальну область видимості пов'язані контекстні сутності:
$id: цілочисельний ID поточного запису.$authordata: об'єкт WP_User автора запису (для миттєвої роботи the_author()).$currentday та $currentmonth: метадані дати публікації.$pages: масив частин контенту, розбитих тегом <!--nextpage-->.$page: поточний номер підсторінки багатосторінкового матеріалу.$multipage: булевий прапор наявності пагінації всередині статті.$more: прапор відсікання анонсу за тегом <!--more-->.$numpages: загальна кількість сторінок розбивки.Завдяки цій мутації глобального контексту так звані теги шаблону (Template Tags — the_title(), the_content(), the_permalink(), get_the_ID()) можуть викликатися без передачі аргументів: вони неявно зчитують дані з підготовленого глобального скоупу.
У сучасних блокових темах (Full Site Editing / Gutenberg) класичний імперативний цикл заміщується декларативним блоком core/query (Query Loop Block). Замість мутації глобальних змінних PHP блоковий рушій використовує ієрархічний контекст блоків (Block Context API), передаючи postId та postType дочірнім компонентам (core/post-title, core/post-content) через ізольовані структури даних.
Роботу The Loop слід сприймати як кінцевий автомат зі стеком контекстів: курсор переміщується вибіркою даних, безперервно проєктуючи властивості поточного елемента в глобальний реєстр рантайму, а по завершенні вимагає обов'язкового відновлення початкового стану.
The Loop setup_postdata Query Loop Block wp_reset_postdata rewind_posts[Вибірка WP_Query: $posts = [Post_0, Post_1, Post_2]]
│
▼
have_posts()? (0 < 3) ──► ТАК
│
▼
the_post()
┌────────────────────────┴────────────────────────┐
▼ ▼
$current_post = 0 setup_postdata($post)
$post = $posts[0] ┌───────────────────┴───────────────────┐
▼ ▼
$authordata = WP_User $pages = [...]
$id = 42 $more = 1
│
▼
[Виконання тіла циклу]
the_title() ──► Читає $post->post_title
the_author() ──► Читає $authordata->display_name
│
▼
have_posts()? (1 < 3) ──► ТАК (Наступний крок)
│
▼
have_posts()? (3 < 3) ──► НІ (Кінець циклу)
│
▼
wp_reset_postdata()
│
▼
Відновлення $post = $wp_the_query->postWP_Query наповнює масив $posts. Вказівник current_post встановлюється в -1. Прапор in_the_loop активний.
have_posts() перевіряє (current_post + 1 < post_count). Якщо елементів немає, повертає false.
the_post() зсуває current_post++ і викликає setup_postdata(). Заповнення $post, $authordata, $pages, $more.
Виклик Template Tags (the_title, the_content) без параметрів. Автоматичне зчитування глобального скоупу поточного запису.
wp_reset_postdata() повертає глобальну змінну $post до вихідного запису головного запиту $wp_the_query.
yield) без переповнення оперативної пам'яті (Allowed memory size exhausted).WP_Post з подальшим прогоном через setup_postdata() для рендерингу стандартними шаблонами теми."inherit": true), автоматичною фільтрацією таксономій та нативною серверною пагінацією.have_posts() та the_post()#Вихідний код ядра в класі WP_Query наочно демонструє механіку:
public function have_posts(): bool {
if ($this->current_post + 1 < $this->post_count) {
return true;
} elseif ($this->current_post + 1 === $this->post_count && $this->post_count > 0) {
// Завершення циклу: тригеримо екшен loop_end
do_action_ref_array('loop_end', [&$this]);
$this->rewind_posts();
}
$this->in_the_loop = false;
return false;
}
public function the_post(): void {
$this->in_the_loop = true;
if ($this->current_post === -1) {
// Початок циклу: тригеримо екшен loop_start
do_action_ref_array('loop_start', [&$this]);
}
$this->current_post++;
$this->post = $this->posts[$this->current_post];
setup_postdata($this->post);
}wp_reset_postdata(), wp_reset_query() та rewind_posts()#wp_reset_postdata(): Відновлює глобальну змінну $post до поточного запису глобального об'єкта $wp_the_query. Використовується завжди після завершення кастомного циклу new WP_Query().wp_reset_query(): Робить виклик wp_reset_postdata(), а також відновлює глобальний вказівник $wp_query = $wp_the_query. Застосовується виключно в легасі-коді після query_posts(). У сучасному коді query_posts() суворо заборонено.rewind_posts(): Скидає $this->current_post = -1. Не звертається до MySQL! Дозволяє виконати повторний обхід тих самих записів в іншому місці шаблону.<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise Loop Iterator & Context Guard
* Description: Потоковий ітератор на генераторах PHP 8.4+ із захистом глобального контексту $post та Keyset-чанкінгом.
* Version: 2.1.0
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\Core\Loop;
use Generator;
use WP_Post;
use WP_Query;
if (!defined('ABSPATH')) {
exit;
}
final class EnterpriseLoop
{
/**
* Потоковий генератор для обходу кастомного WP_Query.
* Автоматично виконує setup_postdata() на кожному кроці
* і гарантовано скидає контекст у блоці finally навіть при виникненні винятків.
*
* @param array<string, mixed> $queryArgs
* @return Generator<int, WP_Post>
*/
public static function iterate(array $queryArgs): Generator
{
$query = new WP_Query($queryArgs);
try {
while ($query->have_posts()) {
$query->the_post();
/** @var WP_Post $post */
global $post;
yield $query->current_post => $post;
}
} finally {
// Гарантоване відновлення глобального контексту (RAII-патерн)
wp_reset_postdata();
}
}
/**
* Потокова пакетна обробка десятків тисяч записів (Keyset Chunking).
* Запобігає переповненню пам'яті PHP (Out of Memory) при важких міграціях та експорті.
*
* @param string $postType
* @param int $chunkSize
* @return Generator<int, WP_Post>
*/
public static function cursor(string $postType, int $chunkSize = 100): Generator
{
global $wpdb;
$lastId = 0;
while (true) {
$keysetWhereFilter = static function (string $where) use ($wpdb, $lastId): string {
return $where . $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", $lastId);
};
add_filter('posts_where', $keysetWhereFilter);
try {
/** @var list<WP_Post> $posts */
$posts = get_posts([
'post_type' => $postType,
'post_status' => 'publish',
'posts_per_page' => $chunkSize,
'orderby' => 'ID',
'order' => 'ASC',
'no_found_rows' => true, // Вимикаємо розрахунок SQL_CALC_FOUND_ROWS
'update_post_term_cache' => false,
'update_post_meta_cache' => false,
'suppress_filters' => false,
]);
} finally {
remove_filter('posts_where', $keysetWhereFilter);
}
if (empty($posts)) {
break;
}
foreach ($posts as $singlePost) {
$lastId = $singlePost->ID;
// Вручну ініціалізуємо контекст для тегів шаблону
setup_postdata($GLOBALS['post'] = $singlePost);
try {
yield $singlePost->ID => $singlePost;
} finally {
// Безпечне очищення
}
}
// Очищення внутрішнього об'єктного кешу для запобігання витоку RAM
wp_cache_flush_group('posts');
if (count($posts) < $chunkSize) {
break;
}
}
wp_reset_postdata();
}
}У чому суть помилки: Розробник створює кастомний цикл через new WP_Query() (наприклад, блок схожих записів або слайдер) і не викликає wp_reset_postdata() після закриваючої фігурної дужки while.
Чому розробники так роблять: Зовні верстка блоку схожих записів відображається коректно, помилка проявляється далі по коду сторінки.
Що відбувається в продакшені: Глобальна змінна $post залишається вказувати на останній запис із допоміжного циклу. Усі наступні виклики тегів шаблону в сайдбарі, коментарях та підвалі (the_author(), get_the_ID(), віджети, форма коментарів) починають рендерити дані цього чужого запису. Ламаються хлібні крихти, умовний тег is_single() повертає помилкові результати, а Schema.org розмітка генерує невірні метадані для SEO.
Як правильно: Завжди закривати кастомний цикл викликом wp_reset_postdata():
$related = new WP_Query(['post_type' => 'post', 'posts_per_page' => 3]);
if ($related->have_posts()) {
while ($related->have_posts()) {
$related->the_post();
the_title('<h4>', '</h4>');
}
wp_reset_postdata(); // Обов'язковий виклик!
}У чому суть помилки: Зміна властивостей глобального об'єкта напряму всередині тіла циклу: $GLOBALS['post']->post_title = 'Новий заголовок';.
Чому розробники так роблять: Спроба швидко змінити дані, що виводяться, без вивчення системи фільтрів ядра.
Що відбувається в продакшені: Порушується інваріант імутабельності даних у пам'яті. Плагіни кешування, SEO-модулі та сторонні хуки отримують пошкоджені дані. Якщо цей об'єкт $post закешований у Redis/Memcached, пошкоджений рядок може зберегтися у Persistent Object Cache, заражаючи наступні запити інших користувачів.
Як правильно: Застосовувати контекстні фільтри WordPress (the_title, the_content):
// ПОМИЛКА:
while (have_posts()) {
the_post();
$post->post_title = strtoupper($post->post_title); // Мутація глобального стану!
}
// ПРАВИЛЬНО:
add_filter('the_title', function(string $title, int $id): string {
if (in_the_loop() && is_main_query()) {
return mb_strtoupper($title);
}
return $title;
}, 10, 2);У чому суть помилки: Використання виклику query_posts($args) на початку шаблону архіву або категорії для зміни кількості постів, що виводяться.
Чому розробники так роблять: Найстаріший процедурний метод кастомізації виводу.
Що відбувається в продакшені: Функція query_posts() знищує оригінальний глобальний об'єкт $wp_query та виконує повторний важкий SQL-запит до MySQL. На кожен перегляд сторінки сервер робить подвійну роботу, подвоюючи TTFB. Канонічна пагінація сторінок (/page/2/) остаточно ламається, видаючи 404 Not Found.
Як правильно: Модифікувати SQL-запит до звернення до бази даних через хук pre_get_posts:
add_action('pre_get_posts', function(\WP_Query $query): void {
if (!is_admin() && $query->is_main_query() && $query->is_category('news')) {
$query->set('posts_per_page', 24);
$query->set('orderby', 'date');
$query->set('order', 'DESC');
}
});У чому суть помилки: Виконання кастомного циклу з параметром 'posts_per_page' => -1 або 'numberposts' => -1 для виведення каталогу або генерації карти сайту.
Чому розробники так роблять: Здається зручним способом гарантовано вивести всі наявні записи.
Що відбувається в продакшені: У міру зростання проєкту з сотень записів до десятків тисяч, MySQL виконує нелімітовану вибірку, а PHP намагається алокувати в пам'яті масив із тисяч важких об'єктів WP_Post. Настає неминучий Fatal error: Allowed memory size exhausted, що призводить до падіння процесу PHP-FPM.
Як правильно: Завжди використовувати пагінацію або потокову обробку чанками через Keyset Cursor (див. наш EnterpriseLoop::cursor).
Метод опитує умову ($this->current_post + 1 < $this->post_count). Якщо курсор дійшов до кінця вибірки, викликається rewind_posts() і повертається false.
При наявності записів та першому вході в цикл спрацьовує хук loop_start.
Вказівник зсувається на наступний цілочисельний індекс. У властивість $wp_query->post поміщається об'єкт WP_Post з масиву $posts.
Прапор in_the_loop переводиться в стан true.
Процедура ядра пов'язує глобальні змінні $id, $authordata, $currentday, $pages, $page, $more, $numpages з полями поточного запису.
Об'єкт автора гідрується через get_userdata($post->post_author) для доступу тегів автора.
Функції the_title(), the_content(), the_permalink() зчитують підготовлений глобальний стан без аргументів.
Фільтри the_title та the_content виконуються контекстно для кожної ітерації.
Після завершення циклу глобальний вказівник $post повертається до запису головного запиту сторінки $wp_the_query->post.
Виключає побічні ефекти та спотворення даних у сайдбарах, футері та мета-тегах.