parisek / timber-kit
WordPress/Timber starter kit — StarterBase, Helpers, Resizer
Requires
- php: ^8.3
- ext-gd: *
- parisek/twig-attribute: ^1.0
- parisek/twig-common: ^1.0
- parisek/twig-typography: ^1.3
- spatie/image: ^3.8
- symfony/twig-bridge: ^5.4 || ^6.2 || ^7.0
- symfony/var-dumper: ^5.4 || ^6.2 || ^7.0
- timber/timber: ^2.0
- twig/string-extra: ^3.0
Requires (Dev)
- brain/monkey: ^2.7
- ergebnis/composer-normalize: ^2.52
- giorgiosironi/eris: ^1.1
- php-stubs/acf-pro-stubs: ^6.5
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
- szepeviktor/phpstan-wordpress: ^2.0
Suggests
- ext-imagick: Required for smart-crop feature in Resizer
This package is auto-updated.
Last update: 2026-08-31 11:09:08 UTC
README
WordPress/Timber starter kit — configurable base class, ACF helpers, image resizer, dev media proxy, WPForms config bridge, ACF block renderer, WPML Copy-field override.
Installation
composer require parisek/timber-kit
What's Included
StarterBase
Extends Timber\Site with dozens of configurable properties. Handles theme setup, Twig extensions, security hardening, Gutenberg blocks, media processing, and admin cleanup — all opt-in via boolean flags.
Helpers
Static methods for formatting ACF data into clean arrays for Twig templates:
formatImage(),formatFile(),formatVideo()— media formatting.formatVideo()derives thecodecs=part of thetypeattribute throughVideoCodecs::codecsString(), which sniffs the file rather than trusting the container extension — a browser skips a<source>whose codec string is wrong, so guessing it means a video that silently never plays.formatMenu()returns aMenuData, which behaves as its item list under every array-shaped operation — iteration,count(), index access, JSON encoding — while exposing the menu's own metadata as properties. That equivalence is what let the return value gain metadata without touching a single call site.formatFields(),fieldFormatter()— ACF field processingformatLink()— link/button formattingremapWpmlReference( $value, array $field, string $target_lang )— remaps an ACF reference field's id(s) to a target WPML language viawpml_object_id, with the element type resolved per ACF field type (image/file/gallery→ attachment,post_object/relationship/page_link→ post,taxonomy→ term; non-reference and non-numeric values pass through). Shared formatting-layer primitive thatWpmlBlockOverridedelegates to, reusable by any field formatterformatMenu( $menu_or_name )— navigation menus. Returns aMenuDataobject exposing the menu's own metadata (title,name,slug,description,id) alongsideitems, plus any ACF fields attached to thenav_menuterm — so a project can give a menu an icon, a colour or a visibility flag without a kit release. The object iterates, counts, indexes and JSON-encodes exactly as the plain item list it replaced, so existing{% for item in menu %}call sites need no change. An empty or missing menu returns a plain[], keeping{% if menu %}guards falsy. Note:id,title,name,slug,descriptionanditemsare reserved metadata keys — an ACF field on thenav_menuterm with one of those names is silently dropped in favour of the built-in valueformatTerms()— taxonomy termsformatLanguageSwitcher()— WPML language switcherresizeImage()— responsive image variantspagination()— pagination formattingreadTime()— estimated reading time in minutes (Unicode-aware word counting, image budget, WPML-aware per-language WPM)getLanguage()— normalized (lowercased, trimmed) language code for a post or the current request, with WPML per-post / site-wide /get_locale()fallbacks, in that order. Region/script subtags are preserved verbatim at every layer — WPML values (e.g.pt-br,zh-hans) and theget_locale()fallback alike (e.g.de_ch, not truncated tode) — so consumers that need the full dialect (per-language typography tables, hreflang) get it, and ones that only want the base language take the first two characters themselvesformatImageFrom( ?array $raw ): ?array— pure-core formatter extracted fromformatImage()'s associative-array branch. No WordPress dependencies, safe for unit / property tests; missing keys resolve tonullsilently,id/width/heightare cast toint|null, and the WordPress SVG-1px workaround is applied uniformlyformatAnnouncement( ?array $value )— announcement-bar ACF group (enabled,text,dates.date_from/date_toas date_picker "U" timestamps) → Twig/Alpine shape. Re-anchors midnight-UTC timestamps towp_timezone()day bounds (00:00:00 / 23:59:59) and returns millisecond timestamps; disabled or absent input yields['text' => '', 'date_from' => 0, 'date_to' => 0]relabelPostType( string $post_type, array $labels )— merge custom labels onto a registered post type (rename the built-inpostto Články/News/…). Applies immediately afterinit, otherwise defers toinitpriority 999; keeps the top-levellabelin sync withlabels.namehideTaxonomyMetaFields( string $taxonomy = 'category', array $fields = ['description', 'slug', 'parent'], bool $hide_columns = true )— hide taxonomy form fields (CSS on add/edit screens) and drop the matching list-table columns, for taxonomies where editors should only pick a name
Resizer
Image resizing via Spatie/Image. AVIF output, responsive variants with breakpoints, crop positions, and cache management. Exposed as a single polymorphic Twig filter, |resizer, that detects its argument shape and routes to one of two underlying methods.
Tuples mode (positional, variadic)
Caller passes the variant tuples directly, in order. Each tuple is [width, height, media-min-width, image_style, quality?] — same shape Resizer::resizer() consumes:
{{ component_picture({
image: item.image|resizer(
['960', '720', '1280', 'crop'],
['480', '360', '', 'crop'],
),
}) }}
Each returned entry's width and height describe the file that was written, not the values that were requested — they differ whenever the variant scales rather than crops, so ['1200', '', …] reports the derived height instead of 0. They are derived from the source dimensions, not measured, so nothing extra is read. A requested axis is exact; a derived one is an estimate, because Spatie's GD and Imagick drivers implement the step differently — a single step stays within a pixel, and the two-axis non-cropping path scales that disagreement. A source of unknown size yields 0 rather than a guess. Resizer::producedDimensions() exposes the same derivation.
Named-variant mode (associative)
A variant may name its values instead of ordering them. Both shapes work, and mix in one call:
{{ component_picture({
image: item.image|resizer(
['960', '720', '1280', 'crop'],
{ width: 480, height: 360, crop: 'top', quality: 82 },
),
}) }}
Recognised keys: width, height, media, image_style (or crop), quality, format. Anything omitted falls back to the same defaults the positional form uses.
format is reachable only this way — deliberately not a sixth positional slot. Use it when a consumer needs a specific encoding rather than the project default:
// og:image — scrapers read JPEG, PNG, GIF and WebP, but not the AVIF written by default. $variants = ( new Resizer() )->resizer( $image, [ [ 'width' => 1200, 'height' => 630, 'crop' => 'center', 'quality' => 95, 'format' => 'jpeg' ], ] );
An unrecognised format falls back to the request-wide one rather than throwing, so one bad variant cannot take down a page render.
SocialImage
The one image variant a link-preview scraper can use. Resizer writes AVIF by default and Facebook, LinkedIn and X do not read it, so an og:image cut through the resizer alone is a preview card with no image — and nothing looks broken until someone shares a link.
use Parisek\TimberKit\SocialImage; $preview = SocialImage::get( $image ); // 1200x630 JPEG, quality 85 $preview = SocialImage::get( $image, [ 'quality' => 95 ] ); if ( $preview ) { $url = $preview['src']; }
Returns null rather than something unusable: a caller falling back to its own default still has a working card, while a wrong or oversized image only looks like an answer. A variant is accepted only when it is the requested cut in a format scrapers read, which is also what rejects the untouched original (resizer() returns the source alone when it cannot process it).
Options: width, height, crop, quality, format — unknown keys are dropped, a format no scraper reads falls back to JPEG, and crop is restricted to the styles that cut to exact dimensions (center, crop, top, bottom, left, right). smart-crop is not among them: it degrades to a plain resize when the source is smaller than the target, while the resizer still reports the dimensions that were asked for, so the entry would claim a cut it did not make. SocialImage::spec() returns what would be cut, without touching an image.
Resolving a post's image
SocialImage::forPost( $post ) finds the image itself, from a map of post type to field:
// In the theme's Base class. protected array $social_image_fields = array( 'project' => 'hero_image', 'post' => array( 'lead_image', 'hero_image' ), // first usable cut wins );
A post type left out of the map falls back to its featured image. Every candidate is tried until one yields a usable cut, not until one merely looks like an image — resolving and cutting are separate steps, and an SVG or an undecodable format passes the first while failing the second. Fields are read through Helpers::formatFields(); timber_kit_social_image_post_fields overrides that reader for projects storing this outside ACF.
Wiring it to an SEO plugin
protected string|bool $social_image_bridge = true; // detect the active plugin protected string|bool $social_image_bridge = 'aioseo'; // or name it
The plugin keeps rendering its own tags; the bridge only supplies the image, and only when there is one — otherwise the plugin's own resolution stands, because a working card from the site default beats a wrong one.
Two separate claims worth keeping apart. Leaving the bridge off changes nothing on upgrade, since no hook is registered. Turning it on changes the tag — that is the point — and with an empty map it hands over the featured image, replacing whatever the plugin resolved. Fill the map for the post types that keep their hero elsewhere. true detects the SEO plugin active on the site — one per site is the norm, so naming it is configuration the package can derive. SocialImageBridge::supported() lists the keys if you would rather be explicit.
A post whose social image the editor chose by hand is left alone. AIOSEO's filter is named for the default image but fires at the end of resolution, so it also sees an explicit per-post choice; overwriting that would be the plugin equivalent of ignoring the editor, and silent, since the panel still shows their pick.
Both og:image and twitter:image are covered. Twitter resolves on a separate path with no filter of its own, so without that second hook the feature only half works and the rest has to be clicked together in the admin. With AIOSEO's "Use Data from Facebook Tab" enabled the Twitter tag already carries the Open Graph result, so the bridge leaves it alone rather than deciding twice.
Why it is needed for AIOSEO specifically: it resolves the OG image from one global source option plus a per-post override, with no per-post-type layer in between, so without this every post of a type shares one image.
Filters:
timber_kit_social_image_defaults— project-wide width/height/crop/quality/formattimber_kit_social_image_formats— the formats considered scraper-readable, for the day a platform adds AVIF
Orientation-aware mode (single map arg)
When the single argument is an associative array carrying at least one of landscape / portrait / square keys, the filter classifies the source image's aspect (±10 % tolerance band around 1:1, overridable via the timber_kit_resizer_aspect_tolerance WP filter) and dispatches the matching tuple set to the standard resize pipeline:
{{ component_picture({
image: item.image|resizer({
landscape: [['960', '720', '1280', 'crop'], ['480', '360', '', 'crop']],
portrait: [['720', '960', '1280', 'crop'], ['360', '480', '', 'crop']],
square: [['800', '800', '1280', 'crop'], ['400', '400', '', 'crop']],
}),
}) }}
Lets templates drop the inline image.width >= image.height branch.
Fallbacks. Missing-metadata / non-numeric / zero-dimension sources classify as landscape (preserves the historical wide-crop default for legacy assets). When the matched bucket has no tuples (empty array or absent key), the helper falls through to the landscape bucket; if that's also empty / absent, the source passes through unchanged rather than crashing with an empty <picture>.
Detection (how the two shapes coexist). The dispatch lives in Resizer::isOrientationMap(): a single arg that's an associative array with at least one recognised key flips into orientation mode. Tuples have integer keys (width / height / media / image_style / quality), so the two shapes can't realistically collide. PHP callers wanting the bucket without the resize step can call Resizer::classifyAspect() directly.
DevMediaProxy
Development-only media proxy for projects that do not keep wp-content/uploads synchronized locally. When TIMBERKIT_MEDIA_ORIGIN is configured, missing local media URLs are rewritten to the upstream origin for common WordPress media surfaces and Media Library payloads.
It also integrates with Resizer through the timber_kit_resizer_missing_source_variants filter, so missing local source images can fall back to already-generated remote variants before returning the original image URL.
GtmContainer
First-party Google Tag Manager loader for projects that need GTM to load and nothing else — no data layer, no ecommerce payload, no plugin-side event tracking. Containers are declared in the theme's Base (see Google Tag Manager under Configuration), keyed by language, and printed by the gtm_container() Twig function.
Off until configured: with no $gtm_containers, gtm_container() prints nothing and gtm_container_noscript() delegates to the GTM4WP plugin, so a project can adopt both call sites before it adopts the configuration. Sites that need GTM4WP's ecommerce data layer keep the plugin and leave the property empty.
See ADR 0005 for the rationale.
WPFormsConfigBridge
Bridges wp-config.php constants to entries of the wpforms_settings option, so per-environment values such as Cloudflare Turnstile test keys can be stored in environment config rather than the WordPress database.
A setting key turnstile-site-key is overridden by a constant WPFORMS_TURNSTILE_SITE_KEY (hyphens become underscores, the whole name uppercased). The bridge is activated automatically by StarterBase when WPForms is loaded.
BlockRenderer
Render callback for ACF Gutenberg blocks defined via block.json. Migrated from per-theme functions.php so projects derived from portadesign/wordpress-base carry one versioned source of truth instead of duplicating ~140 lines per theme.
Wire as block.json renderCallback:
{
"acf": {
"renderCallback": "Parisek\\TimberKit\\BlockRenderer::render"
}
}
Or call from a wrapper in your theme's functions.php for backwards-compatible block.json files:
function timber_block_render_callback( ...$args ): void { \Parisek\TimberKit\BlockRenderer::render( ...$args ); }
What it does:
- Resolves ACF block.json schema to a Twig template path
- Hydrates content via
Helpers::formatFields() - Two-tier cache: in-request memo for editor previews + external object cache (Redis with
flush_group) for the frontend, gated byhas_filter()(dynamic blocks skip frontend cache) - Detects asset-enqueueing side effects (CF7, WPForms, …) and skips cache writes for those blocks so forms keep working
- Skips frontend cache writes for the editor-only empty-block warning so anonymous visitors don't see warnings meant for editors
- Renders a
.block-editor-warningtemplate for empty blocks when a logged-in user views them — uses Gutenberg's native classes so the editor styles it without shipping CSS - Wraps inserter-library previews in a 16:9 aspect-ratio box for consistent thumbnails
- Skips the
block_<name>_contentfilter during inserter-library previews so example data isn't enriched with derived values that would distort thumbnails
The class is final with three public static methods: render(), isInserterPreview(), flushPostBlockCache().
Filters
Package-level filters (stable across versions, prefixed timber_kit/):
| Filter | Args | Purpose |
|---|---|---|
timber_kit/block_renderer/cache_key |
(string $key, array $cache_data, string $block_name) |
Override the cache key composition (e.g. add user role / segment to the variation vectors). Default: 'acf_block_' . md5(wp_json_encode($cache_data)) with $cache_data = [name, data, anchor, className, post_id, lang, paged]. |
timber_kit/block_renderer/use_cache |
(bool $enabled, string $block_name, array $attributes) |
Override the cache-enabled decision per block. Default: true when the block has no registered block_<name>_content filter and the site uses an external object cache with flush_group support. |
timber_kit/block_renderer/content_data |
`(?array $content_data, int | string $post_id, bool $is_preview, array $attributes)` |
timber_kit/block_renderer/context |
(array $context, string $block_name, bool $is_preview) |
Last-chance Twig context modification before Timber::compile() runs. |
timber_kit/block_renderer/empty_alert_html |
(string $html, string $block_name, array $attributes) |
Replace the empty-block warning HTML entirely. Themes can return their own Twig render here (see migration example below). |
Per-block legacy filters (preserved from the original timber_block_render_callback for backwards compatibility — <slug> is the block name with acf/ stripped and dashes converted to underscores, e.g. acf/article-featured → article_featured):
| Filter | Args | Purpose |
|---|---|---|
block_<slug>_content |
(array $content_data) |
Per-block content transform (legacy hook preserved for backwards compatibility). Skipped during inserter-library previews so example data isn't enriched with derived values that would distort thumbnails. |
block_<slug>_template |
(string $template_path, array $content_data) |
Per-block template path override (legacy hook). Runs in all modes including inserter previews. Default path: @component/<slug>/<slug>.twig. |
Twig template
empty-alert.twig is shipped under the @timber-kit/ Twig namespace, registered automatically by StarterBase at priority 20 (so theme paths under the same namespace take precedence). It uses Gutenberg's .block-editor-warning classes for native editor styling and exposes a stable .timber-kit-block-empty class + data-block attribute for theme overrides.
Cache invalidation
BlockRenderer::flushPostBlockCache($post_id) is the handler StarterBase wires to acf/save_post at priority 20. When ACF saves a post, the cache group acf_block_{$post_id} is flushed — invalidating exactly the cached blocks tied to that post without touching others. The handler guards against non-numeric ids (ACF options-page strings, opaque block_* ids) and against environments without wp_cache_supports('flush_group').
Cross-request caches
CacheSignature::shared() composes the part of a cache key every cross-request cache needs: site, language, the current user's roles, and a content version. Callers append only what is specific to them.
Roles rather than user id: role is the axis plugins gate menus on, and it keeps the stored variants to the number of roles instead of the number of accounts. The content version is wp_cache_get_last_changed() over posts and terms — WordPress bumps those itself, so a saved post or an edited term changes the key instead of needing a hook to notice. Nothing has to be flushed, and nothing can be forgotten.
It is deliberately not memoized. switch_to_blog(), a user switch and a save inside a long-running process all move an input mid-request, and a memoized signature would key the second site's menu under the first site's name — found, silently, because term ids collide across sites.
CacheSignature::isAvailable() is false without a persistent object cache, and callers skip both the read and the write rather than always miss.
MenuFieldsCache stores the half of a menu that does not depend on the page. The ACF fields on each item and on the menu itself are the same on every URL and are cached; is_active and in_active_trail are not, and the walk recomputes them every request. Cache the whole menu instead and the highlighted item freezes on whatever page filled the entry — a wrong highlight on every page but one, which no status check can see. The split also keeps it to one entry per menu rather than one per page. Measured against a real Redis object cache on the sloneek front page — 90 items across five menus — the front page goes 402 ms to 344-350 ms and /blog/ 907 ms to 836-857 ms, over two back-to-back runs. Rendered HTML is byte-identical once non-deterministic ids are normalized.
It stores only what it can prove is storable, and the proof runs before the work rather than after it. Formatting expands shortcodes and runs a field_formatter_{$type} filter, either of which may read the global post or the current query, so the walk asks what the item stores rather than what one render produced. Watching the render is not a proof of purity: an unregistered [foo] comes back byte-identical, reads as static, and the literal is stored — until a plugin registers that shortcode and every page serves the frozen source text.
Three surfaces, three answers. A menu item whose raw meta holds no [ cannot reach do_shortcode(), so its fields are a function of what it stores; the test reads meta wp_get_nav_menu_items() has already primed. One registered field_formatter_* callback refuses every menu, because no static proof of its purity exists — a default, not a verdict, so a project that knows better says so through timber_kit_cache_menu_fields. A rendered Contact Form 7 or WPForms embed carries a nonce and is dynamic whatever is stored, so that one surface stays counted during the build. Objects, resources and closures are rejected outright. One unstorable slot condemns the whole menu, rather than storing a payload with a hole that would be replayed as complete.
Entries carry a lifetime (timber_kit_menu_fields_ttl, 12 h by default). The key already versions content, so the lifetime is not about staleness — it bounds the generations each content change orphans, which nothing else deletes.
timber_kit_cache_menu_fields turns the whole path off. Helpers::flushMenuFields() resets the in-flight assembly state for WP-CLI, workers and tests; stored entries need no flushing.
Helpers::flushResolvedPostIds() is the same shape for a different cache. Helpers::urlToPostId() memoizes its answer on a static keyed by blog, language and URL, because under WPML the lookup is a get_page_by_path() query and the same menu and options-page links are resolved once per place they appear. StarterBase wires the flush to clean_post_cache — the hook that fires exactly when a permalink can have moved.
A web request never needs to call it: the process ends before a permalink can change. A long-running process does. WP-CLI commands and persistent workers run many units of work in one process, so a command that renames a slug and then formats a link to it would otherwise be handed the id resolved before the rename. Call Helpers::flushResolvedPostIds() from any such caller that does not boot StarterBase, and from tests — a static outlives the test that filled it, so a test touching urlToPostId() must reset it or be answered by an earlier test's mocks.
In-process memos
Two capability probes are memoized on statics, because both answer a question that is expensive to compute and cheap to store.
Resizer::probeBackendFormats() asks the active ImageMagick or GD build which formats it can decode. The answer is a property of the build. It used to be probed once per Resizer instance and one |resizer call builds one instance, so a page resizing 320 images built 320 Imagick objects to ask the same question. The memo is keyed by concrete class, so a subclass that stubs the probe cannot answer for the base class. Resizer::flushBackendFormats() drops it.
Helpers::getFieldObjectsByScreen() asks ACF which field groups match a screen. ACF caches nothing here — every call walks each registered field group and evaluates its location rules, 8-10 ms against 96 groups whether or not the screen repeats. Answers are memoized per screen; Helpers::flushFieldGroups() drops them, and StarterBase wires it to acf/update_field_group, acf/delete_field_group, acf/trash_field_group and acf/untrash_field_group. ACF fires all four dynamically as acf/{$verb}_{$hook_name}, which is why a literal search for them finds nothing.
formatFields() asks once per nav menu item, and those answers are shared — automatically, with nothing to configure. On a 90-item menu it takes 96 lookups down to 11, most of the saving.
Sharing is safe while ACF's own location types are the only things that see the screen: ACF_Location_Nav_Menu_Item::match() reads the item id only to confirm the key is set, then matches on nav_menu. It is not safe in general — acf_register_location_type() is public API, ACF hands the whole screen to every registered type, and a "show this group on this one menu item" rule for a mega-menu is a legitimate thing to write. Sharing would then serve the first item's groups to every item, with no error and no log.
So the kit checks rather than guesses: before sharing, every registered location type must resolve to a class file inside ACF_PATH. A site that registers its own gets the per-item lookups back automatically. Anything unverifiable — no ACF_PATH, an empty registry, a class with no file — counts as unsafe.
The check cannot see a callback on acf/location/rule_match or acf/location/match_rule, which also receives the screen. ACFML's reads post_id, lang and page_parent, so it is fine; a site whose own callback reads the item id sets timber_kit_share_nav_menu_item_field_groups to false. The same filter forces sharing on for a custom location type known not to read the id.
A web request never needs either flush. A long-running process does: WP-CLI commands and persistent workers run many units of work in one process, so a command that saves a field group and then formats fields would otherwise be handed the list read before the save. Call the flush from any such caller that does not boot StarterBase, and from tests — a static outlives the test that filled it.
The memo freezes the first answer until something flushes it, and acf_get_field_groups() is not a pure function of the screen: a late acf_add_local_field_group(), an acf/load_field_groups callback, or a location-match filter reading mutable state can all change the answer mid-process. The four hooks above cover the admin edit; anything else must flush for itself. A screen wp_json_encode() cannot encode is never memoized, because casting false to a string would collapse every such screen onto one key.
These are in-process memos, not a persistent cache. They remove repeated work inside one render; they do not remove the first call of each request. Moving either into the object cache is a separate decision, and its cost is invalidation rather than implementation: ACF field groups load from theme JSON files and no hook fires when a deploy changes one. A persistent cache would also still want a static in front of it, because wp_cache_get() is not free.
Deriving a value from a post body
$post->content() is not an accessor. It ends in apply_filters( 'the_content' ), and do_blocks() sits on that filter at priority 9 — so asking for it renders the whole article. Ask for it to derive a word count, a reading time, a length or a "does this have an image" flag, and each of those cheap answers costs one full render.
Measured on a nine-teaser blog listing: 469-644 ms of an 897 ms page, building 306 kB of HTML to keep nine integers. The same nine counted from the raw post_content cost 0.8 ms, and the page went 0.868 s to 0.354 s with byte-identical output.
Timber hides this from the obvious check. content() memoizes into $this->___content, so a second call on the same post object is free. Look for a repeated call, find none, and conclude the cost is elsewhere — but the cost is the first call, once per post, and a listing has one object per row. A profiler hides it a second way: the time lands under WP_Hook->apply_filters, not under the theme code that asked.
The rule is the direction of the dependency. Render when the render is what you display. Read post_content when you derive from the text. Markup is not in the way of a text measure — a word counter strips it, and block delimiters are HTML comments that fall with the tags.
The two inputs do not always agree, and the rendered one is not automatically the more correct. the_content rewrites e-mail to use a non-breaking hyphen (U+2011); a word-boundary pattern that admits an ASCII hyphen inside a word but not that one then counts every hyphenated word twice. Across 1583 published articles, 1579 counted the same either way; of the four that differed, two held 52 hyphenated words and two sat exactly on a rounding boundary. The raw field is what the author wrote.
Where a rendered body genuinely is the product — an article page, an excerpt of formatted HTML — the render is not waste and the question becomes whether to cache it. That is a different decision with a different cost, and Cross-request caches above is the shape it takes.
Site Health board
Opt-in check-list of Porta recommended settings surfaced in Tools → Site Health
($site_health flag, default false). Read-only by design: the board verifies
the real, effective state of each recommendation — it never writes anything,
has no options page, and its "Actions" hints point to code fixes. The expected
state lives versioned in code; Site Health only reports drift.
Each check declares its verification method: effect (probe the real outcome,
plugin-agnostic — survives plugin swaps), config (read stored config when
there is no observable effect), or both. Seed set (security): XML-RPC
disabled, WP version hidden, author sitemap disabled, file editing disabled,
REST users endpoint restricted (anonymous loopback probe).
Customize in the project Base class — conscious exceptions stay visible in
code review:
protected bool $site_health = true; protected function health_checks( array $checks ): array { unset( $checks['rest_users_restricted'] ); // host blocks loopbacks — verified at the edge instead $checks['my_check'] = new MyCheck(); // implements Parisek\TimberKit\Health\HealthCheck return $checks; }
The timber_kit_health_checks filter runs after the override for mu-plugin /
per-environment tweaks. Custom checks implement Health\HealthCheck (id,
label, category, method, run(): Result) and return
Result::good() / Result::recommended() / Result::critical().
utf8mb4 charset audit + conversion
The utf8mb4_tables check (category database) audits every prefix-scoped
table via information_schema: non-utf8mb4 tables (plugin tables keep their
install-time charset forever), column-collation overrides, and mixed utf8mb4
collations — the classic source of Illegal mix of collations errors and
silent ? degradation of 4-byte characters (emoji, some CJK).
Remediation is a separate, explicit WP-CLI command that never converts
implicitly — --apply requires selecting concrete tables:
wp timber-kit convert-utf8mb4 # dry-run plan wp timber-kit convert-utf8mb4 --apply --tables=wp_foo,wp_bar # convert exactly these wp timber-kit convert-utf8mb4 --apply --all # conscious full convert
The target collation is the dominant utf8mb4 collation already present in
the database (majority vote, tie-break toward core tables; --collate=
overrides). Tables with COMPACT/REDUNDANT row formats and long indexed
columns are flagged (767-byte index-prefix limit) and require --force.
Seo
Everything the kit knows about an SEO plugin lives under src/Seo/
(Plugin, Canonical, PagedRequest, Pagination, Yoast, Aioseo) —
enforced by a boundary test, tests/Unit/Architecture/SeoBoundaryTest.php, the
same way src/Breeze/ is enforced by BreezeBoundaryTest.
Plugin::detect() picks one plugin when more than one is loaded (AIOSEO wins
— it is the migration target) and is used by both capabilities below, so the
two never disagree about which plugin is running.
| Capability | Yoast | AIOSEO |
|---|---|---|
| canonical | ✅ | ✅ |
| title | n/a — plugin retiring | not yet |
| og:image | n/a — plugin retiring | ✅ (SocialImageBridge) |
| sitemap path | ✅ (Breeze\WarmupSitemap) |
✅ (Breeze\WarmupSitemap) |
n/a — plugin retiring is a decision, not an omission: Yoast is the plugin
being migrated away from on this fleet, so writing its title adapter would be
work invested in a dependency on its way out. AIOSEO carries canonical only
for the matching reason on the other side — its title support is real but not
yet written.
Self-referencing canonical on paginated routes
A listing rendered by a block on an ordinary page is singular as far as an SEO
plugin is concerned — it sees one post, not an archive — so the plugin
resolves the canonical to get_permalink() and every page of the listing
claims to be page one. Canonical::register() fixes this by putting the
pagination segment back onto the canonical it hands to whichever plugin is
active, reading the segment itself (page, or a site's own
$wp_rewrite->pagination_base) rather than assuming it.
A manual canonical — Yoast's and AIOSEO's editors both offer the field — is
left alone whenever it points somewhere other than the current request:
pagination is only appended when the canonical, with any existing pagination
stripped, still describes the page being served. An editor canonicalising
/blog/ to /campaign/ keeps every page of the listing pointing at
/campaign/, unpaginated.
Gated by $seo_canonical_pagination on Base extends StarterBase, default
true — a deliberate, approved exception to this package's own default-off
rule for new behaviour (AGENTS.md § Feature flags & breaking changes). It is
safe on: a site whose listings are real post-type archives already gets a
correct canonical from its plugin, and re-deriving it from that same canonical
is idempotent, so the filter is a no-op there. Turn it off only for a site
that needs the old, unfiltered behaviour:
protected bool $seo_canonical_pagination = false;
WpmlBlockOverride
Runtime override of Copy field values in ACF Gutenberg blocks for WPML-multilingual sites. Hooks render_block_data at priority 20 (after WPML's own handlers) and, for ACF blocks rendered in a non-default language, overwrites attrs.data.<field> for fields marked wpml_cf_preferences = 1 (Copy) with the source-language post's value. Attachment IDs (image / file / gallery) are remapped to per-language duplicates via wpml_object_id.
Solves the long-standing WPML problem where changing a Copy field (typically an image) in the source language never propagates to translated post_content without a manual ATE re-job. ACF configuration becomes the single source of truth for Copy fields — no DB writes, no admin UI, no drift.
Enable it with the $wpml_block_override flag on your Base extends StarterBase — opt-in (default off) because it changes rendered output. Set it before parent::__construct():
class Base extends StarterBase { public function __construct() { $this->wpml_block_override = true; parent::__construct(); } }
StarterBase then hooks WpmlBlockOverride::register() on init when the flag is on. register() self-guards on WPML + ACF Pro, so it no-ops where they're absent. If you don't extend StarterBase, call it yourself:
add_action( 'init', static function (): void { if ( class_exists( \Parisek\TimberKit\WpmlBlockOverride::class ) ) { \Parisek\TimberKit\WpmlBlockOverride::register(); } } );
Requirements (verified at register()):
- WPML active (
ICL_SITEPRESS_VERSIONdefined) - ACF Pro active (
acf_get_field_groupsavailable)
What it does
-
Bypasses non-ACF blocks, admin context, REST requests, and the default language
-
Walks ACF field definitions recursively to find every leaf marked
wpml_cf_preferences = 1— top-level, plus nested inside repeater / group containers at arbitrary depth -
Generates ACF's flattened block-data key pattern for each Copy field (
items_N_image,faq_sections_N_items_M_title, …) and overrides each from source -
Remaps reference ids to their target-language equivalents via the shared
Helpers::remapWpmlReference()primitive (so this and the field formatters resolve translated entities the same way), so a translated page points at translated entities — not the source-language ones:ACF field type Remapped as Notes image,file,galleryattachment post_object,relationship,page_linkpost element type resolved per id via get_post_type()(apage_linkholding a raw URL passes through)taxonomyterm element type is the field's taxonomyuser,link, scalar fields— not remapped ( user: WPML doesn't translate users;link: URL handled by WPML's own link conversion) -
Caches the full block-name → copy-fields index as a single transient with per-request memo
-
Skips the persistent transient entirely under
WP_DEBUGso dev iteration doesn't need manual invalidation -
Emits diagnostic
error_loglines ([timber_kit/wpml_block_override] …) underWP_DEBUGfor override events and missing source-block matches
Filters
| Filter | Args | Purpose |
|---|---|---|
timber_kit/wpml_block_override/should_override |
(bool $default, array $block, string $current_lang, string $default_lang) |
Per-block veto. Default true after non-ACF / admin / REST / default-language guards have passed. |
timber_kit/wpml_block_override/copy_fields |
(array $copy_fields, string $block_name) |
Extend or trim the Copy-field discovery for a block. $block_name is the short name (no acf/ prefix). $copy_fields shape: [ ['field' => array, 'path' => array<int, array{name,type}>], … ]. |
Note the two filters receive the block name differently: should_override gets the full parsed block ($block['blockName'] is acf/foo), while copy_fields gets the short name (foo).
should_override and duplicate blocks. The veto runs before positional pairing, so it must be deterministic per block name, not per instance. If a page has 2+ blocks of the same name and you veto only some instances, the surviving ones' ordinals shift and pair with the wrong source block (silently applying a sibling's Copy value). Decide per block type, as the examples below do — never per individual occurrence.
Disabling / opting out
Per project — the simplest opt-out is to not call register() from the theme. To force it off at runtime even where register() already ran (e.g. a shared bootstrap), veto every block:
add_filter( 'timber_kit/wpml_block_override/should_override', '__return_false' );
Per block — skip specific block types via should_override (full acf/ name here):
add_filter( 'timber_kit/wpml_block_override/should_override', function ( $enabled, $block ) { $off = [ 'acf/hero-text', 'acf/booking-form' ]; return in_array( $block['blockName'] ?? '', $off, true ) ? false : $enabled; }, 10, 2 );
Per field — keep the block syncing but drop one field from the Copy set via copy_fields (short block name here; the returned list is re-normalized, so re-indexing isn't required):
add_filter( 'timber_kit/wpml_block_override/copy_fields', function ( $copy_fields, $block_name ) { if ( $block_name !== 'jumbotron-video' ) { return $copy_fields; } return array_values( array_filter( $copy_fields, fn ( $entry ) => $entry['field']['name'] !== 'background_image' ) ); }, 10, 2 );
Not supported (this iteration)
flexible_contentsub-fields — per-layoutsub_fieldsrequire layout-name awareness- REST API output —
render_block_datadoesn't fire for raw REST responses; out of scope for server-rendered themes
Known limitations
Stale cache on programmatic field registration. Cache invalidation hooks (acf/update_field_group + save_post_acf-field-group) do not fire for programmatic field registration via acf_add_local_field_group(). Code-only changes to wpml_cf_preferences will serve stale cache for up to 24 hours on production. Under WP_DEBUG the persistent transient is bypassed entirely so dev iteration is unaffected. Production workaround: wp transient delete timber_kit_wpml_copy_fields_index in the deploy script, or include a theme-version constant in the cache key.
Reordered duplicate blocks / rows. Both same-named blocks and a repeater's rows within a matched block are paired by position, relying on source and translation sharing the same order and count. Add/remove is guarded at both levels — if the counts of a block name differ, that name is skipped; if a repeater's row count differs between source and translation, that nested field is skipped (no-op). The one unguarded case is an equal-count manual swap: a translation edited independently (not through ATE, which rebuilds from the source and preserves order) where two same-named blocks — or two rows of the same repeater — are reordered without changing the count. Positional matching would then apply one instance's Copy value to the other. There is no stable per-instance id in post_content to detect this, and the blast radius is bounded — a Copy value from a sibling of the same type, read-time only (no DB writes). If you reorder duplicate blocks or rows in a translation independently, re-run it through the WPML translation editor to restore source order.
ACFML preference sync
WPML packs custom fields into translation jobs by exact meta-key lookup
against one global dictionary (custom_fields_translation in
icl_sitepress_settings). ACFML fills that dictionary only event-driven — on
admin field-group save (never fires for JSON-only groups) or on value save
through the ACF pipeline (the only producer of indexed keys like
blocks_0_items_1_title). ACF meta written programmatically (importers,
WPML post duplication, direct update_post_meta()) therefore never gets
dictionary entries and is silently excluded from translation jobs, even when
every field declares a correct wpml_cf_preferences in its JSON definition.
wp timber-kit acfml-sync-preferences reconciles the dictionary with the
code-defined truth: it walks existing postmeta, resolves each key's field
definition via the _<key> field-key companion, and registers the exact
key with the definition's preference — the same result a manual admin re-save
of every post would produce. Intended as a deploy step after
wp timber-kit updates.
wp timber-kit acfml-sync-preferences # dry-run report wp timber-kit acfml-sync-preferences --apply # write the entries wp timber-kit acfml-sync-preferences --apply --post_type=room_type
Dry-run by default; idempotent (a second run writes nothing); patch-only merge
(never rebuilds or prunes the dictionary, existing _<key> companion entries
are never overwritten). Keys resolving to different preferences across
posts are reported as conflicts and skipped — never guessed. Scope is postmeta
of the current site; on multisite run per-site via wp --url=….
Applying newly-translatable keys triggers WPML's ProcessNewTranslatableFields background task — affected translations get flagged as needing update, which is the point: translators see the previously invisible backlog.
Command-line
Every command StarterBase registers, in one place. A command missing from this
table is visibly missing; a command missing from prose is not, which is how two
of these went undocumented for several releases.
| Command | What it does |
|---|---|
wp timber-kit updates |
Runs pending block-data migrations. See § Block data migrations. |
wp timber-kit prune-originals |
Deletes preserved full-resolution originals of -scaled images. See § Media processing. |
wp timber-kit svg-dimensions |
Derives and stores intrinsic width/height for SVG attachments that have none. See § SVG dimensions. |
wp timber-kit convert-utf8mb4 |
Converts legacy utf8 tables and columns to utf8mb4. |
wp timber-kit acfml-sync-preferences |
Reconciles WPML translation preferences for programmatically written ACF meta. See § ACFML preference sync. |
wp timber-kit wpml-cleanup-theme-domain |
Purges WPML String Translation rows and compiled files left behind for a text domain that is no longer registered with ST. See § WPML theme-domain cleanup. |
wp timber-kit outage-screen |
Installs the drop-ins that serve the theme's prerendered outage screen. See § Outage screen. |
Outage screen
WordPress shows a fallback screen in three states, and in none of them can the theme render one:
| State | Entry point | Status | What is alive |
|---|---|---|---|
A .maintenance file in the site root — written by wp maintenance-mode activate, and by core itself during every core and plugin update |
wp_maintenance() in wp-settings.php, before plugins and theme |
503 + Retry-After |
PHP and the filesystem |
| The database is unreachable | wpdb::dead_db() |
503 + Retry-After |
PHP and the filesystem. No options, no translations |
| A fatal PHP error | WP_Fatal_Error_Handler::display_error_template() |
500, no Retry-After |
A crashed WordPress — which the drop-in still does not use |
All three require_once a drop-in in wp-content/, and none sends a status
header first. This command generates those three files:
wp timber-kit outage-screen install # write maintenance.php + db-error.php + php-error.php wp timber-kit outage-screen status # installed / stale / not ours / absent, and is the screen there wp timber-kit outage-screen remove # take back only the files it generated
Why the fatal-error state carries a different status
503 means a planned, bounded outage, and monitoring reads it that way — some
of it suppresses alerts on a 503 that carries Retry-After. A crash is
neither planned nor bounded, and the one thing it must not do is look routine.
Retry-After is dropped for the same reason: nobody knows when a crash clears,
so the header would be a guess presented as a promise.
The three states cannot collide. WP_Fatal_Error_Handler::handle() returns
immediately while wp_is_maintenance_mode() holds, so an update always serves
maintenance.php and never a 500.
The recovery e-mail survives
php-error.php replaces the error template, not the handler. Core sends the
recovery-mode mail in handle() before it reaches the template, so the drop-in
cannot cost an administrator the way back in. Replacing
wp-content/fatal-error-handler.php would be the change that could — this is
not that change.
The generated files send their status + no-cache headers and
readfile() the screen the theme rendered ahead of time — with
parisek/styleguide >= 1.13's vendor/bin/styleguide maintenance:render, which
writes static/templates/component/maintenance/maintenance.html. This package
serves the file; it does not produce it.
Two properties of the generated files are load-bearing, which is why they are generated rather than described in a wiki somewhere:
- They depend on nothing — no Composer autoloader, no WordPress function
beyond the
WP_CONTENT_DIRconstant, no database. For two of the three that is all there is.php-error.phpruns inside a loaded WordPress, where a WP function call would appear to work, and holds to the same rule anyway: an outage screen may not depend on the thing that just crashed. - They never return early. WordPress prints nothing of its own after the
require_once, so a drop-in that returns when the screen is missing serves a blank page. Every path prints something; without the theme's screen, one plain sentence.
install is idempotent, writes atomically (a live request can be reading the
file — reinstalling during an active outage is normal), and refuses to touch a
drop-in it did not generate.
Multisite: wp-content/ is shared across the network while the theme is
per-site, and no drop-in can resolve the current site — db-error.php has no
database to ask. Every site therefore serves the screen of whichever site the
command ran against, and install says so rather than leaving it to be found
during an outage.
The 10-minute expiry. WordPress ignores .maintenance once its timestamp is
600 seconds old. That cannot be extended from a drop-in or from the
enable_maintenance_mode filter — wp_is_maintenance_mode() checks the expiry
before both, and the filter can only turn maintenance mode off. The only lever
is the timestamp itself, which is the deploy script's business (wordpress-base
writes it forward-dated).
WPML theme-domain cleanup
Once a project runs with $wpml_theme_domain_authoritative on — the default —
WPML stops registering the theme's own strings with String Translation, and the
.mo files the theme ships become the single source. Rows registered before
that switch stay behind, and a stale ST row can still win at runtime.
wp timber-kit wpml-cleanup-theme-domain # dry-run report wp timber-kit wpml-cleanup-theme-domain --apply # delete the rows and compiled files
Cache warm-up (Breeze)
Everything in this package that knows about Breeze lives under src/Breeze/
(Parisek\TimberKit\Breeze). A project that does not run the plugin can treat
that whole directory as dead weight, and a test in the suite fails the build if
Breeze is named anywhere else under src/ — the boundary is enforced, not just
documented. The one exception is StarterBase, which keeps the opt-in flags.
The class was called BreezeWarmupSitemap before the move; the old name still
resolves through an alias.
Breeze\WarmupSitemap feeds Breeze's Cache Warmup preloader with every URL from
the site's XML sitemap via the breeze_preload_urls filter.
Breeze 2.5 re-warms the cache after a full purge, but its own URL sources are the homepage, a few auto-detected pages, and a manual list capped at 30 entries — so on a real site most pages are rebuilt by the first visitor to ask for them. The class discovers the sitemap, follows a sitemap index recursively within bounded limits, and merges the result in.
Which sitemap it asks for depends on what the project runs, checked in this order — the first provider that answers wins:
| Provider | Detected by | Root |
|---|---|---|
| AIOSEO | aioseo() / \AIOSEO\Plugin\AIOSEO |
/sitemap.xml |
| Yoast SEO | WPSEO_VERSION / \WPSEO_Sitemaps, and its XML-sitemap switch on |
/sitemap_index.xml |
| WordPress core | nothing else answered | /wp-sitemap.xml |
Each plugin is recognised by a symbol it defines, never by the active-plugin list: a must-use load, a renamed directory or a bundled copy all keep the symbol and lose the list entry.
Yoast additionally has to be serving a sitemap. It carries a switch that
turns its XML sitemap off, and when it is off core serves /wp-sitemap.xml
again — so a site in that state answers on the core path and 404s on Yoast's.
A symbol proves the plugin is loaded; it does not prove the plugin does the
thing being asked about.
When the list is empty, Site Health says so. With $breeze_warmup_sitemap
on, the warmup_sitemap_resolved check reports a stored-but-empty URL list as
critical. Everything upstream of it degrades quietly on purpose — an empty
sitemap result is a normal return value and the refresh job swallows
throwables by contract, because a sitemap outage must never surface as a fatal
in cron. That is right for the purge path and useless to an administrator, and
this check is the one place the silence is broken. A list that has simply not
been built yet counts as good; the first purge after activation schedules it.
Yoast is named rather than left to the core fallback, and that is not a
convenience. Yoast redirects /wp-sitemap.xml to its own index with a 301, and
every fetch here sends redirection => 0 on purpose — following a redirect is
the SSRF surface this module closes. So on a Yoast site the core path does not
degrade to a slower answer, it degrades to no answer: the response code
lands outside 200-299 and the body is dropped. The refresh then stores an empty
list and says nothing, because an empty result is a normal return value and the
cron job swallows throwables by contract.
timberkit_warmup_sitemap_url overrides the resolved address — for a
provider this list does not know yet, or a site serving its sitemap from a
non-default path. It receives the resolved URL and the detected provider key:
add_filter( 'timberkit_warmup_sitemap_url', function ( string $url, string $provider ): string { return 'https://example.com/custom-sitemap.xml'; }, 10, 2 );
A returned value that is not a string, or a URL on another host, is ignored and the detected path is used instead. The same-host guard would reject it at fetch time anyway; refusing it here keeps the guard and still warms the site, rather than silently warming nothing because one callback was wrong.
fetchSitemapUrls() deduplicates by canonical URL form, not by exact
string: two spellings of the same page — differing only in trailing slash,
scheme case, default port, or fragment — collapse to one entry, and the
first-seen spelling wins. Warming the same page twice under two spellings
would waste a slot of the URL cap.
Ordering the warmup list by importance
By default the sitemap-sourced URLs are merged in whatever order the sitemap
generator emitted them, and Breeze warms its queue strictly front to back —
so that order decides how long after a purge a page stays cold.
$breeze_warmup_priority (default false) replaces it with a computed
ordering, scored during the deferred refresh and stored ready to serve, so
the purge-time filter still pays no cost that grows with the sitemap's size.
Requires $breeze_warmup_sitemap — on its own it has nothing to order.
class Base extends StarterBase { public function __construct() { $this->breeze_warmup_sitemap = true; $this->breeze_warmup_priority = true; parent::__construct(); } }
Score is a sum of independent signals, so a fresh page in a menu outranks a menu page nobody has touched in a year:
| Signal | Default weight | Notes |
|---|---|---|
front_page |
1000 | A language's homepage. |
manual |
800 | Already in Breeze's own preload list. |
menu |
500 | Linked from a registered nav menu. |
types |
[] |
Per-post-type points, keyed by post type slug. Empty by default. |
freshness |
2 => 300, 7 => 200, 30 => 100, 365 => 25 |
Points by <lastmod> age in days, ascending buckets. Anything older, missing, unparseable, or in the future scores 0. |
Override the map wholesale in $breeze_warmup_priority_weights, or adjust it
per project with the timberkit_warmup_priority_weights filter (runs once,
at registration — not per purge).
A future <lastmod> scores 0 rather than the top freshness bucket: scheduled
content is not fresh content, and a broken lastmod must never be able to
shoot a URL to the front of the queue.
types is keyed by post type slug, for example:
class Base extends StarterBase { public function __construct() { $this->breeze_warmup_priority_weights = array( 'front_page' => 1000, 'manual' => 800, 'menu' => 500, 'types' => array( 'realizace' => 150 ), 'freshness' => array( 2 => 300, 7 => 200, 30 => 100, 365 => 25 ), ); parent::__construct(); } }
The post type is derived from the sub-sitemap filename, not looked up in
the database: wp-sitemap-posts-<type>-N.xml for core, <type>-sitemap.xml
for AIOSEO and Yoast alike (Yoast appends an index when a type spills over one
file — blog-sitemap2.xml — which the pattern allows for). A URL whose
sub-sitemap doesn't match either shape gets no type, so its types weight is
0. The structural indexes (author, date, product_attributes, rss,
additional) are excluded on purpose — they share the <name>-sitemap.xml
shape but aren't post types. author comes from both plugins, the rest from
AIOSEO. A
taxonomy sitemap can't be told apart from a post-type one by filename either,
so it falls through to weight 0 as well. If a types weight seems to have no
effect, check the sub-sitemap's filename first.
Three things worth knowing before this ships to a real sitemap:
Freshness reads <lastmod>, which is the date of the last edit, not of
publication. A ten-year-old page with a fixed typo therefore ranks as
fresh. The signal is free — it is already in the XML we parse — and
resolving real publication dates would mean matching URLs back to posts
through WPML and custom permalinks.
Naming pages yourself
The scorer treats Breeze's own manual list as a strong signal, but that list
lives in a wp_options row: it cannot be reviewed, carries no reason for any
entry, differs per environment by accident, and a database import wipes it —
after which the site quietly stops warming what mattered.
$breeze_warmup_urls is the same signal, kept in the theme:
protected array $breeze_warmup_urls = array( '/blog/', '/cs/blog/', 'https://example.de/blog/', );
It merges into manual at the weight that source already carries. It adds an
input, not a tier: ordering, weights and the cap are untouched.
Relative or absolute, and the difference matters. A relative path is right
most of the time — the same code runs on ddev, on staging and in production, and
an absolute URL would be silently wrong on two of the three. An absolute URL is
needed when the target is not on home_url()'s host at all, which under WPML's
domain-per-language negotiation is every language but the default.
Both are resolved rather than trusted. An entry naming a post or a page becomes
its ID and comes back as get_permalink(), so what is stored is this
environment's own URL in this environment's own language. An entry that
resolves to nothing is dropped, so a deleted page stops being warmed without
anyone having to remember the list exists.
url_to_postid() cannot see term archives, so those are kept as URLs and
verified with one HEAD during the cron refresh — never on the purge path,
which runs while an editor waits for a save. A probe that fails outright keeps
the entry: one flaky lookup must not empty a curated list.
Filterable as timberkit_warmup_curated_urls, and its 200-entry cap as
timberkit_warmup_curated_max_entries, for projects that keep configuration in
an mu-plugin rather than in the theme subclass. Unlike the weights filter this
one need not be pure — nothing fingerprints it — but a change is picked up at
the next refresh rather than the next purge.
The cap is not the whole cost. timberkit_warmup_sitemap_max_urls
(default 200) applies only to sitemap-sourced URLs. Entries Breeze itself
supplies are warmed on top of it, and every language's homepage and menu
items are guaranteed even when that pushes the total over the cap — the cap
is soft by design. Budget roughly 200 origin renders and about three and a
half minutes of warming, plus Breeze's own entries, plus any guarantee
overflow.
Warming a page nobody visits within the cache TTL (24 hours by default) is
wasted work — the cache expires before the visitor arrives. That 24 hours
is Breeze's own page-cache expiry setting; it is unrelated to this class's
CACHE_TTL (one hour), which governs a different clock — how long the
stored URL list itself is trusted before a refresh is scheduled. A practical
way to size the cap: count how many URLs got at least one pageview
yesterday; that number is the cap.
Draining the tail the cap left behind
$breeze_warmup_priority picks what gets warmed first; it does not warm what
the cap excludes. $breeze_warmup_tail (default false) keeps going after
the purge: every five minutes it dispatches another batch of the excluded
URLs to Breeze's preloader, in the same score order, until the tail runs out
or the next purge resets the run. Requires both $breeze_warmup_sitemap and
$breeze_warmup_priority — on its own there is no ordering to drain.
class Base extends StarterBase { public function __construct() { $this->breeze_warmup_sitemap = true; $this->breeze_warmup_priority = true; $this->breeze_warmup_tail = true; $this->breeze_warmup_tail_batch = 100; parent::__construct(); } }
Each tick checks Breeze's own preload queue (breeze_preload_queue) first
and stands aside while it is non-empty, so the tail drain never competes with
Breeze for the same origin renders. A skipped tick still schedules its
successor — the chain only ends when the tail itself is exhausted.
This never reaches "done", and that is by design. A full purge can arrive several times a day, and each one starts the tail over from its head. On a busy site, only part of the tail is ever covered in one run — but because the tail is in score order, the part covered is always the most valuable part. Do not size this feature expecting the whole sitemap to eventually go warm; size it expecting the top of the tail to stay warm continuously.
The batch size does not multiply out the way it looks. $breeze_warmup_tail_batch
of 100 per five-minute tick reads as 1200 URLs an hour, but that arithmetic
only holds if every tick fires — and a tick fires only when Breeze's own
queue is idle. Real throughput on a site that purges and warms constantly is
lower. Size the batch by the origin-render budget you can afford in a tick
that does run, not by the hourly total the multiplication suggests.
The cursor counts URLs dispatched, not URLs warmed. Each tick hands its
batch to Breeze_Cache_Preloader::preload_url(), which returns void and
may reject a URL outright. The tail advancing past a URL means it was handed
to Breeze, not that the origin rendered it. There is no confirmation signal
to build on: Breeze does not report back.
The five-minute interval is fixed, not a filter. $breeze_warmup_tail_batch
is the only knob; the tick's own cadence stays constant so the brake against
Breeze's queue behaves predictably. $breeze_warmup_tail_batch, like
$breeze_warmup_priority_weights, is read once, at registration — changing
it at runtime after that has no effect. Filterable independently:
timberkit_warmup_tail_batch (also applied once, at registration) and
timberkit_warmup_tail_max_urls (default 5000) which caps how many excluded
URLs are stored as the tail in the first place — a safety bound distinct from
the sitemap URL cap.
A cold start rescues itself. A purge schedules the first tick and resets the tail's cursor immediately, but the tail's contents are only written later, by the deferred refresh. If the first tick runs before that refresh has written anything, it finds an empty tail and ends the chain — nothing else would ever restart it. To close that gap, the refresh itself schedules a tick whenever it writes a non-empty tail, so the chain resumes once the tail actually has something to drain.
Tail draining refuses to wire on multisite. The brake reads Breeze's
breeze_preload_queue option, and Breeze scopes that option per blog on
multisite — every site's brake would read "idle" regardless of what any
other site's queue is doing, and the drain from every site would pile onto
whatever origin actually serves the requests. Rather than run without a
working brake, register() detects is_multisite() and leaves the tail
hooks unwired there, even when $breeze_warmup_tail is true.
Preload chain health
The Site Health check preload_chain_healthy (category caching, needs
$site_health) watches Breeze's own preload queue for silent stalls. Breeze
drives that queue through an Action Scheduler loopback; when the loopback
can't reach the site, the queue simply stops advancing and nothing anywhere
reports it. The check flags a queue that hasn't made progress in the last
minute.
Image resizer output format
The Site Health check resizer_output_format_writable (category performance,
needs $site_health, registered unconditionally) asks whether this server can
write the format the resizer targets — avif by default, or whatever
timber_kit_resizer_target_format returns — and whether transparency survives
the write.
It is the encode-side counterpart to the $resizer_format_health test above,
which asks which formats the backend can decode. Both badge Performance;
they are different axes and neither replaces the other. The output format is
the one every image on the site passes through, so a missing delegate there
takes down every variant rather than one editor's upload — and it does so
silently, because Resizer logs the encoder failure and returns null, which
simply removes that size from <picture>.
The check encodes a half-transparent 16x16 image and reads a pixel back rather than reading a capability list, because the failure that prompted it is invisible to a list: ImageMagick 6.9.11 reported AVIF and wrote AVIF, and flattened the alpha channel while doing it.
| Verdict | Status | Meaning |
|---|---|---|
| no backend | critical | Neither Imagick nor GD — nothing resizes at all |
| missing delegate | critical | Format absent from the build |
| write failed | critical | Listed, but the encoder refuses it |
| alpha lost | critical | Encodes, but transparency comes back opaque |
| unverified | recommended | Encoded, but the backend cannot read its own output back, so transparency is unchecked |
Delegate-free formats (jpeg, png, gif) short-circuit without an encode.
Only the request-wide format is probed; a per-variant 'format' => … override
is not.
The probe is reusable outside wp-admin:
use Parisek\TimberKit\Health\Image\BackendImageFormatProbe; use Parisek\TimberKit\Health\Image\ImageFormatProbe; $probe = new BackendImageFormatProbe(); if ( $probe->hasBackend() && ImageFormatProbe::VERDICT_OK !== $probe->probe( 'avif' ) ) { // This server cannot produce transparent AVIF. }
Inject an ImageFormatProbe into the check to stub the backend in tests:
new ResizerOutputFormatWritable( $probe ).
Usage
Create a Base class in your theme that extends StarterBase:
<?php use Parisek\TimberKit\StarterBase; use Parisek\TimberKit\Helpers; class Base extends StarterBase { public function __construct() { $this->menus = [ 'main-menu' => 'Main Menu', 'footer-menu' => 'Footer Menu', ]; $this->font_stylesheets = [ 'poppins' => 'fonts/poppins/stylesheet.css', ]; $this->disable_search = false; parent::__construct(); } }
Site icon tags
WordPress emits four site-icon tags: icon at 32 and 192 px, apple-touch-icon,
and msapplication-TileImage. It asks get_site_icon_url() for each size, so a
theme that answers with one SVG gets that SVG in all four — including
apple-touch-icon, which iOS cannot read, and a Windows 8 tile nobody wants.
Themes work around it by hardcoding their own <link rel="icon"> block in the
layout, and then both sets render.
$site_icon_tags replaces core's set with the files the theme actually ships:
class Base extends StarterBase { public function __construct() { $this->site_icon_tags = true; parent::__construct(); } }
Nothing is configured. static/images/touch/ is probed for known filenames and
a tag is written only for a file that exists, so both RealFaviconGenerator
output generations work unchanged:
| Slot | Filenames, first hit wins | Tag |
|---|---|---|
| SVG icon | favicon.svg |
<link rel="icon" type="image/svg+xml"> |
| PNG icon | favicon-96x96.png, favicon-32x32.png, favicon-16x16.png |
<link rel="icon" type="image/png" sizes> — sizes read from the filename |
| Shortcut | favicon.ico |
<link rel="shortcut icon"> |
| Apple touch | apple-touch-icon.png |
<link rel="apple-touch-icon" sizes="180x180"> |
| Manifest | site.webmanifest, manifest.json |
<link rel="manifest">, plus theme-color and apple-mobile-web-app-title read from its theme_color and short_name |
Two deliberate omissions. safari-pinned-tab.svg gets no mask-icon tag,
because that tag needs a tint colour no file states and a guessed one renders
worse than no pinned-tab icon. msapplication-TileImage is dropped outright.
An uploaded Site Icon wins. When one is set in Settings → General, this filter steps aside and WordPress uses it — the flag's off-path does the opposite, and overrides the editor's upload silently.
A theme that shipped no favicon file wires nothing, so core keeps whatever it would have done.
Configuration
Override these properties in your child constructor before calling parent::__construct():
Internationalisation
timber-kit treats configurable labels and titles (e.g. $breadcrumb_labels, $options_pages[*]['page_title']) as plain values used verbatim — it never wraps them in __(). Translating them is the consuming theme's responsibility.
Why not in the library: these are assigned in the child __construct() (before parent::__construct()), which runs on setup_theme — before init and before the text domain is loaded. Calling __() there is too early: it returns the string untranslated, and WordPress 6.7+ raises a "Translation loading triggered too early" _doing_it_wrong notice. Wrapping a dynamic config value in __() at use time also defeats string extraction — xgettext/makepot can't read a variable.
Localise at init, with static string literals. Where a config surface has a dedicated setup hook, override it — e.g. setup_breadcrumb_labels() (hooked to init):
public function setup_breadcrumb_labels() { $this->breadcrumb_labels = [ 'home' => _x( 'Home', 'breadcrumb', 'my-theme' ), // … ]; }
For an admin label without a dedicated setup hook (e.g. an options-page page_title), set the value to your already-localised string, or leave the English default.
Theme
| Property | Type | Default | Description |
|---|---|---|---|
$menus |
array | [] |
Registered navigation menus |
$font_stylesheets |
array | [] |
CSS files to enqueue on the frontend. Also forwarded into the Gutenberg editor canvas (both iframed and non-iframed) via block_editor_settings_all, so custom @font-face declarations render in the editor without falling back to system fonts. Relative paths are resolved under static/ and cache-busted with filemtime; absolute URLs pass through |
$theme_script_strategy |
string | 'module' |
How static/dist/js/script.js is enqueued: 'module' → wp_enqueue_script_module() (Vite/ESM); 'defer' → classic deferred wp_enqueue_script() for a webpack IIFE bundle. Override enqueueThemeScript() for finer control |
$preload_fonts |
array | [] |
Font files to preload |
$preload_headers |
bool | true |
Also send the preload hints as a Link: response header, alongside the <link rel=preload> tags core renders from the same list. The header arrives before the body, so every browser acts on it earlier; it is also the whole of an origin's part in HTTP 103 Early Hints, which an edge synthesises from Link: headers it saw on a previous response — PHP cannot emit an informational response itself. Runtime override: timber_kit_preload_headers. Whatever a project adds to wp_preload_resources must be public and identical for every visitor: an edge stores these headers per URL and replays them to whoever asks next |
$preconnect_origins |
array | [] |
Absolute origins advertised with rel=preconnect in that header (e.g. a tag manager, a font host). Paths are stripped — a preconnect opens a connection, it fetches nothing. Runtime override: timber_kit_preconnect_origins |
$search_post_types |
array | ['post'] |
Post types for search |
$article_post_types |
array | ['post'] |
Post types treated as articles |
$block_category |
array | ['slug' => 'custom', 'title' => 'Custom'] |
Custom block category |
$favicon_path |
string | 'images/touch/favicon.svg' |
Favicon path. Read only while $site_icon_tags is off |
$site_icon_tags |
bool | false |
Emit the favicon set found in static/images/touch/ instead of WordPress's four legacy site-icon tags. Opt-in — it changes rendered <head> output. See Site icon tags |
$context_privacy_policy |
bool | false |
Opt-in: populate the site's privacy-policy URL (get_privacy_policy_url()) into the Timber context under $privacy_policy_context_key. Off by default — the key typically drives a cookie-consent partial, which must not appear on projects that ship without one |
$privacy_policy_context_key |
string | 'ccnstL' |
Context key for the privacy-policy URL. The default is deliberately non-semantic so cookie-consent markup keyed off it stays invisible to ad-block heuristics |
$acfml_skip_frontend_field_translation |
bool | true |
Skip ACFML's translation of ACF field definitions (labels, instructions, placeholders, choices) on plain front-end views. Admin, REST, AJAX and WP-CLI keep it, and so does any front-end request where acf_form() is in play. Set to false on a site whose templates print a select/radio choice label rather than its value. See ACFML field-definition translation |
Security & Cleanup
| Property | Type | Default | Description |
|---|---|---|---|
$cleanup_wp_head |
bool | true |
Remove unnecessary wp_head output |
$disable_xmlrpc |
bool | true |
Disable XML-RPC |
$disable_emojis |
bool | true |
Remove emoji scripts/styles |
$disable_feeds |
bool | true |
Disable RSS feeds |
$disable_comments |
bool | true |
Disable comments site-wide: removes comments/trackbacks support from every registered post type (including those registered later via registered_post_type); closes comments_open/pings_open; redirects the Edit Comments admin page and Discussion Settings to the dashboard; unregisters the WP_Widget_Recent_Comments sidebar widget; removes /wp/v2/comments REST routes; rejects REST comment insertion with 403 even if a route is re-registered; removes comment + pingback XML-RPC methods; drops the X-Pingback header; and forces default_comment_status/default_ping_status to closed. Removal of the admin-bar comments node and the dashboard_recent_comments admin widget is controlled separately by $cleanup_admin_bar and $cleanup_dashboard. |
$disable_search |
bool | true |
Disable search |
$cleanup_dashboard |
bool | true |
Remove dashboard widgets |
$cleanup_admin_bar |
bool | true |
Clean up admin bar |
$editor_role_enhancements |
bool | true |
Enhanced editor role caps |
$disable_self_pingbacks |
bool | true |
Disable self-pingbacks |
$restrict_rest_users |
bool | true |
Protect REST API users endpoint |
$disable_application_passwords |
bool | true |
Disable WordPress application passwords so the application-passwords REST endpoint cannot issue long-lived API credentials |
$block_author_enumeration |
bool | true |
Turn numeric ?author=N requests into a 404 on template_redirect (before redirect_canonical), so the /?author=1 → /author/{username}/ username-disclosure attack is blocked. Path-based /author/{slug}/ URLs, admin author filters, and alphanumeric slugs are left alone |
$disable_404_permalink_guess |
bool | true |
Reverses core. Stops redirect_guess_404_permalink() turning a 404 into a redirect. Core matches the requested slug as a PREFIX (post_name LIKE 'about%') and redirects to whatever comes back first, so a reader following a dead link is told the page moved and then shown something else — worse than being told it is gone. The query has a trailing wildcard, so it cannot use the post_name index, and it runs on every 404 carrying a name: a cost any visitor can ask for repeatedly. Genuine canonical redirects are untouched — the guess is the last thing redirect_canonical() tries, after trailing-slash, ?p=ID-to-slug and category-base. Set false on a site that renames slugs without leaving redirects behind and relies on the guess |
$disable_file_editing |
bool | true |
Define DISALLOW_FILE_EDIT so the Theme Editor and Plugin Editor screens are removed from wp-admin |
$remove_wp_generator |
bool | true |
Strip the WordPress version from the the_generator filter (covers both <meta name="generator"> and RSS/Atom feed generators) |
Media Processing
| Property | Type | Default | Description |
|---|---|---|---|
$clean_image_filenames |
bool | true |
Sanitize uploaded filenames |
$big_image_size_threshold |
int | 2560 |
Max image dimension (px) for uploads. Drives WordPress core's native big_image_size_threshold filter — images whose longer edge exceeds it are downscaled by core on upload and served as a -scaled derivative. 0 disables scaling entirely. This is the single canonical knob. |
$max_upload_width |
?int | null |
Deprecated — use $big_image_size_threshold. Honoured only when non-null; the larger of width/height becomes the (square) threshold (explicit 0 disables, preserving the legacy contract). |
$max_upload_height |
?int | null |
Deprecated — use $big_image_size_threshold. |
Why a single dimension, not width × height? WordPress core's
big_image_size_thresholdis one number — it caps the longer edge and fits the image inside a square box (resize($n, $n)), exactly as the old in-theme resize did. Mirroring it with one property keeps the kit honest and lets downscaling run through core's pipeline, which (unlike the previouswp_handle_uploadhook) doesn't fight core's own 2560 cap and covers every upload path (REST, WP-CLI, programmatic), not just the media library. The filter is registered unconditionally and is authoritative — timber-kit owns the threshold across the fleet, overriding any other plugin'sbig_image_size_thresholdfilter. The deprecated width/height pair is read for backward compatibility (larger edge wins) until removed in 2.0.
Reclaiming disk space from preserved originals
When core downscales an upload it keeps the full-resolution original on disk (the original_image / "Restore original image" mechanism). timber-kit deliberately does not delete it on upload: WordPress regenerates every thumbnail sub-size from the original (for best quality), so deleting it on upload would silently degrade any later regeneration — a new crop size, retina variant, or wp media regenerate — to double-compressed output sourced from the -scaled file.
Instead, reclaim space with a deliberate, opt-in sweep once the redesign window (when new crop sizes are likely added) has passed:
wp timber-kit prune-originals --dry-run # report reclaimable space, delete nothing wp timber-kit prune-originals --older-than=30 # prune originals of uploads older than 30 days wp timber-kit prune-originals --limit=500 # cap the batch
The command only prunes genuine size-driven -scaled downscales — it leaves originals preserved for EXIF rotation or format conversion untouched, and never strips the original_image pointer unless the file was actually deleted. The trade-off it makes permanent: future regeneration of those images falls back to the -scaled file. See \Parisek\TimberKit\OriginalImagePruner.
SVG dimensions
An SVG attachment usually reaches the browser as an <img> with no width and no height, so nothing reserves its box and every one of them shifts the layout as it lands.
Two layers cause it, and neither is timber-kit's. getimagesize() cannot parse SVG, so core's wp_generate_attachment_metadata() stores no dimensions for image/svg+xml at all. The svg-support plugin fills part of the gap, but its reader takes only the width and height attributes on the root element — an SVG exported with just a viewBox, the current Figma and Illustrator default, yields an empty string it stores as 0. Measured on one production library: 1519 of 3520 SVGs unsized, and the share growing each year as export tooling moves to viewBox-only.
Nothing to configure for templates. Helpers::formatImage() resolves a missing axis from the file, so a <picture> gets its dimensions on any project, immediately. Nothing is written during a render.
It runs for every image, so the cost is bounded deliberately and is asserted rather than argued:
| Not an SVG, or an SVG that already has both axes | returns on the first comparison, opens nothing — 0.095 us |
| An SVG missing an axis, first time in the request | one bounded read of the file head — 0.086 ms |
| The same attachment again | memoised, including a refusal |
Instrumented on a real page carrying 152 SVG <img> elements: 399 calls, 271 of them returning immediately, 128 resolutions, ~11 ms total per uncached render. Nothing on a cached hit, since no PHP runs.
Run the sweep and that 11 ms goes away — with dimensions in metadata the read path skips every image. The two are not redundant: the read path is the safety net that makes a template correct everywhere, the sweep is what makes it free.
get_attached_file() is the only route to the filesystem here, and the tests assert it is never called for a raster image — a measurement drifts, an assertion does not.
Stored metadata is still worth having — the media library, wp_get_attachment_image_src() and srcset read it and never go through Helpers — so there are two writers:
// Base.php — new uploads protected bool $svg_dimensions = true;
# existing attachments wp timber-kit svg-dimensions --dry-run # report what would be written wp timber-kit svg-dimensions # fill in what is missing wp timber-kit svg-dimensions --force # re-derive over a wrong stored value
All three share one resolver: the width/height attributes first, converted from any CSS absolute unit (px, pt, pc, in, cm, mm, Q) and accepting the full SVG number grammar including exponents; then the viewBox. A single explicit axis is combined with the viewBox ratio rather than discarded. Relative units (em, %) are not intrinsic sizes and fall through.
Using viewBox as a source of width and height is a deliberate policy, not a measurement — W3C defines it as a source of aspect ratio. For an image whose box comes from CSS, which is every call site this exists for, the ratio is the whole point. The cost is that an <img> with no CSS sizing renders at these numbers instead of the SVG's own default.
Refusing is a correct answer. Every ambiguity resolves to nothing rather than a guess, because a wrong number gets written to the database, outlives the package version, and is read back as authoritative by the next sweep. An encoding it cannot decode, a prolog it cannot walk, a root tag beyond 64 kB, an unconvertible unit, a derived 1×1 — all report the attachment as unreadable and leave it alone.
The root start tag is found by walking the prolog rather than searching for the first <svg: that search read a <svg quoted inside a processing instruction, an entity declaration or a CDATA section as if it were the root. Files are read incrementally and stop at that tag, which also sidesteps libxml's 10 MB attribute-value limit that made three real uploads fail to parse whole. Entity references are escaped to literal text, never resolved, so a hostile DOCTYPE never reaches the parser.
It is designed to coexist, not compete. Dimensions are written to core's own _wp_attachment_metadata width/height keys, so every consumer benefits. Each axis is considered separately and a valid stored value is never replaced — filling a missing height cannot disturb a width another plugin resolved. 0 and 1 count as absent (intval( '' ), and core's bogus SVG 1px). The upload filter hooks at priority 20 so it observes other plugins rather than racing them. --force is the single deliberate exception.
The sweep is a deliberate command rather than a lazy write during rendering, for the same reason prune-originals is: a page render must not write to the database, or the healing becomes a property of who happened to request an uncached page — and stops entirely on a read-only replica. See \Parisek\TimberKit\SvgDimensions.
Dev Media Proxy
Off by default. Enable it by pointing it at an upstream origin's uploads URL, via either an environment variable or a PHP constant:
# .ddev/.env — preferred: one line, no PHP, git-tracked so it # propagates to every git worktree automatically (DDEV >= 1.25 # surfaces it to PHP via getenv()). TIMBERKIT_MEDIA_ORIGIN=https://example.com/wp-content/uploads
// wp-config.php — alternative / override (the constant always wins) define( 'TIMBERKIT_MEDIA_ORIGIN', 'https://example.com' );
Behavior:
- if a local uploads file exists, its local URL is kept
- if a local uploads file is missing, the URL is rewritten to the configured origin
- a domain-only origin such as
https://example.comautomatically reuses the local uploads path - a full origin such as
https://example.com/wp-content/uploadsis used verbatim - Resizer can use the same origin to probe already-generated remote variants when local source files are missing
Configuration source & safety:
- Constant wins. When both the constant and the env var are set, the constant is used — an existing
define()keeps its exact behaviour. An explicitly-empty constant (define( 'TIMBERKIT_MEDIA_ORIGIN', '' )) means "disabled" and does not fall through to the env var. - Self-reference is refused. If the origin host equals the site's own uploads host, the proxy stays off — a missing-file rewrite would just resolve back to the same missing file. Host-level check (no
www/port/IDN normalization). http(s)only. Origins with any other scheme are ignored.- Dev-only / trusted config. Anyone who can set the origin can point media URLs (and the remote probe) at a host they choose. Don't enable it in untrusted environments.
See ADR 0003 for the design rationale.
Available hooks:
timber_kit_resizer_missing_source_variants— extension point used byDevMediaProxyto provide remote Resizer variantstimber_kit_resizer_probe_remote_variants— enable/disable remote variant probing, defaulttruetimber_kit_resizer_remote_variant_probe_timeout— HTTP timeout for variant probes, default2.0timber_kit_resizer_remote_variant_probe_limit— max remote variant probes per request, default50timber_kit_resizer_quality_in_cache_key— put a variant's quality in its cache key, defaultfalse(also settable asStarterBase::$resizer_quality_in_cache_key). Without it, re-cutting the same dimensions at a different quality serves the previously generated file. Opt-in because switching it on relocates every non-default-quality variant: old cache files orphan and public URLs change.timber_kit_resizer_aspect_tolerance— tolerance band around 1:1 used byResizer::classifyAspect()to decide whether a source qualifies assquare, default0.1. Returning a smaller value (e.g.0.05) tightens the square band; returning a larger value (e.g.0.2) loosens it.
Google Tag Manager
The kit prints two blocks and the theme decides where they go — the loader in <head>, the noscript iframe right after <body>. Nothing is hooked into wp_head on your behalf, because the loader's position relative to consent-mode defaults is the theme's call.
1. Declare the containers
Plain GTM, no server-side tagging — domain and path are both optional:
class Base extends StarterBase { protected array $gtm_containers = array( 'default' => 'GTM-XXXXXXX', ); }
That yields Google's standard loader, https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX, plus the matching noscript iframe. Server-side tagging only adds domain and path to the same shape.
2. Call it from the layout
templates/layout/layout.twig, or wherever the theme owns <head> and <body>:
<head> <script> window.dataLayer = window.dataLayer || []; function gtag() { window.dataLayer.push(arguments); } gtag('consent', 'default', { /* … */ }); </script> {{ gtm_container() }} {# after the consent defaults, never before #} {{ function('wp_head') }} </head> <body class="{{ body_class }}"> {{ gtm_container_noscript() }} {# first thing inside <body> #} … </body>
Order in <head> is not cosmetic: GTM reads the consent state at load, so a loader placed above gtag('consent', 'default', …) starts before the defaults exist and tags fire with the wrong state.
What lands in the page is Google's published snippet, line for line — same line breaks, same <!-- Google Tag Manager --> comments, no vendor attributes and no generator marks. A person diffing the page source against Google's documentation should find exactly one difference: the id-less URL, where a custom path is configured. Whole-output tests pin that.
3. Set the environment constant
// wp-config.php on production define( 'TIMBERKIT_GTM_ENABLED', true );
Optional — without it, production measures and nothing else does. Set it explicitly where an environment is not what wp_get_environment_type() reports, or where staging should measure too.
Migrating a project off GTM4WP
Both call sites are safe to add before the project has any configuration: gtm_container() prints nothing and gtm_container_noscript() delegates to the plugin, so an unmigrated project renders exactly as it does today. That is what lets one shared layout serve both.
- Replace
{{ gtm4wp_the_gtm_tag() }}in the layout with the two calls above, and ship it. Nothing changes yet — the plugin is still the only source. - Prepare the switch: commit
$gtm_containerson the project'sBasebut do not deploy it yet. - Set the plugin's Container code placement to OFF first, then deploy the configuration. Do it in that order and in one sitting.
- Deactivate the plugin if nothing on the site uses its data layer, and drop it from
DEACTIVATE_PLUGINSin.ddev/.env— the environment gate replaces that workaround. - Confirm in Tools → Site Health that
gtm_container_not_duplicatedis green, and check the page source for exactly one<!-- Google Tag Manager -->block.
The order in step 3 is the whole point. Both sources are live between "configuration deployed" and "plugin switched off", and that window double-counts every visit — a doubling reads as growth, so nothing on the site complains and the numbers stay wrong in the report afterwards. Switching the plugin off first inverts the failure: the window measures nothing instead of measuring twice. A gap is visible in the data as a gap, it is bounded by the deploy, and it needs no correction later.
Reference
Declare the containers on your Base, keyed by language:
class Base extends StarterBase { protected array $gtm_containers = array( 'default' => array( 'id' => 'GTM-XXXXXXX', 'domain' => 'windstream.example.com', // optional — server-side endpoint 'path' => 'aBcDeF/', // optional — see below ), 'de' => array( 'id' => 'GTM-YYYYYYY' ), // inherits domain + path ); }
A single-language project can write the ID on its own: array( 'default' => 'GTM-XXXXXXX' ).
Behavior:
- No
path— the standardhttps://www.googletagmanager.com/gtm.js?id=…loader. - A
path— the container is addressed by that path, so the ID is left out of the URL entirely. Repeating it would hand blockers the pattern a randomly generated path exists to avoid. The query string then starts at?l=instead of continuing with&l=. defaultis the fallback; a language entry states only what differs and inherits the rest. An unknown language falls back todefault, quietly — a missing translation must not stop measurement.- A language written out and left blank is switched off.
'de' => ''(ornull,false,array(), or an entry with a blankid) means do not measure here and does not inherit fromdefault— without it, turning one language off would be unsayable, since every spelling of nothing resolves back to the fallback. - Keys are WPML language codes — what
apply_filters( 'wpml_current_language', null )returns (cs,de,pt-br), not the WordPress locale (de_DE, which WPML keeps separately asdefault_locale). The code is a free-text field in WPML → Languages, so read the site's actual value instead of assuming. Matching ignores case and treats_and-alike, so a key spelled either way still matches — which also meansde-atandde_ATare the same key, and writing both leaves the later one in effect. - A regional variant inherits from its base language. WPML ships
pt-br/pt-pt/zh-hans/zh-hant; anything else regional — German-Austria, English-UK — is a custom language whose code someone typed. Withdeconfigured and node-at, an Austrian visitor reports into the German container; add ade-atentry when Austria needs its own. Longest match wins, so the specific entry always beats the general one. - A malformed value never reaches the page. The container ID is matched against
GTM-[A-Z0-9]+, the domain throughFILTER_VALIDATE_DOMAIN, the path against a charset that excludes?and=. An invalid ID prints nothing; an invalid domain or path falls back to the Google default, so the container stays reachable and the fallback is visible.
Environment gate:
// wp-config.php — decides when defined define( 'TIMBERKIT_GTM_ENABLED', true );
Without the constant, measurement runs when wp_get_environment_type() is production and nowhere else. Deliberately not tied to WP_DEBUG — turning a log off must not turn measurement off with it.
Configured and the plugin still printing its own container is the one broken state: the page loads GTM twice and counts every visit twice, which reads as growth rather than as a fault. The loader never inspects the plugin — guessing at a schema this kit does not own can only go wrong in the direction that stops measurement. The state is reported instead by the gtm_container_not_duplicated Site Health check (needs $site_health), which reads GTM4WP's placement and its GTM4WP_HARDCODED_GTM_ID and tells you to switch the plugin off.
The noscript iframe prints only where it can. ns.html takes the container ID as a query parameter and has no ID-less form, so the block is free where the ID already appears in the loader URL and self-defeating where a custom path exists to keep it out. gtm_container_noscript() therefore prints by default for a container without a custom path and stays silent for one with it. Override either way per container:
'default' => array( 'id' => 'GTM-XXXXXXX', 'path' => 'aBcDeF/', 'noscript' => true ), // print it anyway 'default' => array( 'id' => 'GTM-XXXXXXX', 'noscript' => false ), // never print it
The iframe always addresses the host root (https://<domain>/ns.html?id=…), never the loader path — that path addresses the script only.
One thing the kit does not emit: GTM environments (gtm_auth / gtm_preview) apply to the Google loader only and are ignored for a server-side path, which has no notion of them.
WPForms Config Bridge
Define overrides in wp-config.php:
define( 'WPFORMS_CAPTCHA_PROVIDER', 'turnstile' ); define( 'WPFORMS_TURNSTILE_SITE_KEY', '1x00000000000000000000AA' ); define( 'WPFORMS_TURNSTILE_SECRET_KEY', '1x0000000000000000000000000000000AA' );
Bridged keys:
WPFORMS_<UPPER_SNAKE>for any key already saved in thewpforms_settingsoption- common captcha keys are bridged even on fresh installs without saved settings:
captcha-provider,turnstile-site-key,turnstile-secret-key,recaptcha-type,recaptcha-site-key,recaptcha-secret-key,hcaptcha-site-key,hcaptcha-secret-key
The Cloudflare always-pass test sitekey/secret pair above (1x000…AA / 1x000…AA) is recommended for staging/CI to avoid headless detection blocking the challenge widget.
When any override is active, an admin notice on WPForms admin screens lists which setting keys are read from wp-config.php, so values saved through the WP admin do not silently disappear at runtime without explanation.
ACF
| Property | Type | Default | Description |
|---|---|---|---|
$acf_json_keep_on_delete |
bool | false |
Keep a Local JSON file on disk when its field group, post type or taxonomy is deleted in wp-admin. The database record is still removed; only the unlink is suppressed. Opt-in because it changes what a delete does |
Why
$acf_json_keep_on_deleteexists. ACF treats the JSON file as an export of a database record, soACF_Local_JSONunlinks it on everyacf/trash_*andacf/delete_*. This package inverts that relationship:acf_json_save_paths()routes those files into the theme, where they are the versioned source and the database copy is the derivative. A delete in the admin therefore destroys the source. What survives depends on where else that definition exists: a committed file comes back from git, and a group whose database record was only trashed gets its file written again when the record is restored, because ACF hooksacf/untrash_*to the save path. A group that lives only in its JSON file has no record to restore, so if it was never committed it has neither, and is gone.It destroys it quietly, which is the reason for the flag rather than a note in a runbook. The postmeta values survive the delete, but every
_<field>reference now names a definition nothing can resolve.get_field_objects()skips an unresolvable reference with a barecontinue— no notice, no_doing_it_wrong()— soHelpers::formatFields()returns an array with the key absent rather thanfalse. Consumer code reads the absent key as "off" and renders defaults. Nothing fails: the page returns 200,WP_DEBUGstays silent, and the stored values are still correct in the database.With the flag on, the file stays, ACF registers it again on the next load, and the group reverts to the committed version instead of disappearing.
Enable it where the committed JSON is the source of truth for field definitions and the database copy is the derivative. Leave it off where a delete in the admin is meant to remove the definition for good — the flag makes that operation impossible to complete from the admin, because the file registers the group again on the next request. The library keeps it off; deciding which model a project follows is the project's call:
class Base extends StarterBase { public function __construct() { $this->acf_json_keep_on_delete = true; parent::__construct(); } }
Gutenberg
| Property | Type | Default | Description |
|---|---|---|---|
$gutenberg_align_wide |
bool | true |
Enable wide/full alignment |
$gutenberg_responsive_embeds |
bool | true |
Responsive video embeds |
$gutenberg_editor_styles |
bool | true |
Load editor stylesheet |
$mce_exclude_editor_styles |
bool | true |
Keep gutenberg-editor.css out of the classic (TinyMCE) editor via mce_css. add_editor_style() feeds it to both editors, but its rules are scoped to .editor-styles-wrapper, which only Gutenberg's body carries — so in TinyMCE it contributes mainly its Tailwind Preflight, which resets a to text-decoration: inherit and ol/ul to list-style: none. Every ACF wysiwyg field then shows links as plain body text and lists with no bullets. Default on — that reset is nothing a project wants. Set false when the theme styles the classic editor from inside that same file (body#tinymce / body.mce-content-body), because excluding it switches those rules off silently. See below |
$gutenberg_disable_core_patterns |
bool | true |
Remove core block patterns |
$restrict_allowed_blocks |
bool | true |
Restrict the editor to $allowed_core_blocks + ACF blocks via allowed_block_types_all. Set false on sites whose existing content pre-dates the allowlist — the filter is then not wired at all, so no no-op allowed_block_types_all() override is needed |
$render_block_passthrough_blocks |
string[] | [] |
Block names render_block() returns unchanged, bypassing the core-block wrapper. Exact names ('wpforms/form-selector'), namespace wildcards ('wpforms/*'), or '*' to disable wrapping entirely. Escape hatch for third-party form/gallery blocks the wrapper would break |
$admin_resizable_sidebar |
bool | false |
Opt-in resizable Gutenberg editor sidebar. Default off — the JS/CSS ship inside the package and are served from its vendor/ dir, which the standard theme .htaccess denies, so enabling it also requires an .htaccess allow rule (see below). Set true to enable |
Upgrading with
$mce_exclude_editor_styles— check the file once. Search the theme'sstatic/src/css/gutenberg-editor.cssformce-content-bodyor#tinymce. A match means the project deliberately styles the classic editor from that file, and excluding the file turns those rules off. Either drop the block — if it only restored what Preflight had broken, the native editor now does that job — or set$mce_exclude_editor_styles = falseand keep it. Nothing warns about this: both states render, one just looks like the native editor and the other like the project's.The exclusion is also not "nothing is lost". A theme's own
settings.cssimports reach the iframe, so the compiled file typically also carries:rootcustom properties, base-layer element rules (button { cursor: pointer },html { overflow-anchor: none }) and class-scoped block styles (.wp-block-image,.wp-block-separator). None of that styles the text awysiwygfield holds — which is why the flag is safe — but a project with different imports should read its own compiled output rather than trust that list.
Enabling
$admin_resizable_sidebar—.htaccessrequirement. The sidebar's JS/CSS are served from the package'svendor/directory (vendor/parisek/timber-kit/assets/…) viapackageAssetUrl(). The standard theme.htaccessblanket-deniesvendor/for security (RewriteRule ^vendor/(.*)?$ / [F,L]), so the browser would get 403 for those assets. When you set$admin_resizable_sidebar = true, also allow static assets undervendor/in the project's theme.htaccess, before the blanket deny:# Allow static assets shipped inside vendor (e.g. parisek/timber-kit admin # CSS/JS enqueued via packageAssetUrl()) — must precede the blanket deny. RewriteRule ^vendor/.+\.(css|js|mjs|map|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif)$ - [L] RewriteRule ^vendor/(.*)?$ / [F,L]PHP / source / config under
vendor/stay forbidden. Projects scaffolded fromwordpress-base(currentstarter_theme) already ship this allow rule.
Options Pages
$options_pages declares the ACF options page(s). Each entry requires menu_slug + page_title; optional per-entry keys are parent_slug (sub-page), capability (default edit_posts), icon_url (top-level pages only, default dashicons-admin-generic), and admin_bar (bool, default off — add an admin-bar shortcut to this page; any number of entries may carry this key, including sub-pages).
| Property | Type | Default | Description |
|---|---|---|---|
$options_pages |
array | one "Theme Settings" page | List of ACF options pages. parent_slug => sub-page; admin_bar => true => add admin-bar link for this entry; post_id => ACF storage namespace (see below); [] disables the feature entirely (no page, no admin-bar link). The default "Theme Settings" entry has admin_bar => true |
// one top-level page with an admin-bar shortcut + two sub-pages under it $this->options_pages = [ [ 'menu_slug' => 'settings', 'page_title' => 'Theme Settings', 'admin_bar' => true ], [ 'menu_slug' => 'footer', 'page_title' => 'Footer', 'parent_slug' => 'settings' ], [ 'menu_slug' => 'social', 'page_title' => 'Social', 'parent_slug' => 'settings' ], [ 'menu_slug' => 'dev', 'page_title' => 'Dev Settings', 'capability' => 'manage_options' ], ]; // disable completely $this->options_pages = [];
post_id — storage namespace
Omitted, ACF applies its own default of 'options': values land in wp_options as options_<field_name>, keyed by field name, not by page. That holds fine while an install has exactly one set of options pages — and stops holding the moment a second one appears, which it can without any repo change (a plugin's page, or one created through ACF's admin UI, which lives in the database as an acf-ui-options-page post and shows up in no grep of the theme).
Two pages then share one namespace, and where their field names overlap they read each other's values. Nothing errors: get_field('links_login', 'option') returns the other page's stored value, get_field('links', 'option') returns null, and Helpers::formatFields() sees nothing — an options group that resolved to nothing is indistinguishable from one nobody filled in. The colliding case is the worse of the two, because the page renders and looks correct.
$this->options_pages = [ [ 'menu_slug' => 'settings', 'page_title' => 'Theme Settings', 'post_id' => 'mytheme_settings' ], [ 'menu_slug' => 'footer', 'page_title' => 'Footer', 'parent_slug' => 'settings' ], ];
Values are stored as mytheme_settings_<field_name> and read with Helpers::formatFields('mytheme_settings').
A sub-page inherits its parent's post_id unless it declares its own. ACF does not — acf_options_page::validate_page() applies 'post_id' => 'options' through wp_parse_args to every page independently, parent or not — so without the inheritance a namespaced parent with unmarked children would split one theme's settings across two namespaces and formatFields() would return only half of them. Declare post_id on the child to opt out.
Inheritance is transitive: ACF's add_sub_page() accepts a parent_slug pointing at another sub-page, so a page nested two levels down takes the namespace of its nearest ancestor that declares one. A parent_slug referencing a page outside $options_pages (a plugin's) inherits nothing — this class cannot know what namespace that page registered with.
Adopting post_id also widens what clear_cache_on_options_save() matches: it purges on a save to any options namespace rather than the literal 'options', since ACF saves through acf_save_post( $page['post_id'] ) and would otherwise stop purging for exactly the projects that namespace their storage.
Adopting this on a live site is a data migration: existing values stay behind under the old prefix and have to be copied to the new one. Set it from the start on new projects.
Breadcrumbs
Breadcrumb data ($context['breadcrumb']) is auto-populated by StarterBase::timber_context() from the properties below — projects only override these to customise behaviour. A legacy compatibility guard (class_exists('\Breadcrumb', false)) skips auto-populate when a project still ships the pre-1.7 global \Breadcrumb class.
| Property | Type | Default | Description |
|---|---|---|---|
$breadcrumb_labels |
array<string, string> |
['home' => 'Home', '404' => 'Page not found', 'search' => 'Search: %s', 'pagination' => 'Page %d', 'author' => 'Author: %s'] |
Pre-translated labels for typed items. Defaults are English raw strings — override via setup_breadcrumb_labels() (not __construct()), see below. |
$breadcrumb_menu_name |
string |
'main-menu' |
Nav-menu location slug for the menu-trail strategy (by_menu_trail). Set to a different menu's location slug if breadcrumbs should follow a non-main navigation. |
$breadcrumb_list_page_map |
array<string, string> |
[] |
Post type → ACF option key for "listing page" injection between Home and a single post of that type. Example: ['post' => 'article_list'] injects links.article_list (from the ACF Global Options Page) as the parent crumb on every single post. |
$breadcrumb_menu_trail_post_types |
?array |
null |
Post types eligible for menu-trail. null = auto-detect via is_post_type_hierarchical(). Pass an explicit list to opt-in / opt-out specific CPTs regardless of hierarchy. |
$breadcrumb_include_pagination |
bool |
false |
Append a "Page N" item on paginated archive views. Off by default — opt in per project. |
$autopopulate_breadcrumb |
bool | true |
Auto-populate $context['breadcrumb']. Set false if the theme builds breadcrumbs itself |
Localising labels — override setup_breadcrumb_labels(), not __construct()
Calling _x() from Base::__construct() to populate $breadcrumb_labels triggers WordPress 6.7+'s _load_textdomain_just_in_time notice — the constructor runs before init, but the theme's textdomain has not loaded yet. StarterBase registers setup_breadcrumb_labels() on init (priority 1) as the project-side hook for translated labels:
class Base extends \Parisek\TimberKit\StarterBase { public function setup_breadcrumb_labels() { $this->breadcrumb_labels = array( 'home' => _x( 'Home', $this->theme_name, $this->theme_name ), '404' => _x( 'Page not found', $this->theme_name, $this->theme_name ), 'search' => _x( 'Search: %s', $this->theme_name, $this->theme_name ), 'pagination' => _x( 'Page %d', $this->theme_name, $this->theme_name ), 'author' => _x( 'Author: %s', $this->theme_name, $this->theme_name ), ); } }
$this->theme_name in both _x() slots is intentional — it doubles as the translation context and the textdomain, so a single project identifier scopes everything. Substitute the source strings with the project's locale (Czech, German, …) and the WPML / Polylang stack picks the right translation at render time.
Projects that don't need translated labels (single-locale English sites) can skip the override entirely — the English defaults declared on $breadcrumb_labels apply unchanged.
ACFML field-definition translation
ACFML translates ACF field definitions — labels, instructions, placeholders, prepend/append, choices, message — on every request that loads a field group. ACFML\Strings\FieldHooks implements IWPML_Frontend_Action, so it registers on the front end with no is_admin() guard and hooks acf/load_field. Every field then reaches an unmemoized linear scan over a static array, run through WPML's functional library with a closure allocated per element.
On a page view none of those strings is rendered, so the whole walk is discarded.
Measured on a site with 117 field groups and 972 top-level fields: 479 ms with the translation, 370 ms without, on an otherwise identical stack with a warm Redis object cache. A PHP-FPM slowlog over 3973 slow requests put 71 % of deepest-frame samples inside wpml/fp and wpml/collect. Only the field level costs anything — the group level measured 1.04 s against 1.11 s.
// Base.php — a site whose templates print a choice LABEL rather than its value protected bool $acfml_skip_frontend_field_translation = false;
On by default, because two shapes make it wrong and only one of them is invisible:
- A theme calling
acf_form()puts labels, instructions and placeholders in front of a visitor. This detects itself.acf_form_head()reachesACF_Assets::add_actions(), which records itself in ACF's settings, so the guard steps aside on any front-end request that has set a form up — read withacf_raw_setting(), never withacf_has_done(), which writes the flag it reads. - A template printing a
select/radio/checkboxchoice label rather than its value cannot be detected from the kit. That is what the property is for. The failure if a site needs it and does not set it is one label rendered in the source language — quiet, and easy to mistake for an untranslated string, so it is worth checking rather than assuming when moving a large existing site onto this version.
Four contexts keep the translation and naming all four is load-bearing: admin, AJAX, REST and WP-CLI. Gutenberg loads field groups over REST and ACF talks to admin-ajax from inside it, so an is_admin()-only guard would switch the translation off in the editor, where the labels are the entire point.
Retiring this is a one-line default flip once ACFML memoizes its lookup. Not fixed in acfml 3.0-b.1: every file on the hot path is byte-identical to 2.2.4, and so is the bundled wpml/fp.
Performance
Replaces the standalone Speculation Rules plugin. After upgrading, downstream projects can wp plugin deactivate speculation-rules && wp plugin delete speculation-rules — the same prerender / moderate / logged-out behaviour ships from the theme.
| Property | Type | Default | Description |
|---|---|---|---|
$speculation_rules |
?array |
['mode' => 'prerender', 'eagerness' => 'moderate', 'authentication' => 'logged_out'] |
Hooks configure_speculation_rules() onto the WP 6.8+ wp_speculation_rules_configuration filter. Defaults mirror the standalone plugin's defaults — faster than WP core's prefetch / conservative, with rules emitted only for logged-out visitors so editors browsing the frontend from wp-admin don't trigger prerender-driven double-fires of GA / GTM / Productive page-views. Override individual keys per project (e.g. drop to prefetch if Consent Mode v2 is configured for imperative tracking), or set the whole property to null to fall back to WP core defaults (no override, no auth gate). |
$warn_speculation_rules_plugin_redundant |
bool | true |
Registers a Site Health test (Tools > Site Health → timber_kit_speculation_rules_redundant). Returns status: 'good' when the standalone plugin is inactive; returns status: 'recommended' with a "Manage plugin" link when both code paths are running and would duplicate the wp_speculation_rules_configuration filter. Passive signal only — no admin-notice banner, no auto-deactivation. |
The companion wp_speculation_rules_href_exclude_paths filter is intentionally not wrapped — WP 6.8+ core already excludes /wp-login.php, /wp-admin/*, query-string action URLs, etc., and the standalone plugin only re-emitted a legacy plsr_… filter for backwards compatibility. Downstream projects can still hook the WordPress core filter directly when a project-specific URL needs to be excluded.
// Override mode/eagerness in your Base.php (extends StarterBase) class Base extends \Parisek\TimberKit\StarterBase { protected ?array $speculation_rules = [ 'mode' => 'prefetch', // safer when Consent Mode v2 fires on pageview 'eagerness' => 'moderate', 'authentication' => 'logged_out', ]; }
Block renderer migration guide
If you're upgrading from a theme that carried timber_block_render_callback() inline in functions.php:
-
Bump the Composer constraint to
^1.5:{ "require": { "parisek/timber-kit": "^1.5" } } -
Replace the inline
timber_block_render_callback()body with a wrapper:function timber_block_render_callback( ...$args ): void { \Parisek\TimberKit\BlockRenderer::render( ...$args ); }
block.jsonfiles referencing the old function name keep working. -
Remove the freestanding
add_action( 'acf/save_post', … 'acf_block_…' flush )hook fromfunctions.php— the package now owns it:StarterBase::__construct()wiresBlockRenderer::flushPostBlockCache()toacf/save_postat priority 20. -
(Optional) If you want to keep your existing Tailwind alert template for the empty-block warning, register an override:
add_filter( 'timber_kit/block_renderer/empty_alert_html', static function (string $default, string $block_name, array $attributes): string { $block_label = $attributes['title'] ?? $attributes['name']; return Timber::compile('@component/alert/alert.twig', [ 'content' => [ 'message' => '<strong>' . esc_html($block_label) . ':</strong> ' . esc_html(__('Pro zobrazení vyplňte požadované údaje v pravém panelu.', 'starter_theme')), 'type' => 'warning', 'container' => 'container', ], ]); }, 10, 3 );
Without this filter the package renders its own Twig template (
@timber-kit/empty-alert.twig) using Gutenberg's native.block-editor-warningclasses — no theme styling required.
Testing
ddev start ddev exec "composer test" # Unit suite (Brain\Monkey, fast — default) ddev exec "composer test:property" # Eris property suite (invariant-based) ddev exec "composer test:all" # both suites ddev exec "composer phpstan"
The property suite (tests/Property/, powered by giorgiosironi/eris) targets pure functions only and runs under its own phpunit.property.xml config to stay isolated from Brain\Monkey's Patchwork hooks. CI pins ERIS_SEED to the Actions run ID — reproduce a failing build locally with ERIS_SEED=<run-id> composer test:property.
Releasing
Releases are automated through two GitHub Actions workflows:
.github/workflows/release-stamp.yml— manual trigger (Actions tab → Stamp Release → Run workflow → enter the new semver, e.g.1.5.0). The workflow validates the version, requires non-empty[Unreleased]content inCHANGELOG.md, runs the full PHPUnit + PHPStan suite as a guard, then stamps[Unreleased]to[X.Y.Z] - DATE(UTC) — leaving a fresh empty[Unreleased]block for the next cycle — commits, tagsvX.Y.Z, and pushes both..github/workflows/release.yml— fires automatically on thevX.Y.Ztag push. Extracts the matching CHANGELOG section, derives the merged-PR list from squash-merge commit subjects between this tag and the previous tag, and creates the GitHub Release with structured notes (What's Changed/Pull Requests/Full Changelogcomparison link). Marks the release as Latest only when the new tag is the highest semver, so back-dated patch tags don't steal the badge.
Per-PR conventions
Add entries under ## [Unreleased] in CHANGELOG.md with Keep a Changelog categories (### Added, ### Changed, ### Deprecated, ### Removed, ### Fixed, ### Security). Squash-merge PRs into main so the merge commit subject ends with (#N) — the auto-release workflow uses that to assemble the Pull Requests section.
Distribution scope
.gitattributes marks CHANGELOG.md, tests/, .github/, .ddev/, phpunit.xml, phpstan.neon, and other dev-only files as export-ignore, so composer require parisek/timber-kit only pulls src/, composer.json, LICENSE, and README.md into the consumer's vendor/ tree. No Composer-side archive.exclude config is needed — .gitattributes covers both composer archive and GitHub source-zip downloads.