LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Blog-Übersicht /WordPress-Plugins & Tricks/Tutorials / Ein dynamischer Gutenberg-Block mit block.json und render.php
Diesen Artikel in anderen Sprachen lesen:

Ein dynamischer Gutenberg-Block mit block.json und render.php

Ein dynamischer Gutenberg-Block mit block.json und render.php
Inhalt
  1. Warum PHP die Liste erzeugt
  2. 1. Gerüst und Build
  3. 2. Eine Beschreibung für Editor und Server
  4. 3. Den Block registrieren
  5. 4. Aus Einstellungen eine Beitragsliste bauen
  6. 5. Einstellungen und Vorschau im Editor
  7. 6. Speicherung, Cache und Prüfung
  8. Fragen und Antworten
  9. Quellen

Warum PHP die Liste erzeugt

Eine Liste neuer Beiträge kann sich ändern, obwohl der Beitrag mit der Liste unverändert bleibt. Ein dynamischer Block erzeugt dafür HTML auf dem Server. Das Beispiel speichert nur Rubrik und Anzahl; PHP sucht passende veröffentlichte Beiträge und baut daraus Links. Dynamische Blocks können auch Ersatz-HTML speichern. Hier ist bewusst kein Ersatz vorgesehen: Ohne aktives Plugin bleibt die Ausgabe leer.

1. Gerüst und Build

Das Beispiel verwendet feste Werkzeugversionen: create-block 4.99.0 und @wordpress/scripts 37.0.0. Für diese Kombination eignet sich beispielsweise Node.js 22.23.3; scripts 37 benötigt mindestens 22.22.2 innerhalb der 22er-Reihe. Die installierte Node-Version lässt sich mit node –version prüfen. Die folgenden Befehle erzeugen das Gerüst. Nach dem Ersetzen der Quelldateien folgt erneut npm run build.

Shell
npx @wordpress/create-block@4.99.0 category-posts --variant dynamic --namespace lw --title "Category Posts"
cd category-posts
npm install --save-dev --save-exact @wordpress/scripts@37.0.0
npm run build

Der Quellcode liegt unter src/category-posts/. Die folgenden Beispiele ersetzen block.json, index.js, edit.js, render.php sowie die Plugin-Hauptdatei. Die erzeugten SCSS-Dateien bleiben erhalten. view.js wird nicht eingebunden, weil block.json keinen viewScript-Eintrag enthält.

Text
category-posts/                 (abridged)
├── category-posts.php          plugin header and block registration
├── package.json                build/start scripts with --blocks-manifest
├── readme.txt
├── src/
│   └── category-posts/
│       ├── block.json
│       ├── index.js
│       ├── edit.js
│       ├── render.php
│       ├── view.js             not loaded by this example
│       ├── style.scss
│       └── editor.scss
└── build/                      written by npm start / npm run build
    ├── blocks-manifest.php
    └── category-posts/
        ├── block.json
        ├── index.js
        ├── index.asset.php
        ├── index.css
        ├── style-index.css
        └── render.php

Die beiden Skripte stehen im vorhandenen scripts-Abschnitt von package.json; die übrigen Paketangaben bleiben erhalten. –webpack-copy-php übernimmt PHP-Dateien, –blocks-manifest erzeugt die gemeinsame Metadaten-Datei. npm start baut bei Änderungen neu; npm run build erzeugt die Fassung für die Veröffentlichung.

JSON
{
	"scripts": {
		"build": "wp-scripts build --webpack-copy-php --blocks-manifest",
		"start": "wp-scripts start --webpack-copy-php --blocks-manifest"
	}
}

2. Eine Beschreibung für Editor und Server

block.json beschreibt Name, Attribute und Dateien. Attribute sind gespeicherte Einstellungen: categoryId enthält die Rubriken-ID, count die gewünschte Anzahl. Das Schema prüft den Datentyp; die erlaubten Zahlenbereiche prüft zusätzlich PHP. usesContext fordert die ID des umgebenden Beitrags an, damit die Liste diesen Beitrag auslassen kann.

JSON
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "lw/category-posts",
	"version": "0.1.0",
	"title": "Category Posts",
	"category": "widgets",
	"icon": "list-view",
	"description": "Lists the latest posts of one category, rendered on the server.",
	"attributes": {
		"categoryId": {
			"type": "integer",
			"default": 0
		},
		"count": {
			"type": "integer",
			"default": 5
		}
	},
	"usesContext": [ "postId" ],
	"supports": {
		"html": false,
		"align": [ "wide", "full" ],
		"color": {
			"background": true,
			"text": true,
			"link": true
		},
		"spacing": {
			"margin": true,
			"padding": true
		},
		"typography": {
			"fontSize": true
		}
	},
	"textdomain": "category-posts",
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"render": "file:./render.php"
}

apiVersion 3 ist die Block-API-Version. editorScript und editorStyle gelten für den Editor, style für Editor und Website. supports aktiviert Gestaltungsoptionen wie Farben und Abstände. Die dazugehörigen HTML-Attribute liefern später useBlockProps() und get_block_wrapper_attributes().

3. Den Block registrieren

Die Hauptdatei registriert die im Build-Manifest beschriebenen Blocks. Das Manifest ist eine erzeugte PHP-Datei mit Metadaten; dadurch entfällt das getrennte Einlesen jeder block.json. Für dieses Beispiel gilt WordPress ab 6.9.

PHP
<?php
/**
 * Plugin Name:       Category Posts
 * Description:       Server-rendered block that lists the latest posts of one category.
 * Version:           0.1.0
 * Requires at least: 6.9
 * Requires PHP:      7.4
 * Author:            Lukas Wojcik
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       category-posts
 *
 * @package Lw
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Registers every block listed in build/blocks-manifest.php.
 *
 * The manifest is generated by wp-scripts (--blocks-manifest), so WordPress
 * does not have to read and decode each block.json on every request.
 */
function lw_category_posts_block_init() {
	wp_register_block_types_from_metadata_collection(
		__DIR__ . '/build',
		__DIR__ . '/build/blocks-manifest.php'
	);
}
add_action( 'init', 'lw_category_posts_block_init' );

Für einen einzelnen Block ist alternativ die Registrierung über dessen Ordner möglich. Auch dieser Pfad zeigt auf build: Der Build-Schritt entfällt dadurch nicht. Die Alternative ersetzt die Registrierung im init-Handler und wird nicht zusätzlich ausgeführt.

PHP
// Single block, without a manifest: reads build/category-posts/block.json.
register_block_type( __DIR__ . '/build/category-posts' );

Eine Änderung unter src wirkt erst nach dem nächsten Build. Das gilt auch für render.php und block.json. Auf die Zielinstallation gehören die Plugin-Hauptdatei und der erzeugte build-Ordner.

4. Aus Einstellungen eine Beitragsliste bauen

WordPress stellt render.php drei Variablen bereit: $attributes mit den Einstellungen, $content mit gespeichertem innerem Inhalt und $block mit dem Block-Kontext. Im vorliegenden Beispiel bleibt $content leer. Die Datei erzeugt folgende Ausgabe:

PHP
<?php
/**
 * Server-side rendering of the lw/category-posts block.
 *
 * Variables provided by WordPress:
 *
 * @var array    $attributes Block attributes, defaults from block.json applied.
 * @var string   $content    Inner content (always empty for this block).
 * @var WP_Block $block      Block instance: name, context, parsed block.
 *
 * @package Lw
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

$lwcp_category_id = isset( $attributes['categoryId'] ) && is_int( $attributes['categoryId'] )
    ? $attributes['categoryId'] : 0;
$lwcp_count = isset( $attributes['count'] ) && is_int( $attributes['count'] )
    ? $attributes['count'] : 5;
if ( $lwcp_category_id < 1 ) {
    return;
}
if ( $lwcp_count < 1 || $lwcp_count > 10 ) {
    $lwcp_count = 5;
}
$lwcp_term        = $lwcp_category_id ? get_term( $lwcp_category_id, 'category' ) : null;

// Render nothing until a valid category has been chosen.
if ( ! $lwcp_term instanceof WP_Term ) {
	return;
}

// Do not list the post that contains the block (context from "usesContext").
$lwcp_current_id = isset( $block->context['postId'] ) ? absint( $block->context['postId'] ) : 0;

$lwcp_posts = get_posts(
	array(
		'category__in' => array( $lwcp_term->term_id ),
        'suppress_filters' => false,
		'numberposts'  => $lwcp_count,
		'post_status'  => 'publish',
		'has_password' => false,
		'exclude'      => $lwcp_current_id ? array( $lwcp_current_id ) : array(),
	)
);
?>
<div <?php // Core escapes the returned attribute values.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Core-generated wrapper attributes.
echo get_block_wrapper_attributes(); ?>>
	<?php if ( empty( $lwcp_posts ) ) : ?>
		<p><?php esc_html_e( 'No posts in this category yet.', 'category-posts' ); ?></p>
	<?php else : ?>
		<ul class="wp-block-lw-category-posts__list">
			<?php foreach ( $lwcp_posts as $lwcp_post ) : ?>
				<li>
					<a href="<?php echo esc_url( get_permalink( $lwcp_post ) ); ?>"><?php echo esc_html( get_the_title( $lwcp_post ) ); ?></a>
					<time datetime="<?php echo esc_attr( get_the_date( 'c', $lwcp_post ) ); ?>"><?php echo esc_html( get_the_date( '', $lwcp_post ) ); ?></time>
				</li>
			<?php endforeach; ?>
		</ul>
	<?php endif; ?>
</div>

Eine ungültige oder fehlende Rubrik ergibt keine Ausgabe. Bei einer Anzahl außerhalb von 1 bis 10 verwendet das Beispiel fünf Beiträge. category__in beschränkt die Suche auf die ausgewählte Rubrik; Unterrubriken werden nicht automatisch eingeschlossen. Passwortgeschützte und unveröffentlichte Beiträge bleiben ausgeschlossen.

suppress_filters steht ausdrücklich auf false, damit etwa Sprachfilter anderer Plugins wirken können. Das garantiert noch keine richtige Sprachzuordnung: Die konkrete Mehrsprachen-Erweiterung und deren Vorschau müssen gesondert geprüft werden.

Link, Titel und Datumswerte erhalten jeweils das passende Escaping. Dabei werden Zeichen so kodiert, dass Inhalte nicht als HTML oder Attribute ausgeführt werden. get_block_wrapper_attributes() liefert bereits von WordPress abgesicherte Attributwerte. Der begründete phpcs:ignore betrifft ausschließlich diese Ausgabe.

WordPress lädt render.php für jede Block-Instanz erneut. Unbedingte globale Funktions- oder Klassendeklarationen würden beim zweiten Laden kollidieren. Solche Hilfsdefinitionen gehören in die einmal geladene Plugin-Hauptdatei.

5. Einstellungen und Vorschau im Editor

index.js verbindet den Block mit edit.js. save gibt ausdrücklich null zurück: Gespeichert werden nur Kommentar und Attribute, kein fertiges Listen-HTML.

JavaScript
import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import metadata from './block.json';

// No fallback HTML: only the block comment and attributes are stored.
registerBlockType( metadata.name, {
	edit: Edit,
    save: () => null,
} );

edit.js lädt Rubrikennamen, zeigt eine Auswahl und begrenzt den Regler auf 1 bis 10. Ein Ladefehler erhält eine Meldung statt eines endlosen Ladezeichens. Die Abfrage lädt alle Rubriken; für sehr große Bestände ist eine Suche mit begrenzten Ergebnisseiten zweckmäßiger.

JavaScript
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import {
	Notice,
	PanelBody,
	Placeholder,
	RangeControl,
	SelectControl,
	Spinner,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { decodeEntities } from '@wordpress/html-entities';
import { ServerSideRender } from '@wordpress/server-side-render';
import metadata from './block.json';
import './editor.scss';

// Same query shape as the core Latest Posts block uses for its category list.
const CATEGORIES_QUERY = { per_page: -1, _fields: 'id,name', context: 'view' };

export default function Edit( { attributes, setAttributes } ) {
	const { categoryId, count } = attributes;
	const blockProps = useBlockProps();

    const { categories, categoryError } = useSelect( ( select ) => {
        const store = select( coreStore );
        const args = [ 'taxonomy', 'category', CATEGORIES_QUERY ];
        return {
            categories: store.getEntityRecords( ...args ),
            categoryError: store.getResolutionError( 'getEntityRecords', args ),
        };
    }, [] );

	const options = [
		{ value: '0', label: __( '— Select a category —', 'category-posts' ) },
		...( categories ?? [] ).map( ( term ) => ( {
			value: String( term.id ),
			label: decodeEntities( term.name ),
		} ) ),
	];

	return (
		<>
			<InspectorControls>
				<PanelBody title={ __( 'Settings', 'category-posts' ) }>
					{ categoryError ? (
                        <Notice status="error" isDismissible={ false }>
                            { __( 'Categories could not be loaded.', 'category-posts' ) }
                        </Notice>
                    ) : categories ? (
						<SelectControl
							__next40pxDefaultSize
							__nextHasNoMarginBottom
							label={ __( 'Category', 'category-posts' ) }
							value={ String( categoryId ) }
							options={ options }
							onChange={ ( value ) =>
								setAttributes( { categoryId: Number( value ) } )
							}
						/>
					) : (
						<Spinner />
					) }
					<RangeControl
						__next40pxDefaultSize
						__nextHasNoMarginBottom
						label={ __( 'Number of posts', 'category-posts' ) }
						value={ count }
						min={ 1 }
						max={ 10 }
						onChange={ ( value ) =>
							setAttributes( { count: value ?? 5 } )
						}
					/>
				</PanelBody>
			</InspectorControls>
			<div { ...blockProps }>
				{ categoryId ? (
					<ServerSideRender
						block={ metadata.name }
						attributes={ attributes }
						skipBlockSupportAttributes
					/>
				) : (
					<Placeholder
						icon="list-view"
						label={ __( 'Category Posts', 'category-posts' ) }
						instructions={ __(
							'A category selection in the block settings enables the preview.',
							'category-posts'
						) }
					/>
				) }
			</div>
		</>
	);
}

ServerSideRender sendet Einstellungen an die WordPress-REST-Schnittstelle und zeigt das Ergebnis von render.php. REST bezeichnet hier den HTTP-Zugang zu WordPress-Daten und Funktionen. Die Komponente kann Anfragen bündeln; eine Änderung bedeutet daher nicht zwingend genau eine Anfrage.

Die offizielle Dokumentation beschreibt ServerSideRender als Rückfalllösung für bestehenden PHP-Code. Für neue aufwendige Oberflächen eignet sich häufig eine Vorschau aus REST-Daten in JavaScript. Das Lehrbeispiel verwendet bewusst dieselbe PHP-Ausgabe für Vorschau und Website.

skipBlockSupportAttributes entfernt Gestaltungsattribute aus der Vorschau-Anfrage, weil der äußere Editor-Wrapper diese bereits erhält. Der innere PHP-Wrapper bleibt bestehen. Farben, Abstände und verschachtelte Elemente sind deshalb im Editor und auf der Website getrennt zu prüfen.

6. Speicherung, Cache und Prüfung

So sieht der gespeicherte Block-Kommentar mit Rubrik 7 und drei Beiträgen aus:

Text
<!-- wp:lw/category-posts {"categoryId":7,"count":3} /-->

Statische Blocks speichern üblicherweise HTML. Änderungen an save können bestehendes HTML ungültig machen, wenn das neue Ergebnis nicht mehr zur gespeicherten Struktur passt. Nicht jede Codeänderung löst diesen Fehler aus. Für ältere gültige Strukturen kann eine deprecated-Definition nötig sein.

Beim vorliegenden dynamischen Block entfällt diese Prüfung gespeicherten Listen-HTMLs. Eine geänderte render.php wirkt nach Build und Übernahme; vorhandene Seiten-Caches können trotzdem noch die alte Ausgabe liefern. Ebenso bedeutet ein PHP-Aufruf nicht zwingend eine neue Datenbankabfrage: Ein Objekt-Cache kann Abfrageergebnisse bereitstellen.

Der korrigierte Quellcode wurde am 26.10.2026 mit Node.js 22.23.3 und scripts 37.0.0 erfolgreich gebaut. Der Build allein bestätigt weder die Bedienbarkeit im Editor noch die Kompatibilität aller Themes oder Sprachplugins. Die Entwicklungsabhängigkeiten meldeten beim Audit bekannte Schwachstellen; vor einer produktiven Weiterverwendung sind aktualisierte Abhängigkeiten und erneute Prüfungen erforderlich. node_modules gehört nicht in das ausgelieferte Plugin.

Ein dynamischer Block speichert Einstellungen, gibt Beiträge mit PHP aus und benötigt Prüfungen im Editor und auf der Website. Caches können ältere Ausgaben behalten.
Ein dynamischer Block speichert Einstellungen, gibt Beiträge mit PHP aus und benötigt Prüfungen im Editor und auf der Website. Caches können ältere Ausgaben behalten.

Fragen und Antworten

Warum bleibt eine Änderung ohne Wirkung? Registriert und geladen werden die Dateien unter build. Erst ein neuer Build übernimmt Änderungen aus src; anschließend können Seiten-Caches eine Aktualisierung benötigen.

Darf render.php Hilfsfunktionen enthalten? Unbedingte globale Deklarationen sind ungeeignet, weil dieselbe Datei mehrfach geladen wird. Einmalig geladene Plugin-Dateien sind dafür der passende Ort.

Warum fehlen Beiträge aus einer Unterrubrik? category__in wählt hier gezielt die angegebene Rubrik. Eine Suche einschließlich Unterrubriken benötigt andere Abfrageargumente und eine entsprechend geänderte Beschreibung.

Quellen

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Kontakt

Eine Frage oder eine Idee für ein Webprojekt? Schreib mir kurz, worum es geht. Das Formular nutzt reCAPTCHA zum Schutz vor Spam.

Kommentar schreiben

Erfahrungen mit anderen Plugins oder Hosting-Umgebungen und Rückfragen zur Einrichtung sind hier willkommen.

Die E-Mail-Adresse wird nicht veröffentlicht. Pflichtfelder sind mit einem Stern versehen.