Changelog
Release notes for Pollora, the Laravel framework for WordPress: every stable and pre-release version of pollora/framework, newest first.
Pollora's version numbers follow the Laravel release it is built on. The skeleton (pollora/pollora) is tagged
with the same number as the framework it ships. Releases are also published on
GitHub.
v13.34.1
Removed
- The
use_default_wp_theme_directorykey ofconfig/wordpress.php. Nothing ever read it: setting it totruechanged nothing, and themes always live inthemes/(#297)
Fixed
- Under WordPress 7, a classic theme’s block styles and
global-styleswere printed at the bottom of every page, after the content they style — a flash of unstyled content on slow connections — and the head kept two empty placeholders. WordPress loads those styles on demand atwp_footer, then moves them back into the head through its template enhancement output buffer, which only starts whentemplate-loader.phpincludes a template: a Blade page includes none. AWordPressTemplateEnhancementmiddleware now plays the buffer’s part on the response —wp_template_enhancement_output_buffer_startedbefore the view renders, thewp_template_enhancement_output_bufferfilter andwp_finalized_template_enhancement_output_bufferon the HTML — forRoute::wp()routes and the template hierarchy, so any plugin built on that filter sees Pollora’s pages too. A plain Laravel route that printswp_head()/wp_footer()can add the middleware itself pollora:installwithout a terminal —--no-interaction, CI, orpollora newdriving it — stopped on “Site title is required” unless every option was passed: the prompts it could not show were still required. It now fills what is missing with a working local site — the project name as title (the first label of theAPP_URLhost: under DDEV the directory is alwayshtml),adminatadmin@<APP_URL host>, a generated password shown once (also with--install),en_US, not indexed — and keeps every option it is givenpollora:statusand the dashboard reported a post type or taxonomy under a slug derived from its class name, ignoring the attribute:#[PostType('synthese-presse')] class SyntheseDePressewas listed assynthese-de-presse, a post type that does not exist, and itsplurallabel was ignored too. Both read the attribute now, throughPostType::resolveSlug()/Taxonomy::resolveSlug(), the rule discovery registers with, so they cannot disagree again (#298)
v13.34.0
The first stable release since v13.4.4, on Laravel 13.34. It gathers every change from v13.32.0-beta to v13.34.0-beta.2 below; coming from 13.4, read those entries, or run Nectar’s upgrade-pollora-v13-32 prompt (full comparison).
Fixed
- A plugin with neither
app/norsrc/— one that only ships blocks, which need no class — was discovered from its root,node_modulesincluded: 2 to 7 seconds on every request in debug mode, measured on a blocks-only plugin with a Vite build. Its other top-level directories are scanned instead, nevernode_modules,vendor,bower_components,build,dist,public,resources,assets,languages,langor a hidden directory
v13.34.0-beta.2 Pre-release
Added
pollora:doctor: checks a project for the failures that stay silent — the site renders, the command exits 0 — and prints, under each, the command that fixes it. Each check comes from a failure met in practice: WordPress core not patched or__()not Pollora’s;patches.lock.jsonmissing or older than the framework’s patches;.envnames Pollora does not read (DB_NAME,WP_HOME…) or MySQL settings on a sqlite connection; configuration or routes cached outside production; classes missing from the discovery cache; for the theme, every Pollora plugin and every enabled module: a build missing, written to another folder than Pollora reads, or a hot file pointing at a dev server that is stopped or not exposed, a directory symlinked under another name,%theme_*%/%plugin_*%placeholders or.stubfiles left from a copied template, blocks still in the legacyresources/blocks; pattern files WordPress never registers or has not cached;Route::wp()routes answering in place of a block theme’s templates.--jsonfor scripts; exits 1 on an error- The same checks in WordPress’s Site Health (Tools › Site Health), with a “Pollora” badge, plus one that only a web request can make: every block of the theme, the Pollora plugins and the modules is registered. That is the boot a visitor gets — blocks were once registered under WP-CLI but not over HTTP, which a console check would have passed
Fixed
- Deleting a navigation menu (
wp menu delete, or the Menus screen) raised aTypeErroronce the menu was already gone:delete_nav_menuis thedelete_{$taxonomy}hook, which passes the term ID first, and the listener expected aWP_Term.MenuDeletednow receives the menu WordPress copied before deleting it - While Vite ran hot, its client was enqueued with WordPress’s version appended (
@vite/client?ver=7.1.2). The modules Vite serves import/@vite/clientby its bare URL, so the browser loaded the client twice, as two modules with two HMR connections. It is enqueued with no version, like the entries (regression from v13.34.0-beta) - On a branch install (
dev-develop,13.x-dev), Site Health and the admin notice announced “Pollora 13.4.4 is available”:version_compare()ranks a branch name below every release. A development build is no longer compared with releases — Site Health says it is one, the notice stays silent — and the dashboard, the admin menu badge andpollora:statusshare that one rule, which they each duplicated and missed13.x-dev. Site Health’s info tab now labels the compared version “Latest stable version”, since pre-releases are not counted
Changed
pollora:make:themeactivates the generated theme only where the site needs one. A site with no usable theme — a first install — gets it without a question; a site that already has one keeps it unless the answer is “yes”, now the default “no”, so--no-interactionnever replaces a working theme.--activateand--no-activatesettle it without asking, andpollora:installpasses--activate. Activation goes throughswitch_theme(), which firesswitch_themeandafter_switch_theme; the options were written directly before, so those hooks never ran
v13.34.0-beta Pre-release
The framework’s version tracks Laravel’s: this beta requires Laravel 13.34.
Added
- A third
pollora:make:themetemplate, Magazine (magazine→pollora/theme-buzz): a Full Site Editing block theme whose templates, parts and patterns are edited in the Site Editor, next todefaultandecommerce. The missing-theme page and admin notice list it too
Fixed
- A Vite script was printed before WordPress’s import map, which Firefox and Safari then ignore: any WordPress script module on the page — the navigation block’s, the search block’s, the image lightbox’s — failed on
@wordpress/interactivity was a bare specifier, so the block did nothing. Chromium tolerates the order, which hid it. In a classic theme the import map is always in the footer, so any theme with a Vite script in the head was affected as soon as an author inserted such a block. The Vite client of the dev server had the same problem - A block theme’s own
404.htmlanswered with HTTP 200. WordPress core resolves it towp-includes/template-canvas.php, which is never a Blade view, so it always rendered throughFrontendController’s raw-PHP-template branch — the only branch that never looked atis_404(). Measured on a fresh block theme: right content, wrong status - A real 404 lost its
error404body class, and every page served by the template hierarchy carried a meaningless one built from its path (any-no-such-page). TheWordPressBodyClassmiddleware was meant for Laravel routes, which WordPress’s own resolution calls a 404, but it only ran on WordPress routes — the{any}fallback included — where WordPress’s verdict is the right one. So it did the opposite of its job on both sides: a Laravel route (Route::get('/dashboard/{tab}')) kepterror404,is_404()true and a “Page not found” title over its 200 response
Changed
- Requires Laravel 13.34:
illuminate/*^13.34(was^13.32). Measured onlaravel/frameworkv13.34.0: the full suite, Pint, PHPStan and Rector pass unchanged - The
WordPressBodyClassmiddleware is replaced by aRouteMatchedlistener,ApplyApplicationRouteContext, which runs on every route: a route WordPress answers (Route::wp()and the template-hierarchy fallback, both flaggedisWordPressRoute()) keeps WordPress’s classes and verdict untouched; any other route hasis_404()cleared and its URI segments added as body classes (dashboard tab-settings). A middleware could not do this — Laravel routes are not given the WordPress middleware stack - On the front end and in the admin, a Vite script is enqueued as a WordPress script module (
wp_enqueue_script_module), so WordPress places it after its import map, as it does its own modules: in the head of a block theme (the footer withloadInFooter()), always in the footer of a classic theme. What a module cannot take —dependencies(),localize(),inline()— goes on a classic companion script,{handle}-data, which runs before the module. The editor, login screen and Customizer are unchanged
v13.32.0-beta.9 Pre-release
Added
<InnerBlocks />in a block’srender.blade.php: the block’s inner blocks are edited in place in the editor, inside the rendered template, and printed in place of the tag on the page, in adivcarrying the tag’sclass(pollora-inner-blocksby default). The tag takes Gutenberg’s inner blocks options —allowedBlocks,template,templateLock,orientation… — as attributes, JSON for arrays and objects. Modelled on ACF’s<InnerBlocks />. Arender.phptemplate gets it too- The block editor runtime
window.pollora.blocks(script handlepollora-block-editor, a dependency of the editor script of every block with arendertemplate):bladeEdit(metadata)previews the template through the core block-renderer route and makes its<InnerBlocks />editable;savestores the inner blocks. Shipped inline, as nothing undervendor/has a public URL $isPreviewin a block template: true while it renders for the editor’s previewdevelopis aliased to13.x-dev(extra.branch-alias). Without it,dev-developsatisfied no version constraint: a project ondev-developcould not install a package requiringpollora/framework^13.0—pollora/nectar1.1 does — without an inline alias of its own
Fixed
pollora:make:block --inner-blocksmade a dynamic block whose inner blocks were neither editable in its preview, saved in the post, nor printed by its template. It now writes<InnerBlocks />inrender.blade.phpTemplateMarker’s documentation no longer says every response goes throughtemplate_include: responses fromRoute::wp()and Laravel routes bypass it and carry no marker, as the hierarchy browser tests assert- “Validate Changelog” no longer fails the pull request of every release. It required a non-empty
[Unreleased]on any pull request tomain, which a release fromdeveloporrelease/*has emptied into the version’s section by construction; it now checks hotfixes only
Changed
pollora:make:blockgenerates dynamic blocks on the runtime:edit.jsxiswindow.pollora.blocks.bladeEdit(metadata)instead ofServerSideRender,saveiswindow.pollora.blocks.saveinstead of() => null, and@wordpress/server-side-renderis no longer added topackage.json. Blocks made before keep working- The framework no longer declares the wpackagist repository, from which it required nothing. Composer still fetched its metadata on every install, so a wpackagist network error failed the build — measured: the nightly of 2026-09-27 failed “Code Quality” on
curl error 56from wpackagist.org - The installed package no longer carries the test suite, the CI workflows and the tooling configs:
.gitattributesleaves them out of the archive Composer installs. The browser tests stay reachable by cloning the repository, which is how theme CI fetches them
Removed
get, a grep output committed by mistake
v13.32.0-beta.8 Pre-release
No change to the framework’s code: this beta ships the browser test suite that now guards it, 73 tests per browser.
Added
- Browser tests of the template hierarchy. Two fixture themes: one has a template for every case, the other
index.blade.phpalone. Each URL is read in the framework’s template marker, in the template’s own output and in the HTTP status:front-page,home,page-{slug},page-{id}, a custom page template,page,single,single-{type},archive-{type},archive,taxonomy-{tax}-{term},taxonomy-{tax},category-{slug},tag,author-{nicename},date,search, and404with a 404 status; a Blade view over a PHP template of the same name;Route::wp()and Laravel routes over the hierarchy. Withindexalone every case falls back to it, and an unknown URL still answers 404. Replayed: without the 404 view, or with the marker disabled, the suite fails - Browser tests of the framework’s features, through a fixture plugin that declares each by attribute and checks it by its effect:
#[Filter]and#[Action], a#[PostType](REST type, archive, admin menu),#[WpRestRoute](a public route; anIsAdminone that refuses a visitor and answers an administrator),#[Ajax]for visitors and logged-in users, a script enqueued through theAssetfacade, every script and stylesheet of the home page loading with nothing over plainhttp://,get_theme_file_uri()giving the active theme’s built entry a URL that answers, no server path in the page a visitor gets, the theme’s menu locations and login screen, and__()sending a text domain to WordPress’s catalogues and replacements to Laravel. Replayed: with the v13.32.0-beta.6 theme URI fix undone, or with WordPress’s side of__()disabled, the suite fails - The browser tests run in Firefox and WebKit as well as Chromium every night, and on a manual run of the workflow (
E2E_BROWSERS). Pull requests keep Chromium alone. The schedule runs frommain, so it starts with this release
v13.32.0-beta.7 Pre-release
Fixed
pollora:make:blockrefuses a theme or plugin that cannot build a block, and says why. A plugin made without assets has neitherpackage.jsonnorvite.config.js: the block was written anyway, could not be built, and thenpm installthe command advised walked up to the site’s ownpackage.jsonand installed there. It now stops before writing anything and names the missing filespollora:make:plugin --assetincludes the asset files. The option takes a value, so the bare flag read as null, which the defaults applied without a terminal turned intofalse:--assetalone left the assets out.--asset=falsestill leaves them out, and the other options keep their defaults- A block title holding a quote (
--title="Owner's Hero") no longer breaks the JavaScriptpollora:make:blockwrites.render.blade.phpescaped it;edit.jsxandsave.jsxprinted it raw inside a single-quoted string, so the block failed to build - A block’s editor script depends on every WordPress script its code imports.
BlockRegistrargave each editor script a fixed list —wp-blocks,wp-element,wp-block-editor,wp-i18n— while@roots/vite-pluginturns every@wordpress/*import into a global and records the matching handles ineditor.deps.json. A block importing@wordpress/componentsor@wordpress/server-side-renderworked only when something else happened to load that script. The handles in the build’seditor.deps.jsonare now added to the defaults; in dev mode, which has no build, the defaults stand - Blocks shipped by themes, plugins and modules exist in the block editor, on the page and in the REST API. They were registered by a
BlocksServiceProviderof the module’s own, and over HTTP no shape of that provider works: WordPress is loaded, andinithas fired, before theme and plugin providers boot, so the providerpollora:make:blockwrote — hookinginitfromboot()— was never called, and a REST request is answered before those providers boot at all. Blocks existed in WP-CLI only, which is where they had been checked. Measured on a fresh install: theme-default’sheroandcall-to-actionwere missing from the editor and from/wp/v2/block-types. The framework now registers every module’sresources/views/blocks(and the formerresources/blocks) itself oninit, from a hook set while it boots — before WordPress loads — and creates the asset container of a Laravel module that ships blocks.pollora:make:blockno longer writes a provider. ABlocksServiceProviderkept from an earlier release is harmless: a block WordPress already holds is skipped. Covered by browser tests that insert each block in the editor, reload it, and find it on the published page - Themes and plugins keep working on a site whose autoloader was dumped with
--classmap-authoritative. Their namespaces (Theme\…,Plugin\…) were added at runtime to Composer’s root loader, and an authoritative loader answers from its classmap alone: it never looked at them, so every front-end page answered 404 (Class "Theme\Apiary\Walkers\MenuPrimary" not found) whilewp-login.phpstill worked. Module namespaces now live in a class loader of their own, registered after Composer’s, and shared by the theme and plugin autoloaders under theModuleAutoloader::CLASS_LOADERcontainer key. It has no vendor directory, so it stays out ofClassLoader::getRegisteredLoaders()and cannot moveApplication::inferBasePath(). Measured on a live site in both modes: pages, the PHPUnit and HTTP suites, and the theme sweep all pass. The root loader is no longer bound toComposer\Autoload\ClassLoaderin the container as a side effect php artisan test, and anything else that asks Laravel for its base path, no longer resolves it to a plugin’s directory.ModuleAutoloader::register()registered the root Composer loader again although it was already active, and Composer answers that by moving it to the end ofClassLoader::getRegisteredLoaders()— behind the loader of any plugin shipping its ownvendor/, such as Query Monitor.Application::inferBasePath()reads the first entry of that list, so on such a site 38 of 40 of its PHPUnit tests failed openingplugins/query-monitor/bootstrap/app.php. Composer’sInstalledVersions::getRootPackage()read the same list and reported the plugin as the root package, over HTTP, in artisan and in WP-CLI. Namespaces added withaddPsr4()take effect on an active loader, so the loader is now only registered when it is not already. The bug dates back to June 2025 and shipped in v13.4.4patches/mockery-php84-nullable.patchis back onmain, byte for byte as v13.4.4 shipped it. The v13.4.x line declares that patch by the URLrefs/heads/main/patches/mockery-php84-nullable.patch, and a releasedcomposer.jsoncannot change; removing the file frommainon 2026-09-17 made the URL answer 404, and composer-patches v1 skips a patch it cannot fetch with only a warning — so every v13.4.x install since went without it. Nothing on the 13.32 line references the file: its patch URLs are pinned to commits- The patches this package asks its consumers to apply are pinned to the commit that produced them, instead of being served from
refs/heads/main. A releasedcomposer.jsoncannot be changed, so a branch URL moves under every version already published: removingmockery-php84-nullable.patchfrommainon 2026-09-17 turned it into a 404 for every v13.4.x install, which composer-patches v1 skips with only a warning. With composer-patches v2 a moved patch is worse — its checksum no longer matchespatches.lock.jsonand the install fails. A Patches CI job now proves each URL pins a commit in the branch’s history whose file matches the working tree. Versions already released keep the URLs they shipped with - Generators write a theme’s, plugin’s or module’s classes where its autoloader reads them. The autoloaders map a namespace onto
app/when it exists and ontosrc/otherwise — one directory, never both — while every--plugin,--themeand--modulegenerator wrote intoapp/unconditionally. On a target keeping its classes insrc/, that was worse than one misplaced file: creatingapp/flipped the autoloader and discovery over to it, and every class already insrc/stopped being loaded. They now resolve the directory with the autoloaders’ own precedence, and a target with neither still getsapp/ - The
BlocksServiceProviderthe scaffolder writes registers its blocks oninit, which is when WordPress accepts block registrations at all. It calledregisterDirectory()straight fromboot(), and providers boot before WordPress has definedregister_block_type()—BlockRegistraranswers that by returning immediately, with no error and no notice, so the blocks simply never existed.theme-defaulthit this, shipped v1.4.0 with it, and fixed its own copy by hand; the stub that writes every other provider kept the broken shape, so each new theme and plugin was born with the bug pollora:make:blockwrites theBlocksServiceProviderwhen the target has none. A blocks directory that is not empty was taken as proof that the infrastructure was in place, and it usually is — but blocks registered by something else live there too, and none of them needs the provider that registers Vite-built blocks. In a target liketheme-apiary, whose only shipped block is an ACF one, every block ever scaffolded was written to disk, built by Vite, and registered by nobody: nothing failed, nothing reached the log, and the block simply never appeared in the editor — permanently, since the directory only gets less empty. Measured on a live site, then pinned end to end in the theme’s CI. Only the provider is created; the npm dependencies and the initial Vite patch still belong to a genuinely first blockcomposer installandcomposer updateno longer fail on a Pollora site that has no terminal.pollora:env:setupruns from composer’spost-autoload-dumphook, so it fires inside container builds, deployments and CI; when the database was not reachable it reached for Laravel Prompts, which throws where there is nothing to prompt, and composer exited 1 reporting a prompting problem rather than a database one. Nothing in that command is a gate —pollora:installis what refuses to continue without a database — so with no terminal it now names what is wrong, names the command to run once the database is up, and lets composer finish
Changed
- The block editor shows what the page shows for a block made by
pollora:make:block. A dynamic block’sedit.jsxrendersrender.blade.phpthroughServerSideRenderfor the block’s current attributes, and a static block’s mirrors the markupsave.jsxwrites; both used to show a placeholder (”… – Block Editor”) the page never displayed. A block with--inner-blockskeeps theInnerBlockseditor, which a server render cannot provide.@wordpress/server-side-renderjoins the npm dependencies added with a first block. Checked in a browser: the preview holds the rendered text, and the old stub fails the same test cweagans/composer-patchesmoves from^1.7to^2.0. It is the plugin that applies the WordPress l10n patch — renaming WordPress’s__()to__wp()so Laravel’s helper can have the name — on every Pollora site. Measured before merging: on a fresh install, v2 resolves the framework’s patch as a dependency patch and applies it, and the skeleton’s check that WordPress core carries it passes. Two things change for a project. v2 writes apatches.lock.jsonrecording each patch’s sha256, and on a machine without the patch cached it downloads the patch again and fails withHashMismatchExceptionwhen the file no longer matches — so a patch served from a moving URL can break fresh installs of a project that locked an earlier version. And the skeleton’senable-patchingoption is no longer read: v1.7.3 checked it and defaulted to off, v2 resolves dependency patches unconditionally- The commit-hook tooling is declared where it belongs.
@commitlint/cli,@commitlint/config-conventional,husky,lint-stagedandprettiersat underdependenciesrather thandevDependencies, so every advisory in their tree was reported against this repository at runtime scope — ten high-severity alerts describing packages no Pollora site has ever loaded. Nothing installs differently:npm ciinstalls devDependencies by default, and the commit hook was checked after the move - Every open npm advisory clears:
fast-urimoves to 3.1.8 andjs-yamlto 4.3.2, past the 3.1.6 and 4.3.2 that patch them.npm auditgoes from 2 high-severity vulnerabilities to 0 - The contribution guide documents the pull request title convention. CI validates the title of every pull request against the conventional commit format, and rejected one said only that the check had failed — a rule enforced on a first contribution and published nowhere. The allowed types are listed, along with the fact that Validate Changelog only runs on release pull requests into
mainand never concerns a contribution todevelop
v13.32.0-beta.6 Pre-release
Fixed
get_theme_file_uri()answers the URL the build gave a theme file, instead of an empty string. It returned''for every path there is — measured on a live site, on the front end as much as on wp-login.php. WordPress hands thetheme_file_urifilter both the URL it built and the file it was asked for; the file was thrown away and rebuilt by subtractingget_stylesheet_directory_uri()from the URL, then handed to the asset resolver, which prefixes the container’s own root a second time.resources/assets/app.jswas looked up asresources/assets/resources/assets/app.js, never found, and the failure was logged and swallowed — one line inlaravel.logper call. Both spellings now resolve: the path from the theme root a caller would write, and the path relative to the container root the manifest is keyed on. A theme’s own directory is not web-served on a Pollora project — measured,/themes/…and/content/themes/…both 404 while/build/theme/…serves — so the build is the only address a theme file has. Anything the build does not know about is handed back untouched rather than emptied- The server’s filesystem path no longer reaches the public HTML.
get_theme_root_uri()can only turn a theme root into a URL when it sits underWP_CONTENT_DIR; a Pollora project registers its themes at the project root, and for a root it cannot map WordPress returns the path verbatim — soget_stylesheet_directory_uri()answered/var/www/html/themes/apiary, and WordPress’s speculative-loading rules printed it into the<head>of every front-end page. Measured: one occurrence per page before, zero after. Only an answer that is not a URL is replaced, so a project keeping its themes under content is untouched
v13.32.0-beta.5 Pre-release
Added
- The login screen wears the theme’s design, from the theme’s own theme.json. WordPress loads the theme on
wp-login.phpbut emits none of its design there — measured: zero occurrences ofwp--preset--colorin the HTML of a login screen — so every site logs in through the same grey form whatever it looks like elsewhere. A theme that ships aconfig/login.phpnow gets its colours, radii and typography printed onlogin_head, its own logo above the form, and its home page behind that logo instead of wordpress.org. Nothing is duplicated: the design stays in theme.json, and restyling a theme restyles its login screen. Sign-in, lost password, reset, register and the confirm-admin-email prompt are all covered, being one screen as far aslogin_headis concerned. Strictly opt-in — a theme with noconfig/login.phpgets WordPress’s screen, byte for byte, so upgrading the framework never changes the page people sign in through. See Theming → Login screen pollora/login/palette,pollora/login/logo,pollora/login/stylesandpollora/login/creditfilters, for a module or a plugin to take over any part of the login screen without touching the theme- Discovery says so when one location takes more than 250ms to scan, naming the location, the time and how many structures it found. The cost above was absorbed in complete silence for as long as it existed — no log line, no notice, nothing that measured it. Debug mode only: with the cache on, the cost is paid once
Changed
- A theme’s
config/login.phpis loaded oninit, likemenus.php,sidebars.phpandtemplates.php, so__()works in it. WordPress firesinitthroughwp-load.phpbeforewp-login.phpfireslogin_init, so the config is in place well before anything reads it
Fixed
- Attribute discovery no longer walks a plugin’s
node_modules. Themes and modules have always been scanned atapp/, falling back tosrc/— the two directories the autoloader mapsPlugin\{Name}\onto — while plugins were scanned at their root, which is wherenode_modules/lands as soon as a plugin has a Vite build. Symfony’s Finder enumerates every file before filtering by extension, so the walk costs everything and the extension check costs nothing: measured on a demo plugin with a block build, 69,741 files walked on every request to reach the three PHP files the plugin owns — 1,691 ms against 1 ms for the same directory withoutnode_modules. It also discovered five WordPress core classes shipped inside@wordpress/style-engineand handed them to the discovery pipeline, which is its own kind of wrong. WithAPP_DEBUGon, discovery keeps no cache, so a site paid this on every request: the home page of a site with one such plugin went from 3.0s to 0.78s, and from 691MB of peak memory to 37MB under Blackfire. A plugin shipping neitherapp/norsrc/is still scanned at its root, so one keeping its classes at the top level is unaffected
v13.32.0-beta.4 Pre-release
Added
- Every page says which template answered it, as an HTML comment on
wp_head, wheneverWP_DEBUGis on. WordPress offers no way to see which template rendered a request short of reading the code and guessing, and a Blade hierarchy on top makes the guess harder — several files could plausibly have answered, and which one did is the whole question when a page comes back wrong. Themes had started marking their own views by hand, one at a time, and drifting: theme-apiary marks most of its templates and not its front page. This is derived from the template the request actually resolved to, so it cannot be forgotten, and it reaches every theme without one of its own. A comment changes no markup and no styling; nothing at all is emitted in production, and the path is relative to the project so it never says where the site lives on disk
Fixed
- A failed starter download says what happened instead of raising a TypeError.
make:themeandmake:pluginfetch their template from GitHub and, when that fails, fall back to copying one bundled with the framework — except none is bundled: neithersrc/Theme/stubs/norsrc/Plugin/stubs/exists.getTemplatePath()declaredstringand returnedrealpath()’sfalse, so a transient network failure surfaced asgetTemplatePath(): Return value must be of type string, false returned, naming a method the user has never heard of and saying nothing about the download. Measured on CI the first time GitHub did not serve an archive. Both commands now fail with a sentence that names the cause - A REST controller declaring
WP_REST_Request $requestreceives it. Handler arguments were built from the route parameters alone, so that type hint hadget_param('request')looked up — no route declares a parameter by that name — and the method was invoked withnull, fatalling on a TypeError before a line of it ran. Every endpoint whose handler asked for the request answered 500. A parameter type-hintedWP_REST_Requestnow gets the request; everything else still comes from the route, and builtin types are left alone since they name route values - WordPress’s own entry points answer their real status again.
wp-login.php,wp-links-opml.phpand the rest are run directly by PHP, with Pollora loaded along the way throughwp-config.php; it resolved the URL as a content request anyway, and/cms/wp-login.phpmatches the attachment rewrite rule[^/]+/([^/]+)/?$. No such attachment exists, so WordPress sent a 404 header — and then rendered a perfectly good login form underneath it. The query now runs only when Laravel’s front controller is the script being executed, which covers any entry point WordPress or a project adds later without naming it.pollora_loadedstill fires either way /cms/no longer answers with the source of a Blade template. The WordPress installation root is served by WordPress’s ownindex.php, whosetemplate-loader.phpincludes whatever the template hierarchy hands it — and Pollora puts Blade sources in that hierarchy, since it renders them through Laravel. PHP printed one verbatim, directives and all. A front-end request that reaches that loader has bypassed Laravel and was looking for the site, so it is redirected to the home URLmake:themeandmake:plugindownload the highest version of a starter, not whichever tag GitHub listed first. That order follows what was pushed most recently, so a fix tagged on an older line — 1.2.1 published after 1.4.0 — would have been handed to everyone scaffolding, a downgrade with nothing to notice it by. Tags are now compared as versions, pre-releases lose to any stable version so tagging a beta does not push it onto people who asked for nothing in particular, and a tag that is not a version at all is left out of the comparison rather than winning it. A repository whose tags follow some other scheme keeps its previous behaviour. The request also asks for 100 tags instead of the default 30, which could leave the highest version on an unread second page
v13.32.0-beta.3 Pre-release
Fixed
- The block editor now shows the theme’s own styles, so a block looks in the back office much like it does on the front. Both default themes declare
editor-stylessupport, but that support only tells WordPress how to treat the styles it is handed — it loads none, and nothing named any: theme assets are registered withtoFrontend(), leaving the editor with theme.json’s variables and none of the rules built from them. The theme’s built stylesheets are read from its Vite manifest and passed toadd_editor_style(), which is what reaches inside the editor iframe; enqueueing onenqueue_block_editor_assetslands in the admin page around it instead. Any theme declaring the support gets this, with no change of its own - Multisite is usable again.
Bootstrap::rewriteNetworkUrl()is hooked to thenetwork_site_urlfilter, which WordPress applies with a$schemeofnullwhenever no explicit scheme is requested — the common case — while the method declared a non-nullablestring $scheme. Every request fatalled as soon asMULTISITEwas enabled, the admin included.$pathand$schemenow carry defaults matching the filter’s own signature; single-site installs never reach this path (#296) pollora:installno longer leaves a site whose articles answer 404. It set the permalink structure and flushed, but the rule set WordPress can build mid-install is incomplete — taxonomies and post types are not all registered at that point — so flushing stored exactly that, and every single-post URL 404’d until something flushed again later. The option is dropped instead, letting WordPress rebuild it on the first request. This is the same reasoningcompleteWebInstall()already applied to the WordPress web installer; that hook is registered only outside the console, so the command-line install never went through it. Measured on a fresh install: 404 on an article before, 200 after- The missing-theme guidance page now reaches the case it was written for. It was wired only onto the exception handler, catching the
View [home] not foundthatview()used to throw; since the skeleton stopped declaring WordPress routes the template hierarchy decides, nothing callsview(), nothing throws, and a site with no theme answered a bare 404 instead — the crash the page exists to replace, wearing a different status code. The request is now taken over ontemplate_redirect, which WordPress fires before any template is chosen and whichrunWp()reaches before Laravel routes, alongside the existing handling for robots, favicons, feeds and trackbacks. The exception path stays for anything still routing throughview(), and its registration no longer sits behind a guard that could skip the hook. The admin, AJAX and REST keep their normal responses: the admin is where the user goes to fix this - Blade templates now outrank the PHP stubs at a theme’s root. The root holds the files WordPress needs to consider the theme valid —
index.phpin particular, which ships as “Silence is golden” — and was registered as a view path ahead ofresources/views, solocate()ranked itsindex.phpaboveresources/views/index.blade.php. Every request the template hierarchy could not match to a more specific template (archives, search, custom post types, any single view without its own template) rendered that stub: HTTP 200 with an empty body and no error anywhere. Blade and PHP candidates are now collected separately so every Blade one wins, and view paths are registered in the order they are declared. A classic PHP template with no Blade equivalent is still used - Installing WordPress on a URL where a site already answers no longer dies with
Class "WP_Http_Cookie" not found.wp_install()callswp_remote_get()on the site URL andWP_Httpbuilds aWP_Http_Cookiefor everySet-Cookieheader it gets back, but the install bootstrap loaded only part of the HTTP stack. It now loads the classeswp-settings.phploads, in its order. This affected reinstalls, migrations, and taking a domain back - wordpress.org no longer offers updates for the themes the project scaffolds. WordPress sends every installed theme to its update API keyed by the directory name, so a theme whose name matches a published slug is offered that unrelated theme —
default, whichpollora:installgenerates, matches one, and accepting the offer replaced the project’s theme with a download of the theme published there in 2005. Offers aimed at a theme in the project’s themes directory are moved tono_update. TheUpdate URIheader does not cover this on its own: WordPress consults the matchingupdate_themes_{$hostname}filter only when wordpress.org returned nothing for that theme - A site installed through the WordPress web installer no longer answers 404 on every category, tag and date archive. The rewrite rules WordPress stores during its own install are incomplete — taxonomies and post types are not all registered at that point — and nothing regenerated them afterwards. The option is now dropped on
wp_install, where the migrations already run, so WordPress rebuilds the rules on a later request - wp-admin no longer reports the active theme as missing while the front end renders it.
get_stylesheet_directory()goes through thetheme_rootfilter Pollora sets, butwp_get_theme()computes the root itself and no filter applies there. Registering Pollora’s themes directory replaced$wp_theme_directoriesinstead of appending to it, andget_raw_theme_root()answers a hardcoded/themeswhenever a single directory is registered, whichwp_get_theme()then resolves againstWP_CONTENT_DIR. WordPress’s own themes directory is kept alongside Pollora’s, and thestylesheet_rootandtemplate_rootoptions now hold the directory containing the themes rather than the active theme’s own directory, which was one level too deep - A site installed through the WordPress web installer explains what to do instead of crashing. That path never runs
pollora:install, sothemes/stays empty while thestylesheetoption already points atWP_DEFAULT_THEME, and every front-end request died onView [home] not found. A missing view on a front-end request now renders a page naming the two theme templates and the command that scaffolds them, and wp-admin carries the same warning. It applies only while the site has no theme at all, so a headless install keeps working and a theme missing a single template still surfaces its real error
v13.32.0-beta.2 Pre-release
Added
- pollora.dev as the project website and documentation:
homepageandsupport.docsincomposer.json(shown on Packagist),homepageinpackage.json, the README, and a Documentation link in the Pollora admin dashboard header - Gutenberg blocks render with Blade: a
block.json"render": "file:./render.blade.php"renders through the view factory with$attributes,$contentand$block, Blade components included.pollora:make:blocknow creates dynamic Blade blocks by default — their markup is not stored inpost_content, so changing it never invalidates existing content — and--staticcreates a block withsave.jsx BlockRegistrar::registerDirectory()andregisterBlock()accept an optionalbasePath, the Vite project root the asset entry points are resolved from; it defaults to the closest directory holding avite.configfile, or else the one containingresources/
Changed
- Blocks live in
resources/views/blocks/{slug}instead ofresources/blocks/{slug}, next to the Blade views, forpollora:make:block, itsBlocksServiceProviderstub and the Vite entries it generates. See Migrating fromresources/blocks pollora:make:blocklimits Vite full reloads underresources/viewsto Blade files: laravel-vite-plugin’srefreshPathsand aresources/views/**glob would reload the page on every block JSX change instead of hot-replacing it- BREAKING for custom
BlockRegistrarInterfaceimplementations: both methods gained the optional?string $basePath = nullparameter - Relicensed under MIT, like the other Pollora packages:
composer.jsonand the README declaredGPL-2.0-or-later, and the only license files were the 0BSD/WTFPL texts inherited from the original WordPress integration by Jordan Doyle, now credited in the README - Development against WordPress 7.1 stubs (
php-stubs/wordpress-stubs^7.1);patches/wordpress-stubs.patchregenerated for them and reduced to the__()→__wp()rename. Itswp_mail()hunk no longer matched the stubs (WordPress added an$embedsparameter) and renamed a function nothing overrides mockery/mockery^1.6.11in development
Deprecated
- Blocks in
resources/blocksare still registered, with a notice in the log, until Pollora v15. A theme or plugin blocks directory scans both locations, and a block present in both is taken fromresources/views/blocks pollora:make:block --dynamic, now the default
Fixed
- A block
renderfile outside the block directory is no longer included: WordPress built its own render callback fromblock.jsonwhenever Pollora’s check rejected the path - Blocks built with
vite buildget their view script and stylesheets again: the Vite entries generated bypollora:make:blockonly listed each block’sindexscript, soviewScript,styleandeditorStylewere missing from the production manifest. Running the command again in a configured theme or plugin rewrites the entries pollora:make:blockaddswordpressPlugin()to the Vite plugins on first use: it imported the plugin, then skipped adding it because the name was already in the file- The Artisan commands renamed to the Laravel colon convention in v13.32.0-beta answer to their previous names again. The rename left no alias, so a project whose
composer.jsonstill ranpollora:env-setupinpost-autoload-dumpfailedcomposer installright after upgrading. Kept as aliases:pollora:env-setup,pollora:make-theme,pollora:delete-theme,pollora:make-plugin,pollora:make-block,pollora:make-model,pollora:make-action,pollora:make-filter,pollora:make-posttype,pollora:make-taxonomy,pollora:make-wp-cli pollora:installpointed to a non-existentwp:env-setupcommand when the database connection failed- Asset containers no longer share one Vite configuration. Each
ViteManagerreconfigured Laravel’sVitesingleton in place, so the last container set up — typically the theme — imposed its hot file on every other one: with the theme onnpm run devand a plugin built for production, the plugin’s assets resolved as built but were enqueued as hot, and the block editor crashed withforceFullUrl(): Argument #1 ($path) must be of type string, array given. ThegetAssetUrlsmacro likewise read the build directory of the last manager registered instead of its own, and the Vite client tag was rendered from the unconfigured singleton pollora:installcompletes without interaction (--no-interaction, CI, piped stdin). It calledpollora:make:themewithout a name, which failed withNot enough arguments (missing: "name")after WordPress was installed; it now generates thedefaulttheme frompollora/theme-default— the theme WordPress activates on install — or the one named by the new--themeoptionpollora:installruns its migrations with--forceand fails when they fail. In production,migrateasks for confirmation and cancels when it cannot prompt, yet the install reportedMigration completed successfully: a non-interactive production install left the sessions and cache tables missing and every page answered 500- Commands using
PromptsForMissingOption(pollora:make:theme,pollora:make:plugin) apply their prompt defaults when they cannot prompt, instead of leaving the options empty: a theme generated without interaction had astyle.csswith no author, URI, description or version
Removed
patches/mockery-php84-nullable.patch— Mockery fixed the PHP 8.4 implicit nullable deprecations upstream, so the patch no longer applied and printedCould not apply patch!on everycomposer install, including in projects built on the skeleton.patches/wordpress-core.patchstays: it is what letspollora/helper-overriderdeclare__()
v13.32.0-beta Pre-release
Pollora now follows Laravel’s version numbers: this release requires Laravel 13.32.
Added
- Translation diagnostics in
pollora:statusand the admin dashboard — reports whether the__()override is the one actually installed, alongside the WordPress and Laravel locales.laravel/frameworkdeclares__()behind the samefunction_exists()guard aspollora/helper-overriderand Composer emitsautoload.filesin dependency order, so whichever loads first wins; when Laravel wins nothing errors, WordPress catalogues simply stop resolving and every core, theme and plugin string silently renders untranslated. Detection compares the file declaring__()against the one declaringpollora_translation_resolver()— they ship in the samehelpers.php, so matching paths prove the package won the race whatever the install layout - WordPress Abilities API support through the new
pollora/abilitiespackage (requires WordPress 6.9)Abilityfacade for fluent declaration:Ability::define('acme/get-posts')->description(…)->category(…)->can(…)->using(…)#[Ability]attribute on classes implementingAbilityHandler, discovered automaticallyAbility::category()for the categories abilities file under, declared for you when an ability names one nobody registered- Behaviour annotations (
reads/creates/updates/deletes) published to AI clients asreadOnlyHint,destructiveHintandidempotentHint - Typed
SchemaBuilderfor input and output JSON Schema, and a defensiveInputreader - Permission checks receive the same input as the body, allowing per-object capability checks; they default to refusing
#[SkipDiscovery]attribute to exclude classes from the discovery engine- Classes annotated with
#[SkipDiscovery]are completely invisible to all discoveries — no reflection loaded, no attributes scanned exceptparameter for selective exclusion:#[SkipDiscovery(except: [HookDiscovery::class])]skips all discoveries except the listed ones
- Classes annotated with
- Publishable
config/discovery.phpwithskip_classesandskip_pathsarrays for config-level exclusions (third-party packages, vendor classes) DiscoveryRegistrar— automatically registers Discovery classes from the service container, eliminating manualaddDiscovery()calls in ServiceProviderspollora_register()helper withModuleTypeenum for simplified theme/plugin registration- Themes:
pollora_register(ModuleType::Theme)— 3 lines infunctions.php, auto-detects theme name and path - Plugins:
pollora_register(ModuleType::Plugin, 'my-plugin', __DIR__)— replaces manualPluginRegistrarwiring
- Themes:
#[Ajax]attribute for declarative AJAX action registration with security-by-default- Discovery system performance benchmark suite (
tests/Benchmark/) with realistic attribute complexity and multi-location DDD scenarios - Public method caching in
ReflectionCache::getPublicMethods()— avoids redundantgetMethods(IS_PUBLIC)reflection calls
Changed
- BREAKING: requires Laravel 13.32 (
illuminate/*^13.32). The framework version now tracks the Laravel release it targets, hence the jump from v13.4.3 - BREAKING:
Pollora\Services\Translaternow requires its$domainargument. The old'wordpress'default existed only to pair with thewordpress.key prefix thatpollora/helper-overrider1.2.0 removed — it prefixed every lookup as__('wordpress.'.$value)and relied on the resolver stripping it before the gettext call, so with 1.2.0 installed it silently returned values untranslated. Nothing in the framework used it (SidebarandMenusboth pass'sidebars'/'menus'explicitly); pass__($value, 'default')if you want WordPress’s core catalogue. Two latent bugs went with it: the prefix was stripped with an unanchoredstr_replace(), so a value such as'Go to menus.example'came back as'Go to example', andtranslateItem()was typedstringunderdeclare(strict_types=1), so a wildcardtranslate(['*'])over a config array holding an int or bool raised aTypeError - BREAKING: Ajax module extracted to
pollora/ajaxpackage - BREAKING: Option module extracted to
pollora/optionpackage - BREAKING: Hook domain and adapters extracted to
pollora/hookpackage - Domain layer paths restored for hexagonal architecture consistency
- Public API types exposed for Theme and Option modules
- All manual
addDiscovery()calls removed from ServiceProviders — replaced byDiscoveryRegistrarauto-registration - Empty
boot()methods removed fromLaravelPluginModuleandLaravelThemeModule(RectorRemoveParentDelegatingClassMethodRector) - Artisan command signatures renamed to Laravel colon convention (e.g.
pollora:make:theme) DiscoveryItems::all()optimized — replaced spread-in-loop witharray_merge- Redundant reflection pre-loading removed from
DiscoveryEngine
Fixed
WordPressHeadersmiddleware now respects theDONOTCACHEPAGEconstant set by WooCommerce and compatible cache plugins, preventing public cache headers on cart, checkout, and account pages__()no longer fatals on a translation with replacements whose key is absent from the current locale.__('Shipping :brand', ['brand' => 'Test'])raisedTypeError: Cannot access offset of type array in isset or emptywheneverLang::has()answered no: the guard normalised only the empty array to thedefaulttext domain, so a non-empty replacement array travelled into WordPress’stranslate()as the domain and reachedisset($l10n[$domain]). It hit every__($key, [...])call whose key was not in the locale’s catalogue — typically a site whose sources and language are both English, where noen_US.jsonexists at all, taking down every string with a placeholder. Requirespollora/helper-overrider1.2, which routes on the caller’s intent — a string second argument is a WordPress text domain, a non-empty array is a Laravel call — instead of on whether Laravel happens to hold the key. The same release fixes an unguardedLang::has()that raisedA facade root has not been set.wherever__()runs before or without an application, awordpress.prefix strip that rewrote the substring anywhere it appeared (__('Go to wordpress.org')returnedGo to org), Laravel group keys returning an array to callers typed against WordPress’sstring, and a missing base-language locale fallback that kept afr_FRsite from ever readinglang/fr.json- The
LoopandQueryfacade aliases no longer crash a page that uses them. Both were still declared inextra.laravel.aliasesafter their facade classes had been deleted —Loopsince9a50cac(2026-04-21),Querysince9c0cb42(2024-08-05). Laravel registers an alias without checking its target and only callsclass_alias()the first time the short name is used, so the package installed and booted cleanly and then threwClass "Pollora\Support\Facades\Loop" not foundon whichever page happened to call it. Present in every v13.4.x release. See the migration note below for what replacesLoop. PluginManagerTestisolation — realContainerwith dynamicWP_PLUGIN_DIRreplaces fragileContainerInterfacemock- WooCommerce hooks test updated for
ComingSoonHandlerdependency - PHPStan dead catches widened, redundant
@return $thisdocblocks removed OptionServicenamespace aligned with extracted package- GitHub releases are created again on tag push — the Deploy workflow lacked
contents: write; suffixed tags (-beta,-rc.1) are now published as pre-releases
Removed
- The
LoopandQueryentries inextra.laravel.aliases, whose facade classes no longer exist
Migrating from Loop
Loop:: was removed in 9a50cac as a breaking change, but the alias stayed
behind and the removal was never written down — so a project only found out when
a page 500’d. This is that note, late.
In Blade, the replacement is Sage Directives
(log1x/sage-directives, registered by PolloraServiceProvider): @title,
@content, @excerpt, @permalink, @published, @posts and the rest.
In PHP, Pollora\View\Loop was only ever a proxy over WordPress functions. The
full mapping, read off the deleted source rather than reconstructed:
| Removed | Replacement |
|---|---|
Loop::id() | get_the_ID() |
Loop::title($post) | get_the_title($post) |
Loop::author() | get_the_author() |
Loop::authorMeta($field, $userId) | get_the_author_meta($field, $userId) |
Loop::content($moreText, $stripTeaser) | apply_filters('the_content', get_the_content($moreText, $stripTeaser)), then str_replace(']]>', ']]>', …) |
Loop::excerpt($post) | apply_filters('the_excerpt', get_the_excerpt($post)) |
Loop::thumbnail($size, $attr, $post) | get_the_post_thumbnail($post, $size, $attr) |
Loop::thumbnailUrl($size, $icon) | wp_get_attachment_image_src(get_post_thumbnail_id(), $size, $icon)[0] ?? null |
Loop::link($post, $leavename) | get_permalink($post, $leavename) |
Loop::category($id) | get_the_category($id) |
Loop::tags($id) | get_the_tags($id) ?: [] |
Loop::terms($taxonomy, $post) | get_the_terms($post, $taxonomy) ?: [] |
Loop::date($format, $post) | get_the_date($format, $post) |
Loop::postClass($class, $postId) | 'class="'.implode(' ', get_post_class($class, $postId)).'"' |
Loop::nextPage($label, $maxPage) | get_next_posts_link($label, $maxPage) |
Loop::previousPage($label) | get_previous_posts_link($label) |
Loop::paginate($args) | paginate_links($args) |
Four of these are not straight renames, and a blind substitution breaks them:
thumbnail()andterms()reorder their arguments — the post moves from last to first — and both defaulted the post to the one in the loop.postClass()returned a ready-madeclass="…"attribute string, whereget_post_class()returns an array.content()andexcerpt()appliedthe_content/the_excerptthemselves; dropping the filter silently strips whatever other plugins add there.tags()andterms()normalisedfalseto an empty array.
Query:: has no replacement to document — the facade was already gone before
v13, and the alias outlived it by two years.
- Unfinished Admin Pages module
- Manual
addDiscovery()boot logic in HookServiceProvider, PostTypeServiceProvider, TaxonomyServiceProvider, WpRestAttributeServiceProvider, SchedulerDiscoveryServiceProvider RegisterScheduleDiscoveryUseCase(superseded byDiscoveryRegistrar)