OOP: WP plugin development
Glossary overview

OOP: WP plugin development

Мы будем идти от плохого архитектурного решения к адекватному.

Основные принципы которые надо соблюдать

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

Первоначальный вариант – все происходит в одном файле

if (! defined( 'ABSPATH') ) {
    exit;
}

class Sprm_Wp_Blocks {

    public string $plugin_directory;

    private string $acf_json_path = 'config/acf-json';

    function __construct () {
        $this->plugin_directory = plugin_dir_path(__FILE__);
    }

    public function init(): void {
        add_action('plugins_loaded', [$this, 'setAcfJsonPath']);
        add_action('acf/init', [$this, 'registerAcfBlockTypes']);
    }

    public function setAcfJsonPath(): void {
       ...
    }

    /**
     * Registers all custom ACF block types for the Gutenberg editor.
     *
     * Each block has its own render callback, which means styles are only
     * enqueued when the block actually appears on the page — not globally.
     * WordPress calls the render_callback only for blocks found in the post content,
     * so unused blocks never load their assets.
     *
     * As result, we get enqueued stylesheet only for this block:
     * <link rel='stylesheet' id='sprm-block-statistics-css' href='http://pt-bunny.test/wp-content/plugins/sprm-blocks/templates/blocks/statistics-block/style.css?ver=1771520831' media='all' />
     *
     * @return void
     */
    function registerAcfBlockTypes(): void {

        acf_register_block_type([
            'name'            => 'cta-block',
            'title'           => 'CTA Block',
            'description'     => 'A block for CTA',
            'render_callback' => array($this, 'renderCtaBlock'),
            'icon'            => 'list-view',
            'keywords'        => 'cta'
        ]);

        acf_register_block_type([
            'name'            => 'statistics-block',
            'title'           => 'Statistics Block',
            'description'     => 'A block for displaying statistics block',
            'render_callback' => array($this, 'renderStatisticsBlock'),
            'icon'            => 'list-view',
            'keywords'        => 'statistics'
        ]);
    }

    public function renderStatisticsBlock($block, $content = '', $is_preview = false, $post_id = 0): void
    {
        $handle  = 'sprm-block-statistics';
        $cssFile = $this->styleFile('statistics-block');
        $cssUrl  = $this->styleUrl('statistics-block');

        // Enqueue the block's stylesheet only once, even if the block appears multiple times.
        if (file_exists($cssFile) && !wp_style_is($handle, 'enqueued')) {
            wp_enqueue_style($handle, $cssUrl, [], filemtime($cssFile));
        }

        $template = $this->templatePath('statistics-block');
        if (file_exists($template)) {
            include $template;
        }
    }

    public function renderCtaBlock($block, $content = '', $is_preview = false, $post_id = 0): void
    {
        $handle  = 'sprm-block-cta';
        $cssFile = $this->styleFile('cta-block');
        $cssUrl  = $this->styleUrl('cta-block');

        // Enqueue the block's stylesheet only once, even if the block appears multiple times.
        if (file_exists($cssFile) && !wp_style_is($handle, 'enqueued')) {
            wp_enqueue_style($handle, $cssUrl, [], filemtime($cssFile));
        }

        $template = $this->templatePath('cta-block');
        if (file_exists($template)) {
            include $template;
        }
    }
}

$instance = new Sprm_Wp_Blocks();

$instance->init();

Проблемы

  • Мы видим дублирование кода. Для каждого блока выполняется одинаковая логика:
function acf_register_block_type();

function renderStatisticsBlock() / function renderCtaBlock()

Решение

  • Сделать абстрактный класс Block.
  • От него будет наследоваться каждый блок чтобы не дублировать код.
  • Благодаря статическому связываннию (static::) в методы абстрактного класса будут подставляться значения с наследников.
<?php

namespace Plt\SprmBlocks;

abstract class Block
{
    public static string $blockName;
    public static string $blockSlug;
    public static string $blockTitle;

    // init for each block
    public static function registerHooks(): void
    {
        // static::class - returns full name of child Class ("Plt\SprmBlocks\blocks\Statistics")
        add_action('acf/init', [static::class, 'registerAcfBlockTypes']);
    }

    public static function registerAcfBlockTypes(): void {

        acf_register_block_type([
            'name'            => static::$blockName,
            'title'           => static::$blockTitle,
            'render_callback' => [static::class, 'renderBlock'],
            'icon'            => 'list-view',
            'keywords'        => static::$blockTitle
        ]);

    }

    abstract static function renderBlock($block, $content, $is_preview, $post_id): void;

}

И теперь наследуемся от абстрактного блока:

// Statistics block
<?php

namespace Plt\SprmBlocks\blocks;
use Plt\SprmBlocks\Block;
use Sprm_Wp_Blocks;

class Statistics extends Block
{
    public static string $blockName = 'statistics-block';
    public static string $blockSlug = 'sprm-block-statistics';
    public static string $blockTitle = 'Statistics Block';

    public static function renderBlock($block, $content = '', $is_preview = false, $post_id = 0): void
    {
        $cssFile = Sprm_Wp_Blocks::styleFile(self::$blockName);
        $cssUrl  = Sprm_Wp_Blocks::styleUrl(self::$blockName);

        // Enqueue the block's stylesheet only once, even if the block appears multiple times.
        if (file_exists($cssFile) && !wp_style_is(self::$blockSlug, 'enqueued')) {
            wp_enqueue_style(self::$blockSlug, $cssUrl, [], filemtime($cssFile));
        }

        $template = Sprm_Wp_Blocks::templatePath(self::$blockName);
        if (file_exists($template)) {
            include $template;
        }
    }

}

// CTA Block
<?php

namespace Plt\SprmBlocks\blocks;

use Plt\SprmBlocks\Block;
use Sprm_Wp_Blocks;

class Cta extends Block
{
    public static string $blockName = 'cta-block';
    public static string $blockSlug = 'sprm-block-cta';
    public static string $blockTitle = 'CTA Block';

    public static function renderBlock($block, $content = '', $is_preview = false, $post_id = 0): void
    {
        $cssFile = Sprm_Wp_Blocks::styleFile(self::$blockName);
        $cssUrl  = Sprm_Wp_Blocks::styleUrl(self::$blockName);

        // Enqueue the block's stylesheet only once, even if the block appears multiple times.
        if (file_exists($cssFile) && !wp_style_is(self::$blockSlug, 'enqueued')) {
            wp_enqueue_style(self::$blockSlug, $cssUrl, [], filemtime($cssFile));
        }

        $template = Sprm_Wp_Blocks::templatePath(self::$blockName);
        if (file_exists($template)) {
            include $template;
        }
    }

}

И сделал абстрактный класс Plugin:

<?php

namespace Plt\SprmBlocks;

abstract class Plugin
{
    public static string $plugin_directory;
    public static string $plugin_url;
    public static string $acf_json_path;
    public  static function templatePath(string $block): string
    {
        return static::$plugin_directory . 'templates/blocks/' . $block . '/' . $block . '.php';
    }

    public static function styleFile(string $block): string
    {
        return trailingslashit(static::$plugin_directory) . "templates/blocks/{$block}/style.css";
    }

    public static function styleUrl(string $block): string
    {
        return static::$plugin_url . "templates/blocks/{$block}/style.css";
    }
}

Наследуемся:

<?php

use Plt\SprmBlocks\blocks\Cta;
use Plt\SprmBlocks\blocks\Statistics;
use Plt\SprmBlocks\Plugin;

if (! defined( 'ABSPATH') ) {
    exit;
}

require_once __DIR__ . '/vendor/autoload.php';

class Sprm_Wp_Blocks extends Plugin {

    public static string $plugin_directory;
    public static string $plugin_url;
    public static string $acf_json_path = 'config/acf-json';

    function __construct () {
        self::$plugin_directory = plugin_dir_path(__FILE__);
        self::$plugin_url = plugin_dir_url(__FILE__);
    }

    public function init(): void {
        add_action('plugins_loaded', [$this, 'setAcfJsonPath']);

        // Blocks
        Statistics::registerHooks();
        Cta::registerHooks();
    }

    public function setAcfJsonPath(): void {

        add_filter('acf/settings/load_json', function(array $paths) {

            // Remove the default ACF load path so only our path is used.
            unset($paths[0]);

            $paths[] = trailingslashit(self::$plugin_directory) . self::$acf_json_path;

            return $paths;
        });
    }

}

$instance = new Sprm_Wp_Blocks();

$instance->init();

Уже намного лучше, блоки не дублируют код друг друга.

Инкапсуляция

Смысл в том, что свойства обычно делают private или protected, а взаимодействие с ними происходит только через public методы для доступа.

Геттер – возвращает значение свойства.
Сеттер – устанавливает или изменяет его.

Сейчас у нас свойства public и мы обращаемся к ним напрямую.

abstract class Block
{
    public static string $blockName;
    public static string $blockTitle;
   
     public static function registerAcfBlockTypes(): void {

        acf_register_block_type([
            'name'            => static::$blockName,
            'title'           => static::$blockTitle,
            ....

Переделал.

<?php

namespace Plt\SprmBlocks;

abstract class Plugin
{
    private static array $instances = [];

    protected string $plugin_file;
    protected string $acf_json_path = '';

    final private function __construct()
    {
    }

    final public static function getInstance(): static
    {
        $class = static::class;

        if (! isset(self::$instances[$class])) {
            self::$instances[$class] = new static();
        }

        return self::$instances[$class];
    }

    abstract public function init(): void;

    public static function templatePath(string $block): string
    {
        return static::pluginDir() . "templates/blocks/{$block}/{$block}.php";
    }

    public static function styleFile(string $block): string
    {
        return static::pluginDir() . "templates/blocks/{$block}/style.css";
    }

    public static function styleUrl(string $block): string
    {
        return static::pluginUrl() . "templates/blocks/{$block}/style.css";
    }

    public static function pluginDir(): string
    {
        return plugin_dir_path(static::getInstance()->plugin_file);
    }

    public static function pluginUrl(): string
    {
        return plugin_dir_url(static::getInstance()->plugin_file);
    }

    public static function acfJsonPath(): string
    {
        return static::pluginDir() . static::getInstance()->acf_json_path;
    }
}

Проблема

  • Если на одной странице 2 одинаковых блока. То addActionOnce / addFilterOnce — умное решение реальной WordPress-проблемы: блок может рендериться несколько раз, и хуки не должны дублироваться.
  • DTO
  • Dependency Injection?
  • contracts
  • Сделать класс для путей? получении папки плагина и тд..
  • подклчать классы hookable в цикле
  • Как обрабатывать и выбрасывать ошибки
  • Сделать Мастер плагин для наших плагинов или хотя бы засовывать их в одну группу в меню
  • лоигку в классах можно разбить еще на классы?

Singleton Trait

Решил вынести реализацию Singleton в отдельный Trait, чтобы не писать эту логику в классе плагина.

До этого писали в абстрактном классе Plugin:

abstract class Plugin
{
    private static array $instances = [];

    protected string $plugin_file;
    protected string $acf_json_path = '';

    final private function __construct()
    {
    }

    final public static function getInstance(): static
    {
        $class = static::class;

        if (! isset(self::$instances[$class])) {
            self::$instances[$class] = new static();
        }

        return self::$instances[$class];
    }
    ...

Сделал отдельный Trait:

<?php

namespace Plt\SprmBlocks\Traits;

trait Singleton
{
    private static $instance = null;

    // Private constructor to prevent direct instantiation
    private function __construct()
    {
    }

    // Prevent cloning the instance
    private function __clone()
    {
    }

    // Prevent serialization
    private function __sleep()
    {
    }

    // Prevent deserialization
    private function __wakeup()
    {
    }

    /**
     * Get the single instance of the class.
     *
     * @return static
     */
    public static function getInstance()
    {
        if (static::$instance === null) {
            static::$instance = new static();
        }

        return static::$instance;
    }
}

И теперь используем:

class SprmWpBlocks extends Plugin
{

    use Singleton;
    ...
}

SprmWpBlocks::getInstance()->init();

Нет смысла в классе abstract Plugin

  1. Один наследник. SprmWpBlocks — единственный класс, который его расширяет. Абстрактный класс нужен когда есть общая логика для нескольких реализаций. У тебя одна.
  2. Не переиспользуется. Это не библиотека которую ты подключаешь в 5 плагинов. Это конкретный плагин.
  3. Добавляет indirection без пользы. Чтобы понять что делает SprmWpBlocks, нужно смотреть два файла вместо одного.

Поэтому его удалил, сделал просто Interface и все повыносил в классы:

// Interface

interface Pluginable
{
    public function init(): void;
}

// Plugin
class SprmWpBlocks implements Pluginable
{

    use Singleton;

    protected string $plugin_file = __FILE__;

    public function init(): void
    {
        Assets::registerHooks();
        I18n::registerHooks();
        Acf::registerHooks();

        // Blocks
        Statistics::registerHooks();
        Cta::registerHooks();
        AffiliateModels::registerHooks();
    }
}

SprmWpBlocks::getInstance()->init();