Пользовательские страницы с помощью плагина
Я разрабатываю плагин, в котором хотел бы включить пользовательские страницы. В моем случае такая страница должна содержать форму наподобие контактной формы (не буквально). Когда пользователь заполнит эту форму и отправит её, должен быть следующий шаг, требующий дополнительной информации. Допустим, первая страница с формой будет находиться по адресу www.domain.tld/custom-page/
, а после успешной отправки формы пользователь должен быть перенаправлен на www.domain.tld/custom-page/second
. Шаблон с HTML элементами и PHP кодом также должен быть пользовательским.
Я думаю, что часть проблемы можно решить с помощью пользовательских правил перезаписи URL, но остальные части мне пока неизвестны. Я действительно не знаю, с чего начать поиск и как правильно называется эта задача. Буду благодарен за любую помощь.

При посещении фронтенд-страницы WordPress выполняет запрос к базе данных, и если вашей страницы там нет, этот запрос становится бесполезной тратой ресурсов.
К счастью, WordPress предоставляет способ обработки фронтенд-запросов по-своему. Это возможно благодаря фильтру 'do_parse_request'
.
Возвращая false
в этом хуке, вы можете остановить стандартную обработку запросов WordPress и реализовать собственный механизм.
В этой статье я хочу поделиться способом создания простого ООП-плагина для работы с виртуальными страницами, который будет удобен в использовании и повторном применении.
Что нам понадобится
- Класс для объектов виртуальных страниц
- Класс-контроллер, который будет анализировать запрос и, если он предназначен для виртуальной страницы, отображать её с использованием соответствующего шаблона
- Класс для загрузки шаблонов
- Основные файлы плагина для добавления хуков, которые обеспечат работоспособность всей системы
Интерфейсы
Прежде чем создавать классы, напишем интерфейсы для трёх перечисленных выше объектов.
Сначала интерфейс страницы (файл PageInterface.php
):
<?php
namespace GM\VirtualPages;
interface PageInterface {
function getUrl();
function getTemplate();
function getTitle();
function setTitle( $title );
function setContent( $content );
function setTemplate( $template );
/**
* Получить объект WP_Post, созданный на основе виртуальной страницы
*
* @return \WP_Post
*/
function asWpPost();
}
Большинство методов — это обычные геттеры и сеттеры, не требующие пояснений. Последний метод должен использоваться для получения объекта WP_Post
из виртуальной страницы.
Интерфейс контроллера (файл ControllerInterface.php
):
<?php
namespace GM\VirtualPages;
interface ControllerInterface {
/**
* Инициализация контроллера, запускает хук, позволяющий добавлять страницы
*/
function init();
/**
* Регистрация объекта страницы в контроллере
*
* @param \GM\VirtualPages\Page $page
* @return \GM\VirtualPages\Page
*/
function addPage( PageInterface $page );
/**
* Запускается на хуке 'do_parse_request'; если запрос предназначен для одной из зарегистрированных страниц,
* настраивает глобальные переменные, запускает основные хуки, загружает шаблон страницы и завершает выполнение.
*
* @param boolean $bool Флаг, переданный хуком 'do_parse_request'
* @param \WP $wp Глобальный объект WP, переданный хуком 'do_parse_request'
*/
function dispatch( $bool, \WP $wp );
}
И интерфейс загрузчика шаблонов (файл TemplateLoaderInterface.php
):
<?php
namespace GM\VirtualPages;
interface TemplateLoaderInterface {
/**
* Настройка загрузчика для объекта страницы
*
* @param \GM\VirtualPagesPageInterface $page соответствующая виртуальная страница
*/
public function init( PageInterface $page );
/**
* Активирует стандартные и пользовательские хуки для фильтрации шаблонов,
* затем загружает найденный шаблон.
*/
public function load();
}
Комментарии phpDoc должны быть достаточно понятными для этих интерфейсов.
План работы
Теперь, когда у нас есть интерфейсы, и прежде чем писать конкретные классы, давайте рассмотрим наш рабочий процесс:
- Сначала мы создаём экземпляр класса
Controller
(реализующегоControllerInterface
) и внедряем (вероятно, через конструктор) экземпляр классаTemplateLoader
(реализующегоTemplateLoaderInterface
) - На хуке
init
мы вызываем методControllerInterface::init()
для настройки контроллера и запуска хука, который будет использоваться для добавления виртуальных страниц. - На хуке 'do_parse_request' мы вызовем
ControllerInterface::dispatch()
, где проверим все добавленные виртуальные страницы и, если URL одной из них совпадает с текущим запросом, отобразим её, предварительно установив все необходимые глобальные переменные ($wp_query
,$post
). Мы также используем классTemplateLoader
для загрузки нужного шаблона.
В процессе мы будем активировать некоторые стандартные хуки, такие как wp
, template_redirect
, template_include
... чтобы сделать плагин более гибким и обеспечить совместимость с ядром и другими плагинами, или по крайней мере с их значительной частью.
Помимо этого рабочего процесса, нам также потребуется:
- Очистка хуков и глобальных переменных после выполнения основного цикла для улучшения совместимости с ядром и сторонним кодом
- Добавление фильтра на
the_permalink
, чтобы он возвращал правильный URL виртуальной страницы, когда это необходимо.
Конкретные классы
Теперь мы можем написать наши конкретные классы. Начнём с класса страницы (файл Page.php
):
<?php
namespace GM\VirtualPages;
class Page implements PageInterface {
private $url;
private $title;
private $content;
private $template;
private $wp_post;
function __construct( $url, $title = 'Untitled', $template = 'page.php' ) {
$this->url = filter_var( $url, FILTER_SANITIZE_URL );
$this->setTitle( $title );
$this->setTemplate( $template);
}
function getUrl() {
return $this->url;
}
function getTemplate() {
return $this->template;
}
function getTitle() {
return $this->title;
}
function setTitle( $title ) {
$this->title = filter_var( $title, FILTER_SANITIZE_STRING );
return $this;
}
function setContent( $content ) {
$this->content = $content;
return $this;
}
function setTemplate( $template ) {
$this->template = $template;
return $this;
}
function asWpPost() {
if ( is_null( $this->wp_post ) ) {
$post = array(
'ID' => 0,
'post_title' => $this->title,
'post_name' => sanitize_title( $this->title ),
'post_content' => $this->content ? : '',
'post_excerpt' => '',
'post_parent' => 0,
'menu_order' => 0,
'post_type' => 'page',
'post_status' => 'publish',
'comment_status' => 'closed',
'ping_status' => 'closed',
'comment_count' => 0,
'post_password' => '',
'to_ping' => '',
'pinged' => '',
'guid' => home_url( $this->getUrl() ),
'post_date' => current_time( 'mysql' ),
'post_date_gmt' => current_time( 'mysql', 1 ),
'post_author' => is_user_logged_in() ? get_current_user_id() : 0,
'is_virtual' => TRUE,
'filter' => 'raw'
);
$this->wp_post = new \WP_Post( (object) $post );
}
return $this->wp_post;
}
}
Здесь просто реализован интерфейс, ничего более.
Теперь класс контроллера (файл Controller.php
):
<?php
namespace GM\VirtualPages;
class Controller implements ControllerInterface {
private $pages;
private $loader;
private $matched;
function __construct( TemplateLoaderInterface $loader ) {
$this->pages = new \SplObjectStorage;
$this->loader = $loader;
}
function init() {
do_action( 'gm_virtual_pages', $this );
}
function addPage( PageInterface $page ) {
$this->pages->attach( $page );
return $page;
}
function dispatch( $bool, \WP $wp ) {
if ( $this->checkRequest() && $this->matched instanceof Page ) {
$this->loader->init( $this->matched );
$wp->virtual_page = $this->matched;
do_action( 'parse_request', $wp );
$this->setupQuery();
do_action( 'wp', $wp );
$this->loader->load();
$this->handleExit();
}
return $bool;
}
private function checkRequest() {
$this->pages->rewind();
$path = trim( $this->getPathInfo(), '/' );
while( $this->pages->valid() ) {
if ( trim( $this->pages->current()->getUrl(), '/' ) === $path ) {
$this->matched = $this->pages->current();
return TRUE;
}
$this->pages->next();
}
}
private function getPathInfo() {
$home_path = parse_url( home_url(), PHP_URL_PATH );
return preg_replace( "#^/?{$home_path}/#", '/', esc_url( add_query_arg(array()) ) );
}
private function setupQuery() {
global $wp_query;
$wp_query->init();
$wp_query->is_page = TRUE;
$wp_query->is_singular = TRUE;
$wp_query->is_home = FALSE;
$wp_query->found_posts = 1;
$wp_query->post_count = 1;
$wp_query->max_num_pages = 1;
$posts = (array) apply_filters(
'the_posts', array( $this->matched->asWpPost() ), $wp_query
);
$post = $posts[0];
$wp_query->posts = $posts;
$wp_query->post = $post;
$wp_query->queried_object = $post;
$GLOBALS['post'] = $post;
$wp_query->virtual_page = $post instanceof \WP_Post && isset( $post->is_virtual )
? $this->matched
: NULL;
}
public function handleExit() {
exit();
}
}
По сути, класс создаёт объект SplObjectStorage
, в котором хранятся все добавленные объекты страниц.
На хуке 'do_parse_request'
класс контроллера перебирает это хранилище, чтобы найти совпадение текущего URL с одним из добавленных URL страниц.
Если совпадение найдено, класс делает именно то, что мы запланировали: активирует некоторые хуки, настраивает переменные и загружает шаблон через класс, реализующий TemplateLoaderInterface
.
После этого просто вызывает exit()
.
Теперь напишем последний класс:
<?php
namespace GM\VirtualPages;
class TemplateLoader implements TemplateLoaderInterface {
public function init( PageInterface $page ) {
$this->templates = wp_parse_args(
array( 'page.php', 'index.php' ), (array) $page->getTemplate()
);
}
public function load() {
do_action( 'template_redirect' );
$template = locate_template( array_filter( $this->templates ) );
$filtered = apply_filters( 'template_include',
apply_filters( 'virtual_page_template', $template )
);
if ( empty( $filtered ) || file_exists( $filtered ) ) {
$template = $filtered;
}
if ( ! empty( $template ) && file_exists( $template ) ) {
require_once $template;
}
}
}
Шаблоны, указанные для виртуальной страницы, объединяются в массив со стандартными page.php
и index.php
, перед загрузкой шаблона активируется хук 'template_redirect'
для добавления гибкости и улучшения совместимости.
После этого найденный шаблон проходит через пользовательский фильтр 'virtual_page_template'
и стандартный 'template_include'
: опять же для гибкости и совместимости.
Наконец, файл шаблона просто загружается.
Основной файл плагина
Теперь нам нужно написать файл с заголовками плагина и использовать его для добавления хуков, которые обеспечат работу нашего процесса:
<?php namespace GM\VirtualPages;
/*
Plugin Name: GM Virtual Pages
*/
require_once 'PageInterface.php';
require_once 'ControllerInterface.php';
require_once 'TemplateLoaderInterface.php';
require_once 'Page.php';
require_once 'Controller.php';
require_once 'TemplateLoader.php';
$controller = new Controller ( new TemplateLoader );
add_action( 'init', array( $controller, 'init' ) );
add_filter( 'do_parse_request', array( $controller, 'dispatch' ), PHP_INT_MAX, 2 );
add_action( 'loop_end', function( \WP_Query $query ) {
if ( isset( $query->virtual_page ) && ! empty( $query->virtual_page ) ) {
$query->virtual_page = NULL;
}
} );
add_filter( 'the_permalink', function( $plink ) {
global $post, $wp_query;
if (
$wp_query->is_page && isset( $wp_query->virtual_page )
&& $wp_query->virtual_page instanceof Page
&& isset( $post->is_virtual ) && $post->is_virtual
) {
$plink = home_url( $wp_query->virtual_page->getUrl() );
}
return $plink;
} );
В реальном файле мы, вероятно, добавим больше заголовков, таких как ссылки на плагин и автора, описание, лицензию и т.д.
Gist плагина
Итак, мы закончили работу над нашим плагином. Весь код можно найти в Gist здесь.
Добавление страниц
Плагин готов и работает, но мы ещё не добавили ни одной страницы.
Это можно сделать внутри самого плагина, в файле темы functions.php
, в другом плагине и т.д.
Добавление страниц — это просто:
<?php
add_action( 'gm_virtual_pages', function( $controller ) {
// первая страница
$controller->addPage( new \GM\VirtualPages\Page( '/custom/page' ) )
->setTitle( 'Моя первая пользовательская страница' )
->setTemplate( 'custom-page-form.php' );
// вторая страница
$controller->addPage( new \GM\VirtualPages\Page( '/custom/page/deep' ) )
->setTitle( 'Моя вторая пользовательская страница' )
->setTemplate( 'custom-page-deep.php' );
} );
И так далее. Вы можете добавить все необходимые страницы, просто помните, что нужно использовать относительные URL для страниц.
Внутри файла шаблона вы можете использовать все стандартные теги WordPress, а также писать любой PHP и HTML код.
Глобальный объект post заполняется данными из нашей виртуальной страницы. Саму виртуальную страницу можно получить через переменную $wp_query->virtual_page
.
Получить URL виртуальной страницы так же просто, как передать в home_url()
тот же путь, который использовался при создании страницы:
$custom_page_url = home_url( '/custom/page' );
Обратите внимание, что в основном цикле загруженного шаблона the_permalink()
будет возвращать корректную постоянную ссылку на виртуальную страницу.
Примечания о стилях и скриптах для виртуальных страниц
Вероятно, при добавлении виртуальных страниц также потребуется зарегистрировать пользовательские стили/скрипты и затем просто использовать wp_head()
в пользовательских шаблонах.
Это очень просто, так как виртуальные страницы легко распознаются по переменной $wp_query->virtual_page
, а отдельные виртуальные страницы можно различать по их URL.
Просто пример:
add_action( 'wp_enqueue_scripts', function() {
global $wp_query;
if (
is_page()
&& isset( $wp_query->virtual_page )
&& $wp_query->virtual_page instanceof \GM\VirtualPages\PageInterface
) {
$url = $wp_query->virtual_page->getUrl();
switch ( $url ) {
case '/custom/page' :
wp_enqueue_script( 'a_script', $a_script_url );
wp_enqueue_style( 'a_style', $a_style_url );
break;
case '/custom/page/deep' :
wp_enqueue_script( 'another_script', $another_script_url );
wp_enqueue_style( 'another_style', $another_style_url );
break;
}
}
} );
Примечания для автора вопроса
Передача данных со страницы на страницу не связана напрямую с этими виртуальными страницами, это общая задача.
Однако, если у вас есть форма на первой странице и вы хотите передать данные с неё на вторую страницу, просто используйте URL второй страницы в свойстве action
формы.
Например, в шаблоне первой страницы вы можете:
<form action="<?php echo home_url( '/custom/page/deep' ); ?>" method="POST">
<input type="text" name="testme">
</form>
А затем в шаблоне второй страницы:
<?php $testme = filter_input( INPUT_POST, 'testme', FILTER_SANITIZE_STRING ); ?>
<h1>Значение Test-Me с другой страницы: <?php echo $testme; ?></h1>

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

Очень изящное и простое решение. Проголосовал, расшарил в твиттере.

Код в Controller немного неверен... checkRequest() получает информацию о пути из home_url(), который возвращает localhost/wordpress. После preg_replace и add_query_arg этот URL становится /wordpress/virtual-page. А после trim в checkRequest этот URL превращается в wordpress/virtual. Это бы работало, если бы wordpress был установлен в корневой папке домена. Не могли бы вы предоставить исправление для этой проблемы, потому что я не могу найти подходящую функцию, которая возвращала бы правильный URL. Спасибо за все! (Приму ответ, когда он станет идеальным :)

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

@G.M.: В моем случае WordPress установлен в .../htdocs/wordpress/, и сайт доступен по http://localhost/wordpress/. home_url() возвращает http://localhost/wordpress, а add_query_arg(array()) возвращает /wordpress/virtual-page/. Когда мы сравниваем $path и обрезанный $this->pages->current()->getUrl() в checkRequest(), возникает проблема, потому что $path равен wordpress/virtual-page
, а обрезанный URL страницы равен virtual-page
.

Невероятно, но как, черт возьми, я написал ' вместо " в preg_replace. Ты прав, это была моя ошибка, прости за это. После решения первой проблемы я столкнулся с другой: Undefined property: WP_Query::$queried_object_id
. Вероятно, она связана с Page.php, где мы устанавливаем атрибуты поста, или с setupQuery в Controller'е. У меня есть еще один вопрос по поводу 'post_author' в Page.php, где используется функция is_user_logged_in(). У меня сайт, где любой может зарегистрироваться и войти. Может ли это представлять угрозу безопасности? Обязательно ли использовать функцию is_user_logged_in() или можно установить значение по умолчанию 0?

Функция is_user_logged_in()
не является проблемой безопасности: вы не предоставляете никаких возможностей авторизованным пользователям, и страница даже не существует в базе данных. Однако, установка значения всегда в 0 не вызовет проблем. Что касается ошибки, я много раз тестировал код в разных окружениях (Windows, Linux, PHP 5.4, 5.5)... с включенным WP debug и отладочными плагинами. Никаких проблем не было. Так что, либо вы изменили что-то в коде, либо проблема связана с конфликтом плагина/темы. В обоих случаях я не могу решить эту проблему и надеюсь, вы поймете, что я уже потратил слишком много времени на этот ответ. @user1257255

Я понимаю. Огромное спасибо за такой развернутый ответ и за все остальное! :)

Привет @gmazzap, если я передаю параметр запроса, маршрут с addPage не работает

Более простое решение — автоматически добавлять страницу, если она не существует. Мы можем использовать пользовательское мета-поле для проверки её наличия. Мы можем добавить обработчик в фильтр the_content
, если текущий ID совпадает с ID страницы.
$this->id = $this->settings['id'];
add_action('init', function (){
$page = get_posts([
'meta_key' => 'my_frontend_id',
'meta_value' => $this->id,
'post_type' => 'page',
'post_status' => 'any',
'numberposts' => 1
])[0];
if (is_null($page)){
$pageId = wp_insert_post($this->settings);
add_post_meta($pageId, 'my_frontend_id', $this->id, true);
}
else
$pageId = $page->ID;
add_filter('the_content', function ($content) use ($pageId){
if (is_singular() && in_the_loop() && is_main_query() && get_the_ID() === $pageId){
ob_start();
$this->render();
return ob_get_clean();
}
return $content;
}, 1);
});
Однако здесь есть нюанс. Например, защита паролем не работает с таким подходом. Требуются дополнительные усилия, чтобы правильно защитить эти страницы от несанкционированного чтения или записи. В итоге я предпочёл использовать шорткоды вместо этого фильтра, хотя могу представить сценарии, где он может быть полезен.

Я однажды использовал решение, описанное здесь: http://scott.sherrillmix.com/blog/blogger/creating-a-better-fake-post-with-a-wordpress-plugin/
Фактически, когда я его использовал, я расширил решение так, чтобы можно было зарегистрировать более одной страницы за раз (остальной код примерно похож на решение, на которое я ссылаюсь в предыдущем абзаце).
Однако для работы этого решения необходимо, чтобы были разрешены красивые постоянные ссылки...
<?php
class FakePages {
public function __construct() {
add_filter( 'the_posts', array( $this, 'fake_pages' ) );
}
/**
* Внутренне регистрирует страницы, которые мы хотим подделать. Ключ массива — это ярлык, по которому страница будет доступна во фронтенде
* @return mixed
*/
private static function get_fake_pages() {
//http://example.com/fakepage1
$fake_pages['fakepage1'] = array(
'title' => 'Фейковая страница 1',
'content' => 'Это содержимое фейковой страницы 1'
);
//http://example.com/fakepage2
$fake_pages['fakepage2'] = array(
'title' => 'Фейковая страница 2',
'content' => 'Это содержимое фейковой страницы 2'
);
return $fake_pages;
}
/**
* Подделывает результат выборки записей
*
* @param $posts
*
* @return array|null
*/
public function fake_pages( $posts ) {
global $wp, $wp_query;
$fake_pages = self::get_fake_pages();
$fake_pages_slugs = array();
foreach ( $fake_pages as $slug => $fp ) {
$fake_pages_slugs[] = $slug;
}
if ( true === in_array( strtolower( $wp->request ), $fake_pages_slugs )
|| ( true === isset( $wp->query_vars['page_id'] )
&& true === in_array( strtolower( $wp->query_vars['page_id'] ), $fake_pages_slugs )
)
) {
if ( true === in_array( strtolower( $wp->request ), $fake_pages_slugs ) ) {
$fake_page = strtolower( $wp->request );
} else {
$fake_page = strtolower( $wp->query_vars['page_id'] );
}
$posts = null;
$posts[] = self::create_fake_page( $fake_page, $fake_pages[ $fake_page ] );
$wp_query->is_page = true;
$wp_query->is_singular = true;
$wp_query->is_home = false;
$wp_query->is_archive = false;
$wp_query->is_category = false;
$wp_query->is_fake_page = true;
$wp_query->fake_page = $wp->request;
//Более длинные структуры постоянных ссылок могут не совпадать с ярлыком фейковой записи и вызывать ошибку 404, поэтому мы перехватываем ошибку здесь
unset( $wp_query->query["error"] );
$wp_query->query_vars["error"] = "";
$wp_query->is_404 = false;
}
return $posts;
}
/**
* Создает виртуальную фейковую страницу
*
* @param $pagename
* @param $page
*
* @return stdClass
*/
private static function create_fake_page( $pagename, $page ) {
$post = new stdClass;
$post->post_author = 1;
$post->post_name = $pagename;
$post->guid = get_bloginfo( 'wpurl' ) . '/' . $pagename;
$post->post_title = $page['title'];
$post->post_content = $page['content'];
$post->ID = - 1;
$post->post_status = 'static';
$post->comment_status = 'closed';
$post->ping_status = 'closed';
$post->comment_count = 0;
$post->post_date = current_time( 'mysql' );
$post->post_date_gmt = current_time( 'mysql', 1 );
return $post;
}
}
new FakePages();
