PHPStan
The blockstudio/phpstan extension brings static analysis to Blockstudio
projects. It validates block templates, schema files, settings paths, and hook
names at analysis time, catching bugs before they reach runtime.
Installation
composer require --dev blockstudio/phpstan
The 7.7 template typing and expanded block tag checks require
blockstudio/phpstan 0.1.9 or newer and PHPStan 2.1.47 or newer. Update the
package separately from the plugin:
composer require --dev blockstudio/phpstan:^0.1.9
The extension auto-discovers via PHPStan’s extension installer. No manual configuration needed.
What it checks
Template field access
When a PHP file lives next to a block.json, the extension validates every
$a['key'] access against the block’s declared attributes.
<?php
echo $a['title']; // OK
echo $a['subtitle']; // OK
echo $a['typo']; // Error: Field "typo" does not exist in block.json
The extension knows that Blockstudio defines $a, $attributes, $b,
$block, $c, $context, $content, $isEditor, $isPreview, and the
island variables before it includes a template, so direct reads such as
$a['title'] are not reported as possibly undefined, and $a is typed from
the block.json beside the template. The shape survives narrowing, so
$a['label'] ?? '', isset( $a['cta']['target'] ) and
$a['cta']['title'] !== null keep the declared field types; a template that
assigns $a itself keeps the type it assigned. Do not add a
/** @var array<string, mixed> $a */ docblock: it replaces that shape with an
open array.
Field keys follow Blockstudio’s runtime flattening rules. tabs and anonymous
group containers without an id are presentation-only, so their child fields
remain in the parent scope. A named group prefixes its child keys with the group
ID:
echo $a['heading']; // Anonymous group child.
echo $a['cta_text']; // "text" inside the named "cta" group.
Reusable custom fields
File-backed custom/* references are expanded before template keys and array
shapes are checked. The extension reads the matching field.json and applies
the same idStructure, overrides, nested reference, group, tabs, and repeater
rules as Blockstudio:
{
"name": "mytheme/hero",
"attributes": [
{ "id": "heading", "type": "text" },
{ "id": "description", "type": "textarea" }
]
}
{
"blockstudio": {
"attributes": [
{
"type": "custom/mytheme/hero",
"idStructure": "hero_{id}",
"overrides": {
"heading": { "id": "title" }
}
}
]
}
}
The resulting template keys are title and hero_description. They are
recognized in PHP, Twig, Blade, block tags, and inferred attribute shapes.
Missing, ambiguous, invalid, or cyclic file-backed definitions produce a
specific blockstudio.customField.* error. Dependent key checks are skipped for
that block so one unresolved definition does not create a second wave of false
unknown-field errors.
Definitions registered only at runtime through the blockstudio/fields PHP
filter cannot be inferred by static analysis. Use a field.json definition when
the field must contribute statically checked template keys.
Twig template access
Twig templates are scanned automatically. No annotation needed.
<h1>{{ a.title }}</h1>
<p>{{ a.typo }}</p> {# Error: Field "typo" does not exist in block.json #}
Blade template access
Blade templates are scanned the same way.
<h1>{{ $a['title'] }}</h1>
<p>{{ $a['typo'] }}</p> {{-- Error --}}
Block tag validation
Block tags are validated where Blockstudio reads them: <bs:> and <block>
tags, the prefix tags configured in blockTags.prefixes (such as <acme-card>,
resolved in namespace order like the runtime), and alias tags, in PHP, Twig, and
Blade block templates and in page layouts. Page sources below pages/
(index.php, index.html, index.twig, index.blade.php) are checked as
well, including the HTML elements the project maps to blocks. Bundled UI blocks
are known when ui.enabled is on.
<bs:mytheme-hero title="Hello" />
<!-- OK -->
<bs:mytheme-nonexistent />
<!-- Error: unknown block -->
<bs:mytheme-hero title="Hi" badattr="" />
<!-- Error: unknown attribute -->
<bs:mytheme-hero class="wide" />
<!-- Error: class is dropped unless the block declares a class field -->
<bs:mytheme-hero id="intro" aria-label="Intro" data-x="1" />
<!-- OK: forwarded to the root element -->
<block name="core/separator" />
<!-- OK (core blocks always valid) -->
An attribute is accepted when the block declares it as a field (including
custom fields and the fields a block-typed field forwards under its
idStructure), or when Blockstudio forwards it to the block’s root element:
data-*, aria-*, id, style, role, title, lang, dir, hidden,
and tabindex. In templates every html-* attribute passes through; in page
sources an html- prefix only names one of the forwarded attributes. Anything
else, an undeclared class above all, is reported, because Blockstudio stores
it and never renders it.
The runtime registers element mappings and tag aliases in PHP filters, which
static analysis cannot evaluate, so the analysis reads them from
blockstudio.json. scanPhp also checks tags in other project PHP, such as
component markup in modules, without reporting unknown blocks there:
{
"phpstan": {
"blockTags": {
"elements": {
"section": "theme/section",
"h2": "theme/heading",
"div": [
{ "attribute": "width", "block": "theme/row" },
"theme/div"
]
},
"aliases": { "theme-button": "bsui/button" },
"scanPhp": true
}
}
}
An element maps to a block name or to rules tried in order: the first rule
whose attribute the element carries wins, and a plain block name always
matches. Elements a project does not map keep their core block and are not
checked. Diagnostics use blockstudio.blockTag.unknown,
blockstudio.blockTag.attribute, and blockstudio.blockTag.class.
Database record typing
Db::get() returns a typed instance based on the db.php schema. Record
shapes are inferred automatically.
return [
'storage' => 'table',
'fields' => [
'email' => ['type' => 'string', 'required' => true],
'name' => ['type' => 'string'],
],
];
$db = Db::get('mytheme/subscribers');
$record = $db->create(['email' => 'a@b.com']);
echo $record['email']; // string
echo $record['name']; // string|null (optional field)
echo $record['typo']; // Error: Offset 'typo' does not exist
Required fields are non-nullable. Optional fields are type|null. An id
field (int) is always present. The same typing works for the PHP-native
Blockstudio\\Db\\Schema / Blockstudio\\Db\\Field builder syntax.
Settings path validation
Settings::get() paths are checked against the known settings schema.
Settings::get('tailwind/enabled'); // OK, returns bool
Settings::get('tailwind/enabld'); // Error: Did you mean "tailwind/enabled"?
Hook name validation
Blockstudio filter and action hook names are validated.
add_filter('blockstudio/render', $cb); // OK
add_filter('blockstudio/rendrr', $cb); // Error: Did you mean "blockstudio/render"?
Dynamic settings hooks like blockstudio/settings/tailwind/enabled are always
allowed. Non-blockstudio hooks are ignored.
Schema validation
The extension validates every Blockstudio schema file in the project.
block.json:
- Missing
namefield - Missing field
type - Missing
idon value fields that require one - Unknown field types
- Field properties the field type does not accept, such as
"translat"instead of"translate", with the closest known property as a suggestion select/radio/checkboxwithoutoptionsorpopulate- Duplicate field IDs
- A
tabsfield inside a repeater, directly or inside a group without anid - Invalid
pluginDependenciesdeclarations
Container fields like group and tabs, plus custom/* field references, can
omit id when they only expand or wrap other fields.
Field properties are checked against the properties schemas/block.json
declares for each built-in field type, plus the properties every field
accepts. Wrapper-specific properties on custom/* references and project-defined
namespaced field types are outside this check. Existing field-type and custom
reference validation still applies. The identifiers are
blockstudio.blockJson.property and blockstudio.fieldJson.property.
This checks property names, not just their values: minimized is valid on a
repeater but is reported on a text field. The plugin’s runtime behavior is
unchanged. If a project deliberately attaches its own metadata to built-in
fields, suppress only these findings without disabling other shape checks:
parameters:
ignoreErrors:
- identifier: blockstudio.blockJson.property
- identifier: blockstudio.fieldJson.property
Built-in fields inside a reusable field.json are still checked, even when a
custom/* reference has its own wrapper options. Extension files are outside
the scope of these property-name diagnostics; extension-specific set rules
remain valid.
field.json (custom reusable fields):
- Missing
name - Missing or empty
attributes - Invalid attribute objects or missing field
type - Missing
idon value fields that require one - Unknown field types
- Field properties the field type does not accept
- A
tabsfield inside a repeater, directly or inside a group without anid - Missing, ambiguous, invalid, or cyclic nested
custom/*references
Extension JSON (block extensions in extensions/):
- Missing
name(target block) - Missing
blockstudiokey - Missing
blockstudio.extendkey
page.json (file-based pages):
- Missing
title - Missing
slug - Invalid
postStatusvalues
db.php:
- Missing
fieldsarray - Invalid field types
- Supports both legacy arrays and
Blockstudio\\Db\\Schema/Blockstudio\\Db\\Field
rpc.php:
- Invalid HTTP methods
- Wrong
publicvalue (must betrue,false, or'open') - Supports both legacy arrays and attributed object returns
cron.php:
- Missing
scheduleon configured tasks - Missing
callbackon configured tasks - Supports both legacy arrays and attributed object returns
blockstudio.json:
- Shorthand booleans like
"tailwind": true(must use the nested object format{"enabled": true, "config": ""})
Configuration
The extension discovers Blockstudio files in the analyzed project by default.
When a project uses blocks or reusable fields from a library outside the
project root, add the library path to blockstudioScanRoots. The path is
scanned for both block.json and field.json definitions, so external block
tags and their custom fields can be validated together.
parameters:
blockstudioScanRoots:
- vendor/acme/block-library/blockstudio
To ignore files, use PHPStan’s standard excludePaths configuration.
Analysis presets
The auto-discovered extension remains the compatibility-safe base. Blockstudio 7.6 adds opt-in layers without changing existing projects:
| Preset | Behavior |
|---|---|
base.neon |
Existing schema, template, hook, settings, and API analysis. |
theme.neon |
Base plus WordPress theme roots, style headers, Blockstudio assets, selector scoping, field defaults, and repeater bounds. |
extreme-theme.neon |
Theme plus PHPStan max, unsafe PHP, output escaping, Tailwind, JavaScript, and Interactivity API checks. |
wordpress-render.neon |
Extreme theme plus a caller-supplied live WordPress render probe. |
Installing blockstudio/phpstan enables only the base extension. Include an
opt-in layer in an existing PHPStan configuration:
includes:
- vendor/blockstudio/phpstan/extreme-theme.neon
parameters:
blockstudioThemeRoots:
- .
blockstudioThemeExcludePaths:
- fixtures/**
blockstudioThemeMaxFiles: 10000
blockstudioExtremeJavaScript: true
blockstudioExtremeTailwind: true
blockstudioThemeExcludePaths controls the theme scanner.
PHPStan’s standard excludePaths independently controls PHP analysis.
The canonical command’s phpstan.excludePaths setting in blockstudio.json
(and its --exclude option) configures both layers. Relative patterns resolve
against every configured root, so entries such as vendor/** bound PHPStan
analysis as well as Blockstudio’s project scan.
A pattern naming a single directory, such as _dist/** or node_modules/**,
excludes that directory wherever it appears, not only beside the theme root. A
pattern containing a slash, such as assets/*/docs/**, stays anchored to the
root. Generated block assets live in a _dist directory inside each block, so
the single-directory form is what keeps compiled output out of analysis.
Canonical command
The package installs vendor/bin/blockstudio-phpstan as its analysis
executable. It runs the base preset unless a project selects another one, so
installing the package never enables the theme or extreme-theme layers on its
own:
vendor/bin/blockstudio-phpstan --root . -- --no-progress
The presets include the extension themselves. Do not add
phpstan/extension-installer to a project that runs the canonical command:
with the installer present, extension.neon is loaded twice and PHPStan stops
with “This file is included multiple times” before analysing anything.
The command defaults PHPStan to a 1G memory limit so Composer-managed
WordPress projects do not inherit a typical 128M CLI ceiling. Pass an
explicit PHPStan option after -- to override it, such as
-- --memory-limit=512M --no-progress.
Adopting analysis on an existing project
A project that has never been analysed will usually report findings on its first
run. Rather than lowering the preset, generate a PHPStan baseline and point
phpstan.configuration at a project configuration that includes it. New code is
then held to the full preset while the recorded findings stay out of the way:
vendor/bin/blockstudio-phpstan -- --no-progress --generate-baseline=phpstan-baseline.neon
includes:
- phpstan-baseline.neon
parameters:
level: max
{
"phpstan": {
"configuration": "phpstan.neon"
}
}
Keep the level line for the base and theme presets. The canonical command
only sets level max for them when no project configuration is included, and
without a level PHPStan stops with “No rules detected”. The extreme-theme
preset sets level max itself.
Relative paths resolve from blockstudio.json. The canonical command and the
managed commit hook both pick the file up, so the hook gates new findings without
requiring the backlog to be cleared first.
Keep project-wide command defaults in blockstudio.json:
{
"$schema": "https://blockstudio.dev/schema/blockstudio",
"phpstan": {
"preset": "extreme-theme",
"roots": ["."],
"excludePaths": ["fixtures/**"],
"maxFiles": 10000
}
}
Relative roots resolve from the file that declares them. Explicit CLI values
replace the corresponding JSON values. Pass
--blockstudio-json path/to/project.json for an alternate configuration
source. Malformed JSON, unsupported phpstan keys, invalid values, and a
missing explicit source fail deterministically with exit code 2.
Select another preset, include a project configuration, add repeatable roots and exclusions, or ask PHPStan for JSON:
vendor/bin/blockstudio-phpstan \
--preset theme \
--configuration phpstan.neon \
--root . \
--exclude 'fixtures/**' \
--max-files 10000 \
--error-format json \
-- --no-progress
The wrapper composes configuration in the system temporary directory and deletes it on exit. It never writes generated configuration, baselines, hooks, or caches into the analyzed project. Callers can still configure PHPStan’s normal cache explicitly.
The exit contract is:
0when analysis passes1when PHPStan reports diagnostics2for invalid usage, configuration, or process execution
Managed commit hook
To make the extreme-theme analysis a repository commit gate, enable the
Blockstudio-owned hook in blockstudio.json:
{
"$schema": "https://blockstudio.dev/schema/blockstudio",
"phpstan": {
"preset": "extreme-theme",
"roots": ["."],
"excludePaths": [],
"maxFiles": 10000
},
"githooks": {
"commit": true
}
}
Synchronize it after installing dependencies or changing the setting:
vendor/bin/blockstudio-githooks sync
The command writes its generated pre-commit hook and ownership record inside
Git’s common directory, sets core.hooksPath to the managed hook directory,
and safely chains the previously configured pre-commit hook. Repeated syncs
refresh only the generated file, so package upgrades are idempotent.
Set commit to false, remove it, or remove blockstudio.json, then run
sync to restore the recorded hook path and delete only Blockstudio-owned
files. You can also run:
vendor/bin/blockstudio-githooks remove
User-owned files are never overwritten or deleted. If someone changes
core.hooksPath after Blockstudio was enabled, removal preserves that newer
setting. The generated hook supports linked Git checkouts and paths containing
spaces by resolving the active repository and project root at commit time. It
runs the canonical command from that root, so the same phpstan object controls
interactive runs and commits without duplicated generated arguments.
The hook runs vendor/bin/blockstudio-phpstan only. Blockstudio does not run a
formatter, and the hook never rewrites project files. Missing dependencies and
analysis failures block the commit with an actionable error.
Live WordPress rendering
The live layer is deliberately explicit. The caller owns the WordPress environment and supplies an argv array:
vendor/bin/blockstudio-phpstan \
--preset wordpress-render \
--render-command='["wp","eval-file","tools/render-probe.php"]' \
--render-working-directory=. \
--render-timeout=60 \
--root .
The command runs without a shell and must print one JSON object:
{"ok":true}
A probe can report a focused failure:
{
"ok": false,
"message": "Rendered block failed.",
"file": "/absolute/path/to/block.json",
"line": 12
}
Non-zero exits, timeouts, malformed JSON, and ok: false produce
blockstudio.wordpress.render.
blockstudio.javascript.domContract reports a [data-*] attribute selector
that a script passes to querySelector(), querySelectorAll(), closest(), or
matches() when no analysed markup carries that attribute. Markup is every
PHP, Twig, Blade, and HTML file and every React .tsx and .jsx component
under the analysed roots, minus the excluded paths. Components are parsed:
JSX attribute names count, and so do the string keys of object literals, so
an attribute object spread onto an element satisfies the selector, while
class names and other attribute values do not.
The Interactivity API checks read the same React components. A
data-wp-interactive namespace on a JSX element counts as a template reference
for blockstudio.interactivity.orphan, and the data-wp-* directives inside it
resolve against the nearest enclosing namespace, or a namespace:: prefix in
the value. Directives with a static string value, written as a JSX attribute or
as a string key of an attribute object, must name existing store handlers and
paths (blockstudio.interactivity.handler, blockstudio.interactivity.binding),
and store code must not mutate the attributes and classes they bind
(blockstudio.interactivity.derivedState). React escapes attribute values, so
data-wp-context in a component is not reported, and dynamic values are not
checked. Namespaces under bsui/ belong to the bundled UI blocks, whose stores
ship with Blockstudio outside the analysed roots, so they are never orphans.
Diagnostic identifiers
New preset diagnostics retain stable blockstudio.* IDs:
- Theme structure:
blockstudio.theme.root.missing,blockstudio.theme.style.missing,blockstudio.theme.style.header,blockstudio.theme.scanLimit - Assets and fields:
blockstudio.theme.asset.manualEnqueue,blockstudio.theme.asset.selectorScope,blockstudio.theme.asset.missing,blockstudio.field.default,blockstudio.field.repeaterBounds - PHP:
blockstudio.php.forbiddenFunction,blockstudio.wordpress.rawDatabaseWrite,blockstudio.output.unescaped - Tailwind:
blockstudio.tailwind.compilerMissing,blockstudio.tailwind.compile,blockstudio.tailwind.unknownUtility,blockstudio.tailwind.semanticToken - JavaScript:
blockstudio.javascript.syntax,blockstudio.javascript.debugOutput,blockstudio.javascript.bannedApi,blockstudio.javascript.leakedGlobal,blockstudio.javascript.importSpecifier,blockstudio.javascript.rootGuard,blockstudio.javascript.initShape,blockstudio.javascript.domContract,blockstudio.javascript.listenerCleanup,blockstudio.javascript.reducedMotion - Interactivity:
blockstudio.interactivity.import,blockstudio.interactivity.moduleImport,blockstudio.interactivity.namespace,blockstudio.interactivity.scopedDom,blockstudio.interactivity.derivedState,blockstudio.interactivity.handler,blockstudio.interactivity.binding,blockstudio.interactivity.context,blockstudio.interactivity.orphan
Performance
The scanner accepts ordinary materialized directories, deduplicates roots, skips dependency/build/cache trees, caches file reads in memory, and sorts diagnostics deterministically. Keep roots narrow, exclude fixture/generated trees, and set a file limit for large repositories. JavaScript and Tailwind can be disabled independently. Live rendering never runs unless its preset is selected.
Theme, JavaScript, and Tailwind diagnostics are reported through PHPStan’s
result cache. After changing only a block.json or a script, a warm
cache can report the previous result. Pass
--debug after -- to bypass the cache when verifying such changes.
Project contract for coding agents
The package installs a third executable, vendor/bin/blockstudio-agents. It
writes an AGENTS.md describing the project it runs in, which is what a coding
agent needs before it touches anything: what the project authors, which
Blockstudio features are enabled, what analysis will reject, and the commands
that apply.
vendor/bin/blockstudio-agents
Blockstudio contract created: /path/to/project/AGENTS.md
Nothing in the output is a fixed template. Every line is derived from one of three sources:
- The project’s own files. A theme with a
style.cssheader, blocks, and file-backed pages produces a different document than a plugin that registers blocks and nothing else. Counts, directories, block namespaces, and template languages are what the scanner actually found, using the same roots and exclusions as analysis. blockstudio.json. Only enabled features are described, with their configured values: block tag prefixes and the namespaces they resolve through, Tailwind, the bundled UI, the editor asset reset, the cache path, the performance profile, static prerendering and its dynamic paths, theme defaults, Content Sync, and the commit hook.- The selected
phpstan.preset. The correctness section is read from the preset files themselves, layer by layer, so it lists exactly the rules that preset registers. Atbaseit describes the schema, template, hook, and settings rules. Atextreme-themeit also describes the theme structure rules, the strict PHPStan flags, unsafe PHP, output escaping, Tailwind, and JavaScript. A preset that gains a rule changes the generated document with it.
The commands section follows the same principle. wp bs prerender status
appears when static prerendering is enabled, vendor/bin/blockstudio-githooks sync when githooks.commit is set, and wp bs db schemas when the project
actually has a db.php.
| Option | Behavior |
|---|---|
--root <path> |
Project root (default: current directory) |
--config <path> |
blockstudio.json path (default: <root>/blockstudio.json) |
--output <path> |
Contract path (default: <root>/AGENTS.md) |
--stdout |
Print the contract instead of writing it |
--check |
Exit 1 when the contract on disk is not current |
--force |
Replace a file Blockstudio does not own |
--check makes the contract a CI gate, the same way the commit hook makes
analysis one:
vendor/bin/blockstudio-agents --check
Ownership
The generated file carries the same kind of marker as the managed commit hook,
and the same rule applies: Blockstudio never replaces a file it did not write.
An AGENTS.md that predates the command, or one an author wrote by hand, is
refused with exit code 2 and left untouched until --force is passed.
Regenerating is safe. The file ends with a notes region, and the bytes between its markers are preserved across every regeneration:
## Project notes
<!-- blockstudio:notes:start -->
Deploys run from the release branch only.
<!-- blockstudio:notes:end -->
Exit codes are 0 when the contract is written, current, or printed, 1 when
--check finds an outdated contract, and 2 for usage, configuration, or
filesystem errors.
Field type shapes
The extension maps every Blockstudio field type to a concrete PHP type:
| Field type | PHP type |
|---|---|
text, textarea, richtext, wysiwyg, code |
string |
date, datetime, classes, html-tag, unit, gradient |
string |
number, range |
int|float |
toggle |
bool |
select, radio, checkbox (single) |
string|int |
select, checkbox (multiple) |
list<string|int> |
color |
array{value: string, opacity: float|null} |
link |
array{url: string, title: string|null, target: string|null, opensInNewTab: bool|null} |
icon |
array{set: string, subSet: string, icon: string} |
files (single) |
array{id: int, url: string, alt: string|null, mime_type: string|null} |
files (multiple) |
list<array{id: int, url: string, ...}> |
group |
Named groups use underscore prefixes; anonymous groups flatten into the parent scope |
repeater |
list<array{...child fields...}> |
tabs |
Flattened into parent scope |
API stubs
The extension ships stubs for the full Blockstudio public API:
bs_render_block(),bs_block_values(),bs_get_group(),bs_get_scoped_class(),bs_db_form(),bs_is_editor(),bs_is_preview(),bs_is_buffering(),bs_strip_render_markers()Db::get(),Db::create(),Db::list(),Db::get_record(),Db::update(),Db::delete()Settings::get(),Settings::get_all()Field_Registry::instance(),Field_Registry::all(),Field_Registry::get()Build::blocks(),Build::extensions(),Build::get_build_dir()
These provide autocomplete and type checking without needing the Blockstudio plugin source on your machine.
Requirements
- PHP 8.2+
- PHPStan 2.1.47+
phpstan/extension-installer(recommended, for auto-discovery)
The package installs its BC Math compatibility provider automatically. Native
ext-bcmath is therefore optional, including for fresh generated themes that
enable the JavaScript checks through the theme or extreme preset.