WordPress: Plugin-Entwicklung

Akademie

WordPress: Plugin-Entwicklung

Struktur, Aktivierung/Deinstallation, Shortcodes und die Settings/Options API.

Admin-Oberfläche: Settings API und Meta-Boxen

Begriffe vorab

  • Admin-Menü: Einträge in der linken Backend-Leiste.
  • Settings API: WordPress-Schnittstelle, um Einstellungen mit Bereichen und Feldern zu registrieren und sicher zu speichern.
  • Meta-Box: ein Kasten im Beitragseditor für Zusatzfelder.
  • Capability: eine einzelne Berechtigung wie manage_options.

Eine Einstellungsseite in drei Schritten

Die Settings API übernimmt den mühsamen Teil: Nonce-Prüfung, Rechtekontrolle, Speichern und Sanitizing laufen über options.php. Du beschreibst nur, was gespeichert werden soll.

Schritt 1: Menü anlegen

add_action( 'admin_menu', function () {
    add_options_page(
        'Merkzettel', 'Merkzettel',
        'manage_options', 'merkzettel',
        'mz_render_settings'
    );
} );

Schritt 2: Einstellung registrieren

add_action( 'admin_init', function () {
    register_setting( 'mz_group', 'mz_settings', array(
        'type'              => 'array',
        'sanitize_callback' => 'mz_sanitize_settings',
        'default'           => array( 'max_items' => 50 ),
    ) );
    add_settings_section( 'mz_main', 'Allgemein', '__return_false', 'merkzettel' );
    add_settings_field( 'max_items', 'Maximale Einträge', 'mz_field_max', 'merkzettel', 'mz_main' );
} );

function mz_sanitize_settings( $in ) {
    return array( 'max_items' => max( 1, min( 500, absint( $in['max_items'] ?? 50 ) ) ) );
}

function mz_field_max() {
    $o = get_option( 'mz_settings', array( 'max_items' => 50 ) );
    printf( '<input type="number" name="mz_settings[max_items]" value="%d" min="1" max="500">', (int) $o['max_items'] );
}

Schritt 3: Seite ausgeben

function mz_render_settings() {
    if ( ! current_user_can( 'manage_options' ) ) { return; }
    echo '<div class="wrap"><h1>Merkzettel</h1><form method="post" action="options.php">';
    settings_fields( 'mz_group' );
    do_settings_sections( 'merkzettel' );
    submit_button();
    echo '</form></div>';
}

settings_fields() gibt das versteckte Nonce-Feld und die Gruppe aus; options.php prüft beides und ruft deinen sanitize_callback auf. Die Prüfung der Bereichsgrenzen dort ist wichtig, weil niemand dem Formular vertrauen darf.

Meta-Box mit sicherem Speichern

add_action( 'add_meta_boxes', function () {
    add_meta_box( 'mz_box', 'Merkzettel', 'mz_box_html', 'post', 'side' );
} );

function mz_box_html( $post ) {
    wp_nonce_field( 'mz_save', 'mz_nonce' );
    $v = get_post_meta( $post->ID, '_mz_note', true );
    printf( '<textarea name="mz_note">%s</textarea>', esc_textarea( $v ) );
}

add_action( 'save_post', function ( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; }
    if ( ! isset( $_POST['mz_nonce'] ) || ! wp_verify_nonce( $_POST['mz_nonce'], 'mz_save' ) ) { return; }
    if ( ! current_user_can( 'edit_post', $post_id ) ) { return; }
    update_post_meta( $post_id, '_mz_note', sanitize_textarea_field( wp_unslash( $_POST['mz_note'] ?? '' ) ) );
} );

Die drei Prüfungen im Speichern-Callback – Autosave überspringen, Nonce verifizieren, Recht prüfen – gehören immer zusammen. Fehlt eine, entsteht entweder ein Sicherheitsloch oder ein Fehler beim automatischen Zwischenspeichern.

Zum Selbermachen

  1. Baue die Einstellungsseite nach und speichere einen Wert außerhalb des erlaubten Bereichs – er wird begrenzt.
  2. Füge ein zweites Feld (Checkbox) hinzu und erweitere den Sanitizer.
  3. Prüfe in der Datenbank, wie die Option als Array gespeichert wird.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Plugin Handbook – „Settings API“ und „Administration Menus“ (developer.wordpress.org/plugins/settings/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Funktionsreferenzen zu register_setting(), add_settings_field(), add_meta_box() (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Plugin Handbook – „Security: Nonces, Data Validation“ (developer.wordpress.org/plugins/security/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Aufbau: Header, Struktur und Bootstrap

Begriffe vorab

  • Plugin-Header: der Kommentarblock am Anfang der Hauptdatei, an dem WordPress ein Plugin erkennt.
  • Hauptdatei: die PHP-Datei mit dem Header; sie wird von WordPress direkt geladen.
  • Bootstrap: der Startcode, der Konstanten setzt, Klassen lädt und Hooks registriert.
  • Autoloading: Klassen werden bei Bedarf automatisch geladen, statt jede Datei von Hand einzubinden.
  • Präfix: eindeutiger Namensanfang für Funktionen, Konstanten und Optionen (hier mz_).

Das Beispiel dieses Deepdives

Wir bauen durchgängig ein kleines Plugin namens „Merkzettel“: Angemeldete Nutzer können Beiträge auf eine persönliche Merkliste setzen. Es braucht eine Einstellungsseite, ein Shortcode, eine Schnittstelle zum Speichern, eine eigene Tabelle und einen Aufräumjob – genug, um alle wichtigen Bausteine zu zeigen.

Verzeichnisstruktur

merkzettel/
├── merkzettel.php        ← Hauptdatei mit Header (dünn halten)
├── uninstall.php         ← Aufräumen beim Löschen
├── includes/
│   ├── class-plugin.php  ← Startlogik, registriert Hooks
│   ├── class-admin.php   ← Einstellungen
│   └── class-store.php   ← Datenbankzugriff
├── assets/
│   └── merkzettel.js
└── languages/

Der Header

Nur Plugin Name ist Pflicht. Sinnvoll sind außerdem Angaben zu Mindestversionen, damit WordPress eine Aktivierung auf zu alten Systemen verhindert:

<?php
/**
 * Plugin Name:       Merkzettel
 * Description:       Persönliche Merkliste für Beiträge.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      8.0
 * Author:            Beispiel GmbH
 * License:           GPL-2.0-or-later
 * Text Domain:       merkzettel
 * Domain Path:       /languages
 */

defined( 'ABSPATH' ) || exit;

Seit WordPress 6.5 gibt es zusätzlich den Header Requires Plugins, in dem man durch Komma getrennt die Slugs von Plugins nennt, die installiert sein müssen. Der Header Update URI verhindert, dass ein selbst verteiltes Plugin versehentlich durch ein gleichnamiges aus dem WordPress.org-Verzeichnis überschrieben wird.

Bootstrap: dünne Hauptdatei

Die Hauptdatei sollte nur Konstanten setzen und die eigentliche Arbeit anstoßen. Alles andere liegt in Klassen, die sich bei Bedarf laden:

define( 'MZ_VERSION', '1.0.0' );
define( 'MZ_FILE', __FILE__ );
define( 'MZ_PATH', plugin_dir_path( __FILE__ ) );
define( 'MZ_URL',  plugin_dir_url( __FILE__ ) );

spl_autoload_register( function ( $class ) {
    if ( strpos( $class, 'MZ_' ) !== 0 ) {
        return;
    }
    $file = MZ_PATH . 'includes/class-' . strtolower( str_replace( '_', '-', substr( $class, 3 ) ) ) . '.php';
    if ( is_readable( $file ) ) {
        require_once $file;
    }
} );

add_action( 'plugins_loaded', array( 'MZ_Plugin', 'init' ) );

Warum Präfixe und der ABSPATH-Schutz

Alle Plugins teilen sich einen globalen PHP-Namensraum. Eine Funktion save_item() ohne Präfix kollidiert früher oder später mit einem anderen Plugin und löst einen Fatal Error aus. Das ABSPATH-Prüfen am Dateianfang verhindert, dass die Datei direkt über die URL ausgeführt wird, außerhalb von WordPress.

Faustregel: Die Hauptdatei bleibt unter etwa 50 Zeilen. Wächst sie, ist Logik im falschen Ort.

Zum Selbermachen

  1. Lege den Ordner merkzettel mit Hauptdatei und Header an.
  2. Aktiviere das Plugin im Backend und prüfe, dass es in der Liste erscheint.
  3. Setze testweise Requires PHP: 99 und beobachte, dass die Aktivierung verweigert wird.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Plugin Handbook – „Header Requirements“ (developer.wordpress.org/plugins/plugin-basics/header-requirements/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Plugin Handbook – „Best Practices“ (Präfixe, Dateistruktur; developer.wordpress.org/plugins/plugin-basics/best-practices/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • PHP-Handbuch – „spl_autoload_register“ (php.net) (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Eigene Tabellen mit dbDelta und $wpdb

Begriffe vorab

  • $wpdb: die globale Datenbankklasse von WordPress.
  • Tabellenpräfix: $wpdb->prefix, in der Konfiguration festgelegt (nicht fest wp_).
  • dbDelta: Funktion, die Tabellen anlegt oder an eine neue Definition anpasst.
  • Prepared Statement: eine Abfrage mit Platzhaltern, die Werte sicher einsetzt.

Wann eine eigene Tabelle?

Für wenige, einfache Werte genügen Optionen und Post-Meta. Eine eigene Tabelle lohnt, wenn viele Datensätze mit eigenem Aufbau entstehen oder gezielt abgefragt werden: Merklisten vieler Nutzer, Statistikeinträge, Protokolle. Post-Meta bei Millionen Zeilen wird langsam, eine passend indexierte Tabelle nicht.

Tabelle mit dbDelta anlegen

dbDelta() vergleicht die gewünschte Definition mit der vorhandenen und legt an oder ändert. Das macht es wiederholbar – ideal für Updates. Es ist aber pingelig bei der Schreibweise:

  • Jedes Feld in einer eigenen Zeile.
  • Zwei Leerzeichen zwischen PRIMARY KEY und der Definition.
  • Das Wort KEY statt INDEX.
  • Keine Apostrophe oder Backticks um Feldnamen.
class MZ_Store {
    public static function table() {
        global $wpdb;
        return $wpdb->prefix . 'mz_items';
    }

    public static function create_table() {
        global $wpdb;
        $charset = $wpdb->get_charset_collate();
        $sql = "CREATE TABLE " . self::table() . " (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            user_id bigint(20) unsigned NOT NULL,
            post_id bigint(20) unsigned NOT NULL,
            created datetime NOT NULL,
            PRIMARY KEY  (id),
            UNIQUE KEY user_post (user_id,post_id),
            KEY post_id (post_id)
        ) $charset;";
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( $sql );
    }

Der eindeutige Schlüssel user_post sorgt dafür, dass derselbe Beitrag pro Nutzer nur einmal gespeichert werden kann – die Datenbank erzwingt, was die Anwendung sonst prüfen müsste.

Sicher lesen und schreiben

    public static function add( $user_id, $post_id ) {
        global $wpdb;
        return $wpdb->insert(
            self::table(),
            array( 'user_id' => $user_id, 'post_id' => $post_id, 'created' => current_time( 'mysql', true ) ),
            array( '%d', '%d', '%s' )
        );
    }

    public static function for_user( $user_id, $limit = 50 ) {
        global $wpdb;
        return $wpdb->get_results( $wpdb->prepare(
            'SELECT post_id, created FROM ' . self::table() . ' WHERE user_id = %d ORDER BY created DESC LIMIT %d',
            $user_id, $limit
        ) );
    }
}

Der Tabellenname darf nicht über einen Platzhalter eingesetzt werden, Werte schon. Deshalb kommt der Name aus $wpdb->prefix und fester Zeichenfolge, alle Nutzerwerte laufen über %d/%s. Eine Verkettung von Nutzereingaben in SQL ist die klassische Einfallstür für SQL-Injection.

Zum Selbermachen

  1. Lege die Tabelle über die Aktivierung an und prüfe sie in einem Datenbankwerkzeug.
  2. Füge denselben Eintrag zweimal ein und beobachte, dass der eindeutige Schlüssel den Doppeleintrag verhindert.
  3. Ergänze eine Spalte, erhöhe die DB-Version und prüfe, dass dbDelta die Tabelle anpasst.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Plugin Handbook – „Creating Tables with Plugins“ (dbDelta-Regeln; developer.wordpress.org/plugins/creating-tables-with-plugins/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Klassenreferenz zu wpdb (insert, prepare, get_results, get_charset_collate) (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Frontend: Shortcode, Assets, REST und AJAX

Begriffe vorab

  • Shortcode: ein Platzhalter wie [merkzettel], den WordPress beim Ausgeben durch Code ersetzt.
  • Enqueue: das geordnete Einbinden von Skripten und Styles über WordPress.
  • AJAX: Daten im Hintergrund an den Server senden, ohne die Seite neu zu laden.
  • REST-Route: eine URL der WordPress-REST-API, an die ein Plugin eigene Funktionen hängt.
  • Nonce: kurzlebiges Token, das die Herkunft einer Anfrage bestätigt.

Shortcode, der nur bei Bedarf Assets lädt

Ein häufiger Fehler ist, Skripte auf jeder Seite zu laden. Besser: Skript registrieren und erst im Shortcode einreihen. So wird es nur dort geladen, wo der Shortcode steht.

add_action( 'wp_enqueue_scripts', function () {
    wp_register_script( 'mz-js', MZ_URL . 'assets/merkzettel.js', array(), MZ_VERSION,
        array( 'in_footer' => true, 'strategy' => 'defer' ) );
} );

add_shortcode( 'merkzettel', function ( $atts ) {
    $atts = shortcode_atts( array( 'titel' => 'Meine Merkliste' ), $atts, 'merkzettel' );
    if ( ! is_user_logged_in() ) {
        return '<p>Bitte anmelden.</p>';
    }
    wp_enqueue_script( 'mz-js' );
    wp_add_inline_script( 'mz-js', 'window.MZ=' . wp_json_encode( array(
        'rest'  => esc_url_raw( rest_url( 'merkzettel/v1/items' ) ),
        'nonce' => wp_create_nonce( 'wp_rest' ),
    ) ) . ';', 'before' );
    return '<div class="mz"><h3>' . esc_html( $atts['titel'] ) . '</h3><ul id="mz-list"></ul></div>';
} );

Wichtig: Ein Shortcode-Callback gibt zurück, er gibt nicht aus. wp_json_encode() und esc_url_raw() sorgen dafür, dass Werte sicher im Skript landen.

REST statt admin-ajax

Für neue Plugins ist die REST API der empfohlene Weg: klare URLs, Versionierung, Rechteprüfung pro Route. Die Route wird auf rest_api_init registriert; der permission_callback ist verpflichtend.

add_action( 'rest_api_init', function () {
    register_rest_route( 'merkzettel/v1', '/items', array(
        array(
            'methods'             => 'POST',
            'callback'            => 'mz_add_item',
            'permission_callback' => fn() => is_user_logged_in(),
            'args'                => array(
                'post_id' => array( 'required' => true, 'sanitize_callback' => 'absint' ),
            ),
        ),
    ) );
} );

function mz_add_item( WP_REST_Request $req ) {
    $post_id = (int) $req['post_id'];
    if ( ! get_post_status( $post_id ) ) {
        return new WP_Error( 'mz_not_found', 'Beitrag nicht gefunden.', array( 'status' => 404 ) );
    }
    MZ_Store::add( get_current_user_id(), $post_id );
    return rest_ensure_response( array( 'ok' => true ) );
}

Bei Cookie-Anmeldung muss jede Anfrage das Nonce zur Aktion wp_rest mitsenden, entweder im Header X-WP-Nonce oder als Parameter _wpnonce. Fehlt es, setzt WordPress den Nutzer auf 0 – die Anfrage gilt als anonym, auch wenn man angemeldet ist.

Wenn doch admin-ajax.php

Bestehender Code nutzt oft die Hooks wp_ajax_{aktion} (angemeldet) und wp_ajax_nopriv_{aktion} (nicht angemeldet) mit admin-ajax.php. Das funktioniert weiter, bietet aber weniger Struktur als REST. Auch dort gehören Nonce-Prüfung und Rechtekontrolle in den Handler.

Zum Selbermachen

  1. Baue den Shortcode und prüfe im Seitenquelltext, dass das Skript nur auf Seiten mit Shortcode erscheint.
  2. Rufe die Route ohne Nonce auf und beobachte die Antwort.
  3. Sende einen ungültigen post_id und prüfe den 404-Fehler.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Plugin Handbook – „Shortcodes“ (developer.wordpress.org/plugins/shortcodes/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress REST API Handbook – „Adding Custom Endpoints“ und „Authentication“ (developer.wordpress.org/rest-api/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Funktionsreferenz zu wp_register_script() (Ladestrategie ab WordPress 6.3) (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Qualität und Veröffentlichung: Standards, Tests, Plugin Check

Begriffe vorab

  • WPCS: die WordPress Coding Standards, ein Regelwerk für Stil und gute Praxis.
  • PHPCS: PHP_CodeSniffer, das Werkzeug, das Code gegen solche Regeln prüft.
  • Plugin Check (PCP): ein offizielles Plugin, das ein anderes Plugin auf Verzeichnis-Anforderungen prüft.
  • Stable Tag: Angabe in der readme.txt, welche Version Nutzer als aktuelle sehen.
  • SemVer: Versionierung nach dem Schema Haupt.Neben.Fehlerkorrektur.

Automatische Prüfung als Routine

Gute Plugins prüfen sich selbst, bevor jemand anderes es tut. Drei Werkzeuge decken die wichtigsten Ebenen ab:

  1. PHPCS mit WPCS findet Stilverstöße und typische Sicherheitsmängel (fehlendes Escaping, ungeprüfte Eingaben).
  2. PHPUnit mit der WordPress-Testumgebung prüft Verhalten. Das Gerüst erzeugt WP-CLI mit wp scaffold plugin-tests.
  3. Plugin Check prüft statisch (über PHP_CodeSniffer) und dynamisch (das Plugin wird aktiviert) auf Anforderungen des WordPress.org-Verzeichnisses sowie auf Hinweise zu Internationalisierung, Barrierefreiheit, Performance und Sicherheit.
# Stil und Sicherheit prüfen
composer require --dev wp-coding-standards/wpcs
vendor/bin/phpcs --standard=WordPress includes/ merkzettel.php

# Testgerüst erzeugen
wp scaffold plugin-tests merkzettel

Ein einfacher Test

class Test_Store extends WP_UnitTestCase {
    public function test_add_is_unique_per_user_and_post() {
        $user = self::factory()->user->create();
        $post = self::factory()->post->create();

        $this->assertNotFalse( MZ_Store::add( $user, $post ) );
        $this->assertFalse( @MZ_Store::add( $user, $post ) ); // eindeutiger Schlüssel greift
    }
}

Kompatibilität pflegen

  • Mindestversionen von WordPress und PHP im Header ehrlich angeben und tatsächlich testen.
  • Mit eingeschaltetem WP_DEBUG entwickeln, damit Hinweise zu veralteten Funktionen sichtbar werden.
  • Versionsnummern nach SemVer vergeben und Änderungen in einer Änderungsliste festhalten.

Veröffentlichung im WordPress.org-Verzeichnis

Die Richtlinien sind klar: Plugins müssen GPL-kompatibel lizenziert sein (empfohlen: „GPLv2 or later“), ihr Code muss weitgehend lesbar sein – Verschleierung, etwa durch Umbenennen in unlesbare Namen, ist nicht erlaubt; für minifizierte Dateien sollen lesbare Quellen mitgeliefert oder verlinkt werden – und Nutzer dürfen nicht ohne ihre Einwilligung verfolgt werden; Plugins dürfen keine externen Server ohne ausdrückliche, autorisierte Zustimmung kontaktieren, üblicherweise über ein Opt-in. Die readme.txt beschreibt das Plugin im Verzeichnis; ihr Stable tag bestimmt, welche Version als stabil ausgeliefert wird.

Ein Praxisbeispiel für das Opt-in-Prinzip ist das Tracking im Quiz-Academy-Plugin dieses Projekts: Es ist standardmäßig aus, bindet keinen eigenen Tracker ein und wird erst im Backend bewusst eingeschaltet.

Zum Selbermachen

  1. Installiere PHPCS mit WPCS und prüfe dein Plugin. Behebe die ersten fünf Meldungen.
  2. Installiere „Plugin Check“ und führe es für dein Plugin aus.
  3. Schreibe eine readme.txt mit Beschreibung, Installation, Änderungsliste und Stable tag.

Eine Checkliste vor jedem Release

  1. Versionsnummer in Header, Konstante und readme.txt angleichen.
  2. PHPCS und Tests laufen lassen, Plugin Check ausführen.
  3. Mit der niedrigsten unterstützten WordPress- und PHP-Version testen.
  4. Änderungsliste ergänzen, Stable tag setzen.
  5. Auf einer frischen Installation aktivieren, benutzen, deaktivieren und löschen – und prüfen, dass nichts zurückbleibt.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Plugin Handbook – „Detailed Plugin Guidelines“ (developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Plugin Handbook – „Plugin Readmes“ (developer.wordpress.org/plugins/wordpress-org/how-your-readme-txt-works/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • Make WordPress Plugins – „Introducing Plugin Check (PCP)“ und Plugin Check im Plugin-Verzeichnis (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Coding Standards (github.com/WordPress/WordPress-Coding-Standards) (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Hintergrundjobs und Zwischenspeicher: WP-Cron und Transients

Begriffe vorab

  • WP-Cron: der in WordPress eingebaute Zeitplan für wiederkehrende Aufgaben.
  • Event: eine geplante Ausführung eines Hooks.
  • Intervall: der Abstand zwischen zwei Ausführungen (z. B. hourly, daily).
  • Transient: ein zwischengespeicherter Wert mit Ablaufzeit.
  • Object Cache: ein Zwischenspeicher im Arbeitsspeicher, der Abfragen beschleunigt.

Wie WP-Cron arbeitet

WP-Cron ist kein echter Cronjob des Servers. Bei jedem Seitenaufruf prüft WordPress, ob geplante Aufgaben fällig sind, und stößt sie im Hintergrund über wp-cron.php an. Daraus folgt: Auf einer Seite ohne Besucher laufen Aufgaben verspätet oder gar nicht, und bei sehr viel Verkehr entstehen viele unnötige Prüfungen. Auf stark genutzten Seiten schaltet man deshalb die Seitenaufruf-Auslösung mit define( 'DISABLE_WP_CRON', true ) ab und ruft wp-cron.php stattdessen per echtem Server-Cronjob in festen Abständen auf. Wer das Abschalten ohne Ersatz tut, bekommt gar keine Ausführung mehr.

Aufgabe planen – nur einmal

Der häufigste Fehler: wp_schedule_event() bei jedem Seitenaufruf aufrufen und so Hunderte gleiche Aufgaben anlegen. Plane stattdessen bei der Aktivierung und prüfe vorher mit wp_next_scheduled():

// Aktivierung
if ( ! wp_next_scheduled( 'mz_cleanup' ) ) {
    wp_schedule_event( time(), 'daily', 'mz_cleanup' );
}

// Die eigentliche Arbeit
add_action( 'mz_cleanup', function () {
    global $wpdb;
    $wpdb->query( $wpdb->prepare(
        'DELETE i FROM ' . MZ_Store::table() . ' i LEFT JOIN ' . $wpdb->posts . ' p ON p.ID = i.post_id WHERE p.ID IS NULL AND i.created < %s',
        gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS )
    ) );
} );

// Deaktivierung
wp_clear_scheduled_hook( 'mz_cleanup' );

Die Aufgabe räumt Einträge zu inzwischen gelöschten Beiträgen weg. Eigene Intervalle ergänzt man über den Filter cron_schedules.

Transients als Zwischenspeicher

Teure Ergebnisse legt man mit einer Ablaufzeit ab, damit sie nicht bei jedem Aufruf neu berechnet werden:

function mz_popular_posts() {
    $cached = get_transient( 'mz_popular' );
    if ( false !== $cached ) {
        return $cached;
    }
    global $wpdb;
    $rows = $wpdb->get_results(
        'SELECT post_id, COUNT(*) AS n FROM ' . MZ_Store::table() . ' GROUP BY post_id ORDER BY n DESC LIMIT 10'
    );
    set_transient( 'mz_popular', $rows, 15 * MINUTE_IN_SECONDS );
    return $rows;
}

Ohne persistenten Object Cache liegen Transients in der Tabelle wp_options; mit einem Object Cache (z. B. Redis) liegen sie im Arbeitsspeicher. Ablaufzeiten sind Obergrenzen: Ein Transient kann früher verschwinden, deshalb muss der Code immer mit einem Fehlen rechnen.

Transients sind Zwischenspeicher, keine Datenbank. Alles, was nicht neu berechnet werden kann, gehört in Optionen oder eine Tabelle.

Zum Selbermachen

  1. Plane die Aufgabe bei Aktivierung und prüfe sie mit dem Plugin „WP Crontrol“ oder wp cron event list.
  2. Löse sie manuell aus und beobachte das Ergebnis.
  3. Messe die Laufzeit von mz_popular_posts() mit und ohne Transient.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Plugin Handbook – „Cron“ (developer.wordpress.org/plugins/cron/), einschließlich „Hooking WP-Cron Into the System Task Scheduler“ (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Funktionsreferenzen zu wp_schedule_event(), wp_next_scheduled(), set_transient() (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WP-CLI Handbuch – „wp cron“ (developer.wordpress.org/cli/commands/cron/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Lebenszyklus: Aktivierung, Deaktivierung, Deinstallation

Begriffe vorab

  • Aktivierung: der Moment, in dem ein Administrator das Plugin einschaltet.
  • Deaktivierung: das Ausschalten; das Plugin bleibt installiert.
  • Deinstallation: das Löschen des Plugins über das Backend.
  • DB-Version: eine in der Datenbank gespeicherte Zahl, mit der man erkennt, ob Struktur-Updates nötig sind.

Drei Ereignisse, drei Aufgaben

  • Aktivierung: einmalige Einrichtung – Tabellen anlegen, Standardwerte setzen, Cron-Aufgaben planen.
  • Deaktivierung: aufräumen, was ohne Plugin ins Leere liefe – geplante Aufgaben entfernen. Keine Nutzerdaten löschen.
  • Deinstallation: endgültiges Entfernen aller Daten, die das Plugin angelegt hat.

Aktivierung und Deaktivierung

register_activation_hook( MZ_FILE, 'mz_activate' );
register_deactivation_hook( MZ_FILE, 'mz_deactivate' );

function mz_activate() {
    MZ_Store::create_table();                  // siehe Datenbank-Lektion
    add_option( 'mz_settings', array( 'max_items' => 50 ) );
    update_option( 'mz_db_version', MZ_VERSION );
    if ( ! wp_next_scheduled( 'mz_cleanup' ) ) {
        wp_schedule_event( time(), 'daily', 'mz_cleanup' );
    }
    flush_rewrite_rules();                     // nur nötig, wenn Rewrite-Regeln registriert wurden
}

function mz_deactivate() {
    wp_clear_scheduled_hook( 'mz_cleanup' );
    flush_rewrite_rules();
}

Wichtig: Die Aktivierungs-Funktion wird nicht ausgeführt, wenn ein Plugin per Update ersetzt wird. Strukturänderungen in einer neuen Version gehören deshalb nicht allein dorthin. Üblich ist ein Versionsvergleich beim Start:

add_action( 'plugins_loaded', function () {
    if ( get_option( 'mz_db_version' ) !== MZ_VERSION ) {
        MZ_Store::create_table();             // dbDelta ist wiederholbar
        update_option( 'mz_db_version', MZ_VERSION );
    }
} );

Deinstallation: uninstall.php

Das Handbuch empfiehlt eine Datei uninstall.php im Plugin-Hauptordner. WordPress führt sie aus, wenn das Plugin im Backend gelöscht wird. Die Alternative register_uninstall_hook() speichert den Callback in einer Datenbankoption und gilt als weniger geeignet. In uninstall.php prüft man zuerst die Konstante WP_UNINSTALL_PLUGIN, damit die Datei nicht von außen aufrufbar ist:

<?php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;

delete_option( 'mz_settings' );
delete_option( 'mz_db_version' );

global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mz_items" );

Daten beim Löschen zu entfernen ist die saubere Regel – für wertvolle Nutzerdaten bietet sich aber eine Einstellung „Daten beim Löschen behalten“ an, die uninstall.php berücksichtigt. Ein versehentliches Löschen darf nicht alle Merklisten vernichten.

Zum Selbermachen

  1. Registriere die Hooks und schreibe bei Aktivierung eine Option.
  2. Aktiviere und deaktiviere das Plugin und prüfe in der Datenbank (wp_options), was bleibt.
  3. Lösche das Plugin und prüfe, dass nichts zurückbleibt.

Sonderfall Multisite

In einer Multisite-Installation läuft der Aktivierungs-Hook bei einer Netzwerk-Aktivierung einmal für das Netzwerk, nicht automatisch für jede einzelne Website. Legt dein Plugin pro Website Tabellen oder Optionen an, muss der Code die Websites selbst durchlaufen oder die Einrichtung beim ersten Aufruf einer Website nachholen. Für kleine Plugins ohne eigene Tabellen ist das meist unproblematisch.

Typische Fehler

  • In der Aktivierung Ausgaben erzeugen (Leerzeichen, Meldungen) – das stört das Backend und löst Hinweise aus.
  • In der Deaktivierung Daten löschen, sodass ein kurzes Aus- und Wiedereinschalten alles vernichtet.
  • Aufräumcode nur in der Deaktivierung, aber nicht in uninstall.php.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt. Die Aussage, dass der Aktivierungs-Hook bei einem Plugin-Update nicht ausgeführt wird, ist Standardwissen ohne eigene Fundstelle.

Quellen

  • WordPress Plugin Handbook – „Activation / Deactivation Hooks“ und „Uninstall Methods“ (developer.wordpress.org/plugins/plugin-basics/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Funktionsreferenz zu register_uninstall_hook() (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Funktionsreferenzen zu wp_schedule_event() und wp_clear_scheduled_hook() (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Plugin-Entwicklung

Begriffe vorab

  • Plugin: ein Erweiterungspaket, das Funktionen hinzufügt, unabhängig vom Theme.
  • Präfix: ein eindeutiger Namensanfang für Funktionen und Optionen zur Vermeidung von Konflikten.
  • Autoload: Optionen, die bei jedem Seitenaufruf automatisch geladen werden.

Ein Plugin ist eine PHP-Datei (oder ein Ordner) in wp-content/plugins/ mit einem Header-Kommentar. Pflichtangabe ist der Plugin Name.

<?php
/**
 * Plugin Name: Mein Plugin
 * Description: Kurze Beschreibung.
 * Version: 1.0.0
 * Requires at least: 6.5
 * Requires PHP: 8.0
 * Text Domain: mein-plugin
 */

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

Die ABSPATH-Prüfung verhindert den direkten Aufruf der Datei außerhalb von WordPress.

Aktivierung, Deaktivierung, Deinstallation

  • register_activation_hook( __FILE__, 'callback' ): einmalige Einrichtung, etwa Tabellen anlegen.
  • register_deactivation_hook(): Aufräumen, zum Beispiel geplante Events entfernen.
  • uninstall.php oder register_uninstall_hook(): Daten beim Löschen entfernen.

Präfixe und Struktur

Funktionen, Klassen, Optionen und Hooks bekommen ein eindeutiges Präfix oder einen Namespace, um Konflikte mit anderen Plugins zu vermeiden. Bei größeren Plugins bewähren sich Klassen, eine Ordnerstruktur wie includes/, admin/, assets/ und Autoloading.

Shortcodes

add_shortcode( 'meinpraefix_hinweis', 'meinpraefix_hinweis_cb' );
function meinpraefix_hinweis_cb( $atts, $content = null ) {
	$atts = shortcode_atts( array( 'typ' => 'info' ), $atts );
	return '<div class="hinweis ' . esc_attr( $atts['typ'] ) . '">' . esc_html( $content ) . '</div>';
}

Ein Shortcode-Callback muss den Inhalt zurückgeben und darf nicht direkt ausgeben.

Optionen und Settings API

Einstellungen speicherst du mit der Options API (get_option(), update_option()). Für Admin-Seiten nutzt du die Settings API: register_setting(), add_settings_section() und add_settings_field(). Sie übernimmt Nonce-Prüfung, Speicherung und Sanitizing über einen Callback.

Menüseiten legst du mit add_menu_page() oder add_options_page() auf dem Hook admin_menu an.

Bei add_option() und update_option() ist Autoload standardmäßig aktiv. Große Datenmengen sollten mit Autoload false gespeichert werden, sonst wird jede Seite langsamer.

Ein Beispiel: das kleinste sinnvolle Plugin

Eine Datei mit Header-Kommentar, die einen Shortcode registriert, ist bereits ein vollständiges Plugin. Sie liegt in wp-content/plugins/, lässt sich im Backend aktivieren und überlebt Theme-Wechsel. Genau deshalb gehört Funktionalität, die unabhängig vom Aussehen gebraucht wird, in ein Plugin und nicht in die functions.php eines Themes.

Wachsende Plugins strukturieren

  • Code in Klassen und Ordner wie includes/ und admin/ aufteilen.
  • Übersetzbare Texte mit Text Domain versehen.
  • Beim Deinstallieren angelegte Daten aufräumen (uninstall.php).

Sicherheit gleich mitdenken

Jede Eingabe bereinigen, jede Ausgabe escapen, Formulare mit Nonce absichern und Rechte prüfen. Diese Regeln sind in der Sicherheitslektion erklärt und gehören von Anfang an in jedes Plugin, nicht erst nachträglich.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Plugin Handbook – „Plugin Basics“ und „Settings API“ (developer.wordpress.org/plugins/) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Funktionsreferenz zu register_activation_hook() (allgemeine Referenz, nicht Zeile für Zeile geprüft)

Datenmodell: Post Types, Taxonomien, Meta und WP_Query

Begriffe vorab

  • Post Type: Inhaltstyp, etwa Beitrag, Seite oder ein eigener Typ.
  • Taxonomie: Ordnungssystem für Inhalte, z. B. Kategorien oder Schlagwörter.
  • Term: ein einzelner Eintrag einer Taxonomie.
  • Meta: zusätzliche Felder zu einem Beitrag.

Inhalte in WordPress sind Posts eines bestimmten Post Types. Beiträge, Seiten und Anhänge sind eingebaut, eigene Typen ergänzt du mit register_post_type().

Custom Post Types

add_action( 'init', 'meinpraefix_register_book' );
function meinpraefix_register_book() {
	register_post_type( 'book', array(
		'labels'       => array( 'name' => __( 'Bücher', 'mein-plugin' ) ),
		'public'       => true,
		'has_archive'  => true,
		'show_in_rest' => true,
		'supports'     => array( 'title', 'editor', 'thumbnail', 'custom-fields' ),
	) );
}

Die Registrierung erfolgt auf init. Der Slug darf höchstens 20 Zeichen lang sein – eine feste Grenze durch die Spalte post_type in der Tabelle wp_posts, die als VARCHAR(20) angelegt ist; eine Überschreitung bricht die Registrierung mit einem Fehler ab. show_in_rest ist nötig, damit der Typ im Block-Editor und in der REST API verfügbar ist. Permalinks aktualisierst du nur einmalig bei Aktivierung mit flush_rewrite_rules(), nicht bei jedem Seitenaufruf.

Taxonomien

Mit register_taxonomy() ordnest du Inhalte. Hierarchische Taxonomien verhalten sich wie Kategorien, nicht hierarchische wie Schlagwörter.

Post Meta

update_post_meta( $post_id, 'isbn', sanitize_text_field( $isbn ) );
$isbn = get_post_meta( $post_id, 'isbn', true );

Der dritte Parameter true liefert einen einzelnen Wert statt eines Arrays. Mit register_post_meta() und show_in_rest machst du Meta-Felder für Block-Editor und REST API sichtbar.

WP_Query

$query = new WP_Query( array(
	'post_type'      => 'book',
	'posts_per_page' => 6,
	'meta_key'       => 'isbn',
	'orderby'        => 'date',
	'no_found_rows'  => true,
) );
if ( $query->have_posts() ) {
	while ( $query->have_posts() ) {
		$query->the_post();
		the_title();
	}
	wp_reset_postdata();
}
  • Nie query_posts() verwenden, es überschreibt die Main Query.
  • Die Main Query änderst du mit dem Hook pre_get_posts, wobei $query->is_main_query() geprüft wird.
  • no_found_rows => true spart die Zählabfrage, wenn keine Paginierung nötig ist.

Für Abfragen mit vielen Meta-Bedingungen lohnt sich oft eine Taxonomie, da diese deutlich besser indexiert wird.

Ein durchgängiges Beispiel

Für ein Rezeptportal legst du den Post Type recipe an, dazu eine hierarchische Taxonomie „Gericht“ (Vorspeise, Hauptgericht …) und Meta-Felder wie Kochzeit. Eine Abfrage mit WP_Query holt dann zum Beispiel alle Hauptgerichte unter 30 Minuten. Die Datenstruktur bleibt dabei sauber getrennt von der Darstellung im Theme.

Wann Meta, wann Taxonomie?

Meta eignet sich für einzelne Werte pro Beitrag (Preis, Kochzeit). Eine Taxonomie lohnt sich, wenn Inhalte nach einem Merkmal gruppiert, gefiltert und aufgelistet werden sollen. Taxonomien sind dafür besser indexiert; Abfragen über viele Meta-Felder werden bei großen Datenmengen langsam.

Gute Praxis bei Abfragen

  • Die Hauptabfrage über pre_get_posts ändern, nicht mit query_posts().
  • Nur benötigte Felder und Mengen laden.
  • Ergebnisse, die selten wechseln, zwischenspeichern.

Prüfstatus: Belegt (Stand 1. Oktober 2026): Jahreszahlen, Zahlen, Namen und Quellenangaben dieser Lektion, soweit die Quellenliste sie nennt, wurden gegen Primärquellen geprüft. Nicht einzeln belegt: erklärende Darstellung nach Lehrbuchstand und Quellen, die in der Liste als „allgemeine Referenz“ markiert sind. Codebeispiele sind nur auf Syntax geprüft, nicht in WordPress ausgeführt.

Quellen

  • WordPress Developer Resources – Funktionsreferenz zu register_post_type() (Longenscheidung des 20-Zeichen-Limits durch die Spalte post_type in wp_posts) (allgemeine Referenz, nicht Zeile für Zeile geprüft)
  • WordPress Developer Resources – Klassenreferenz zu WP_Query und der Hook pre_get_posts (allgemeine Referenz, nicht Zeile für Zeile geprüft)