Tworzenie prostej wtyczki WordPress — praktyczny tutorial

Jak napisać pierwszą wtyczkę WordPress bez złych nawyków? Od struktury plików po hooks, shortcodes, Settings API, i18n i podstawy bezpieczeństwa.

Opublikowano

12 lutego 2025 09:00

Czytanie

4 min czytania

Aktualizacja

15 kwietnia 2026 11:52

Wtyczki to sposób na rozszerzanie WordPressa bez modyfikacji core'a. Nawet jeśli jesteś frontend developerem, umiejętność napisania prostej wtyczki przyda się częściej niż myślisz — integracje z API, czyli Application Programming Interface, definiuje sposób komunikacji między aplikacjami lub modułami., custom shortcodes, modyfikacje zachowania sklepu WooCommerce.

W tym artykule stworzymy działającą wtyczkę od zera. Bez frameworków, bez boilerplate'ów — czysty PHP i WordPress API.

Krótka odpowiedź: Wtyczka WordPress to folder w wp-content/plugins/ z plikiem PHP zawierającym specjalny nagłówek komentarza. Rozszerzasz funkcjonalność przez podpinanie się pod hooki (add_action, add_filter), panel ustawień budujesz przez Settings API, a dane wejściowe zawsze sanityzujesz przed zapisem i escapujesz przed wyświetleniem. Wszystkie funkcje i stałe powinny mieć unikalny prefiks, żeby uniknąć konfliktów z innymi wtyczkami.

Co zbudujemy?

Wtyczkę "Reading Time" która:

  • Oblicza czas czytania dla każdego posta
  • Wyświetla go automatycznie przed treścią
  • Ma panel ustawień w adminie
  • Obsługuje shortcode do ręcznego umieszczenia

Struktura wtyczki

Minimalna wtyczka to jeden plik PHP. Ale dla porządku:

Code
wp-content/plugins/
└── reading-time/
    ├── reading-time.php      # Główny plik (wymagany)
    ├── includes/
    │   ├── functions.php     # Funkcje pomocnicze
    │   └── admin.php         # Panel admina
    ├── assets/
    │   └── style.css         # Style
    └── readme.txt            # Opis dla wordpress.org

Krok 1: Główny plik wtyczki

Code
<?php
/**
 * Plugin Name: Reading Time
 * Plugin URI: https://example.com/reading-time
 * Description: Wyświetla szacowany czas czytania dla postów
 * Version: 1.0.0
 * Requires at least: 6.0
 * Requires PHP: 8.0
 * Author: Maciej Sala
 * Author URI: https://example.com
 * License: GPL v2 or later
 * Text Domain: reading-time
 */
 
// Zabezpieczenie przed bezpośrednim dostępem
if (!defined('ABSPATH')) {
    exit;
}
 
// Stałe
define('RT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('RT_PLUGIN_URL', plugin_dir_url(__FILE__));
define('RT_VERSION', '1.0.0');
 
// Ładowanie plików
require_once RT_PLUGIN_DIR . 'includes/functions.php';
 
// Admin tylko w panelu
if (is_admin()) {
    require_once RT_PLUGIN_DIR . 'includes/admin.php';
}
 
// Aktywacja
register_activation_hook(__FILE__, 'rt_activate');
function rt_activate() {
    // Domyślne ustawienia
    $defaults = [
        'words_per_minute' => 200,
        'display_position' => 'before_content',
        'post_types'       => ['post'],
    ];
    add_option('rt_settings', $defaults);
}
 
// Deaktywacja
register_deactivation_hook(__FILE__, 'rt_deactivate');
function rt_deactivate() {
    // Cleanup jeśli potrzebny
}
 
// Odinstalowanie (dla większych wtyczek zwykle lepszy jest osobny uninstall.php)
register_uninstall_hook(__FILE__, 'rt_uninstall');
function rt_uninstall() {
    delete_option('rt_settings');
}

Nagłówek w komentarzu jest wymagany — WordPress rozpoznaje po nim wtyczkę.

Krok 2: Funkcje pomocnicze

Code
<?php
// includes/functions.php
 
/**
 * Oblicz czas czytania
 */
function rt_calculate_reading_time($content) {
    $settings = get_option('rt_settings');
    $wpm = $settings['words_per_minute'] ?? 200;
    
    // Usuń HTML i policz słowa
    $text = wp_strip_all_tags($content);
    preg_match_all('/\p{L}+/u', $text, $matches);
    $word_count = count($matches[0]);
    
    // Oblicz minuty
    $minutes = ceil($word_count / $wpm);
    
    return max(1, $minutes); // Minimum 1 minuta
}
 
/**
 * Wygeneruj HTML z czasem czytania
 */
function rt_get_reading_time_html($post_id = null) {
    if (!$post_id) {
        $post_id = get_the_ID();
    }
    
    $content = get_post_field('post_content', $post_id);
    $minutes = rt_calculate_reading_time($content);
    
    // Polska odmiana
    $label = rt_pluralize($minutes, 'minuta', 'minuty', 'minut');
    
    return sprintf(
        '<span class="reading-time">📖 %d %s czytania</span>',
        $minutes,
        $label
    );
}
 
/**
 * Polska odmiana liczebników
 */
function rt_pluralize($number, $singular, $plural, $genitive_plural) {
    if ($number === 1) {
        return $singular;
    }
    
    $mod10 = $number % 10;
    $mod100 = $number % 100;
    
    if ($mod10 >= 2 && $mod10 <= 4 && ($mod100 < 10 || $mod100 >= 20)) {
        return $plural;
    }
    
    return $genitive_plural;
}
 
/**
 * Filtr: dodaj przed/po treści
 */
add_filter('the_content', 'rt_add_reading_time_to_content');
function rt_add_reading_time_to_content($content) {
    // Tylko na pojedynczych postach
    if (is_admin() || is_feed() || !is_singular() || !in_the_loop() || !is_main_query()) {
        return $content;
    }
    
    $settings = get_option('rt_settings');
    $post_types = $settings['post_types'] ?? ['post'];
    
    // Sprawdź czy ten post type jest włączony
    if (!in_array(get_post_type(), $post_types, true)) {
        return $content;
    }
    
    $reading_time = rt_get_reading_time_html();
    $position = $settings['display_position'] ?? 'before_content';
    
    if ($position === 'before_content') {
        return $reading_time . $content;
    }
    
    return $content . $reading_time;
}
 
/**
 * Shortcode: [reading_time]
 */
add_shortcode('reading_time', 'rt_shortcode');
function rt_shortcode($atts) {
    $atts = shortcode_atts([
        'post_id' => null,
    ], $atts);
    
    return rt_get_reading_time_html($atts['post_id']);
}
 
/**
 * Ładowanie stylów
 */
add_action('wp_enqueue_scripts', 'rt_enqueue_styles');
function rt_enqueue_styles() {
    wp_enqueue_style(
        'reading-time',
        RT_PLUGIN_URL . 'assets/style.css',
        [],
        RT_VERSION
    );
}

Krok 3: Panel administracyjny

Code
<?php
// includes/admin.php
 
/**
 * Dodaj stronę ustawień
 */
add_action('admin_menu', 'rt_admin_menu');
function rt_admin_menu() {
    add_options_page(
        'Reading Time',           // Tytuł strony
        'Reading Time',           // Tytuł w menu
        'manage_options',         // Capability
        'reading-time',           // Slug
        'rt_settings_page'        // Callback
    );
}
 
/**
 * Rejestracja ustawień
 */
add_action('admin_init', 'rt_register_settings');
function rt_register_settings() {
    register_setting(
        'rt_settings_group',
        'rt_settings',
        [
            'sanitize_callback' => 'rt_sanitize_settings',
        ]
    );
    
    // Sekcja główna
    add_settings_section(
        'rt_main_section',
        'Ustawienia wyświetlania',
        null,
        'reading-time'
    );
    
    // Pole: słowa na minutę
    add_settings_field(
        'words_per_minute',
        'Słów na minutę',
        'rt_field_words_per_minute',
        'reading-time',
        'rt_main_section'
    );
    
    // Pole: pozycja
    add_settings_field(
        'display_position',
        'Pozycja',
        'rt_field_display_position',
        'reading-time',
        'rt_main_section'
    );
    
    // Pole: typy postów
    add_settings_field(
        'post_types',
        'Typy treści',
        'rt_field_post_types',
        'reading-time',
        'rt_main_section'
    );
}
 
/**
 * Sanityzacja ustawień
 */
function rt_sanitize_settings($input) {
    $sanitized = [];
    
    $sanitized['words_per_minute'] = absint($input['words_per_minute'] ?? 200);
    $sanitized['words_per_minute'] = max(50, min(500, $sanitized['words_per_minute']));
    
    $sanitized['display_position'] = in_array($input['display_position'], ['before_content', 'after_content'])
        ? $input['display_position']
        : 'before_content';
    
    $sanitized['post_types'] = array_map('sanitize_key', $input['post_types'] ?? ['post']);
    
    return $sanitized;
}
 
/**
 * Pola formularza
 */
function rt_field_words_per_minute() {
    $settings = get_option('rt_settings');
    $value = $settings['words_per_minute'] ?? 200;
    ?>
    <input 
        type="number" 
        name="rt_settings[words_per_minute]" 
        value="<?php echo esc_attr($value); ?>"
        min="50"
        max="500"
    />
    <p class="description">
        Przeciętna szybkość czytania to 200-250 słów na minutę.
    </p>
    <?php
}
 
function rt_field_display_position() {
    $settings = get_option('rt_settings');
    $value = $settings['display_position'] ?? 'before_content';
    ?>
    <select name="rt_settings[display_position]">
        <option value="before_content" <?php selected($value, 'before_content'); ?>>
            Przed treścią
        </option>
        <option value="after_content" <?php selected($value, 'after_content'); ?>>
            Po treści
        </option>
    </select>
    <?php
}
 
function rt_field_post_types() {
    $settings = get_option('rt_settings');
    $selected = $settings['post_types'] ?? ['post'];
    
    $post_types = get_post_types(['public' => true], 'objects');
    
    foreach ($post_types as $pt) {
        if ($pt->name === 'attachment') continue;
        ?>
        <label style="display: block; margin-bottom: 5px;">
            <input 
                type="checkbox" 
                name="rt_settings[post_types][]" 
                value="<?php echo esc_attr($pt->name); ?>"
                <?php checked(in_array($pt->name, $selected)); ?>
            />
            <?php echo esc_html($pt->label); ?>
        </label>
        <?php
    }
}
 
/**
 * Strona ustawień
 */
function rt_settings_page() {
    if (!current_user_can('manage_options')) {
        return;
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
        
        <form action="options.php" method="post">
            <?php
            settings_fields('rt_settings_group');
            do_settings_sections('reading-time');
            submit_button('Zapisz ustawienia');
            ?>
        </form>
        
        <hr>
        
        <h2>Shortcode</h2>
        <p>Możesz też użyć shortcode w dowolnym miejscu:</p>
        <code>[reading_time]</code>
        <p>Lub z parametrem ID posta:</p>
        <code>[reading_time post_id="123"]</code>
    </div>
    <?php
}

Krok 4: Style

Code
/* assets/style.css */
 
.reading-time {
    display: inline-block;
    padding: 0.5em 1em;
    margin-bottom: 1em;
    background: #f0f0f0;
    border-radius: 4px;
    font-size: 0.875em;
    color: #666;
}
 
/* Dark mode support */
@media (prefers-color-scheme: dark) {
    .reading-time {
        background: #333;
        color: #ccc;
    }
}

Krok 5: Internacjonalizacja (i18n)

Dla obsługi tłumaczeń:

Code
// W głównym pliku, po stałych
add_action('init', 'rt_load_textdomain');
function rt_load_textdomain() {
    load_plugin_textdomain(
        'reading-time',
        false,
        dirname(plugin_basename(__FILE__)) . '/languages'
    );
}
 
// Użycie w kodzie
$label = sprintf(
    _n('%d minuta czytania', '%d minut czytania', $minutes, 'reading-time'),
    $minutes
);

Testowanie wtyczki

1. Debug mode

W wp-config.php:

Code
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);

2. Sprawdź logi

Code
tail -f wp-content/debug.log

3. Użyteczne hooki do debugowania

Code
// Tymczasowo
add_action('wp_footer', function() {
    if (!current_user_can('manage_options')) return;
    
    echo '<pre style="background: #000; color: #0f0; padding: 20px;">';
    print_r(get_option('rt_settings'));
    echo '</pre>';
});

Rozszerzenia — pomysły

REST API endpoint

Code
add_action('rest_api_init', function() {
    register_rest_route('reading-time/v1', '/post/(?P<id>\d+)', [
        'methods'  => 'GET',
        'callback' => function($request) {
            $post_id = $request['id'];
            $content = get_post_field('post_content', $post_id);
            
            return [
                'post_id'      => $post_id,
                'reading_time' => rt_calculate_reading_time($content),
            ];
        },
        'permission_callback' => '__return_true',
    ]);
});

Taki permission_callback jest poprawny tylko wtedy, gdy endpoint ma być publiczny.

Gutenberg block (opcjonalnie)

Code
add_action('init', function() {
    register_block_type('reading-time/display', [
        'render_callback' => function() {
            return rt_get_reading_time_html();
        },
    ]);
});

Dobre praktyki

1. Prefixowanie

Wszystkie funkcje i stałe zaczynaj od unikalnego prefixu (rt_ w naszym przypadku), aby uniknąć konfliktów.

2. Sanityzacja danych

Code
// Wejście od użytkownika
$input = sanitize_text_field($_POST['field']);
 
// Escape przy wyświetlaniu
echo esc_html($variable);
echo esc_attr($attribute);
echo esc_url($url);

3. Nonces dla formularzy

Code
// W formularzu
wp_nonce_field('rt_save_settings', 'rt_nonce');
 
// Przy zapisie
if (
    !isset($_POST['rt_nonce']) ||
    !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['rt_nonce'])), 'rt_save_settings')
) {
    die('Security check failed');
}

4. Capability checks

Code
if (!current_user_can('manage_options')) {
    wp_die('Brak uprawnień');
}

Struktura finalna

Code
reading-time/
├── reading-time.php
├── includes/
│   ├── functions.php
│   └── admin.php
├── assets/
│   └── style.css
├── languages/
│   └── reading-time-pl_PL.po
├── uninstall.php
└── readme.txt

FAQ

Od czego zacząć tworzenie wtyczki WordPress?

Utwórz folder w wp-content/plugins/nazwa-wtyczki/ i główny plik PHP z nagłówkiem komentarza zawierającym Plugin Name: — WordPress rozpoznaje wtyczkę po tym nagłówku. Dodaj zabezpieczenie przed bezpośrednim dostępem (if (!defined('ABSPATH')) exit;) i możesz zacząć podpinać własny kod przez add_action() i add_filter(). Minimalna wtyczka to dosłownie kilkanaście linii kodu.

Jak używać Settings API do panelu ustawień wtyczki?

Zarejestruj stronę menu przez add_options_page() wewnątrz hooka admin_menu. Następnie w hooku admin_init użyj register_setting(), add_settings_section() i add_settings_field() do zdefiniowania formularza. Dane zapisuje WordPress automatycznie przy wysłaniu formularza — musisz tylko podać sanitize_callback do walidacji i sanityzacji danych wejściowych. To solidniejsze podejście niż ręczna obsługa $_POST.

Jak bezpiecznie obsługiwać dane wejściowe w wtyczce WordPress?

Zasada jest prosta: sanityzuj na wejściu, escapuj na wyjściu. Na wejściu używaj sanitize_text_field(), absint(), sanitize_email() zależnie od typu danych. Na wyjściu w HTML używaj esc_html(), w atrybutach esc_attr(), w URLach esc_url(). Dla formularzy zapisujących dane do bazy zawsze weryfikuj nonce przez wp_verify_nonce().

Czym różnią się haki aktywacji, deaktywacji i odinstalowania?

Hook aktywacji (register_activation_hook) uruchamia się jednorazowo przy włączeniu wtyczki — dobry moment na tworzenie tabel w bazie lub zapisanie domyślnych ustawień. Hook deaktywacji uruchamia się przy wyłączeniu wtyczki, ale dane powinny zostać (użytkownik może wtyczkę reaktywować). Hook odinstalowania (lub plik uninstall.php) uruchamia się przy usunięciu wtyczki — tu czyścisz opcje i tabele, żeby nie zostawiać śmieci.

Jak dodać shortcode w wtyczce WordPress?

Zarejestruj shortcode przez add_shortcode('nazwa', 'funkcja_callback'). Callback otrzymuje tablicę $atts z atrybutami i opcjonalnie $content dla shortcode'ów z zawartością. Użyj shortcode_atts() do scalenia podanych atrybutów z domyślnymi wartościami. Shortcode powinien zwracać HTML (przez return, nie echo), żeby mógł być osadzony w dowolnym miejscu.

Jak załadować style i skrypty tylko tam, gdzie są potrzebne?

Używaj wp_enqueue_style() i wp_enqueue_script() wewnątrz hooka wp_enqueue_scripts. Dla zasobów panelu admina użyj hooka admin_enqueue_scripts. Żeby załadować zasoby tylko na konkretnych stronach, sprawdzaj warunki przez is_singular(), is_page() itp. lub parametr $hook dostępny w admin_enqueue_scripts. Nigdy nie dodawaj <link> ani <script> bezpośrednio przez echo.

Jak przetestować wtyczkę WordPress podczas developmentu?

Włącz tryb debug przez define('WP_DEBUG', true) i define('WP_DEBUG_LOG', true) w wp-config.php — błędy PHP trafią do wp-content/debug.log. Używaj error_log(print_r($zmienna, true)) do podglądu danych. Możesz też tymczasowo wyświetlać dane w stopce strony dla zalogowanych adminów przez hook wp_footer z zabezpieczeniem current_user_can('manage_options').

Podsumowanie

Tworzenie wtyczki WordPress to:

  1. Nagłówek PHP z metadanymi wtyczki
  2. Hooks do podpinania się w odpowiednich miejscach
  3. Settings API dla konfiguracji w adminie
  4. Sanityzacja każdego inputu od użytkownika
  5. Prefixowanie wszystkich funkcji
  6. Sensowny uninstall i brak śmieci po deaktywacji

Nasza wtyczka Reading Time to ~200 linii kodu, ale pokazuje wszystkie kluczowe koncepty. Od tego wzorca możesz rozbudować praktycznie dowolną funkcjonalność.

Źródła i dokumentacja


Chcesz poznać WordPress głębiej? Sprawdź WordPress od zera — instalacja, architektura i podstawy działania lub poznaj REST API WordPressa z React i Next.js.

Pracuję z tym zawodowo.

Jeśli chcesz dobrze poukładać WordPressa, WooCommerce albo headless setup jeszcze przed wdrożeniem, skontaktuj się ze mną. Pomagam ocenić trade-offy techniczne, redakcyjne i biznesowe, zanim projekt zacznie generować kosztowny chaos.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab