PHPantom — Blade¶
Known gaps and planned features in PHPantom's Laravel Blade template
support. For Eloquent model support see laravel.md. For the shipped
preprocessor pipeline and module layout see ARCHITECTURE.md.
The strategy, implemented in src/blade/: preprocess .blade.php
files (recognised by URI suffix, or by a did_open languageId of
"blade") into valid virtual PHP, feed the virtual PHP through the
existing pipeline (parser, resolver, completion, definition), and map
response positions back to the original Blade file through a source
map. Every item below builds on that pipeline.
Items are ordered by impact (descending), then complexity (ascending) within the same impact tier, with a dependent item placed after its dependency.
| Label | Scale |
|---|---|
| Impact | Critical, High, Medium-High, Medium, Low-Medium, Low |
| Complexity | Low (mechanical/boilerplate, no design decisions), Medium (self-contained, follows an existing pattern), Medium-High (spans modules, some new design), High (shared/core subsystem, correctness or performance tradeoffs), Very High (cross-cutting architecture, wide blast radius) |
Out of scope (and why)¶
| Item | Reason |
|---|---|
| Editor-side Blade language registration | The tree-sitter grammar, .blade.php file association, and languageId: "blade" wiring belong to editor extensions, not the server. Zed's official PHP extension (which absorbed PHPantom's plain-PHP wiring; this repo no longer bundles zed-extension/) will not grow Blade support — that registration belongs to the planned zed-laravel extension. On VS Code, Blade extensions already set languageId to "blade", so PHPantom's integration needs to register for both "php" and "blade". Neovim's lspconfig can be configured to send .blade.php files with the correct languageId. |
| Rendering or booting to resolve templates | Consistent with laravel.md: we never run PHP or boot a Laravel application. |
Philosophy¶
- No application booting. Consistent with
laravel.md. We never run PHP or boot a Laravel application. - Signatures over call-site scanning. A template's variable types
come from its declared contract: the Bladestan-compatible chain in
src/blade/signature.rs—@bladestan-signaturedocblock, first docblock before template code,@props/@aware, Blade's own component scope, the backing component class, and the layouts the template@extends. The template declares what it expects; call sites are then validated against that contract (src/blade/contract.rsandsrc/diagnostics/blade_call_site.rs), exactly as a function signature works. Inferring types from call sites inverts the contract and produces "true for one caller" types, so it is not the foundation — the shipped call-site inference fallback for unannotated projects is layered strictly below every declared source. Projects running Bladestan (a PHPStan extension for Blade template analysis) get the full contract model in both the editor and CI from the same annotations. - Discovery is just directory walks. Walking the configured view
roots and the directories the component namespaces live in
(
src/blade/discovery.rs) is the full extent of external Blade file discovery. Paths are converted to view names and component names via string transforms. A namespace no PSR-4 mapping of the project's owncomposer.jsoncovers (a vendor package that registered a component namespace) is read off the class index instead, since there is no directory to walk for it. - PSR-4 says where a namespace lives, not what a component is
called. A mapping resolves
App\View\Componentsto the directory to walk, and once we know an FQN (e.g.App\View\Components\Alert) the existingfind_or_load_classpipeline reads its source. The names themselves always come from the file paths. - Graceful degradation. Unknown directives become comments. Failed component resolution produces comments. The user always gets partial completions rather than a broken file. The preprocessor must never produce invalid PHP.
BL11. Custom directive discovery¶
Impact: Medium · Complexity: Medium
Blade::directive('datetime', …) and Blade::if('env', …)
registrations in app and package service providers declare
project-specific directives. (Component namespace/path registrations —
Blade::componentNamespace(), Blade::anonymousComponentPath(),
Blade::anonymousComponentNamespace() — are already scanned into
ProviderResources and extend the discovery index; see
src/virtual_members/laravel/provider_resources.rs. What remains is the
directive-registration half.) Scan literal directive registrations too
— the same provider-scanning shape as the macro scanner — so that:
- known custom directives stop degrading to comments in the preprocessor and instead map to expression-preserving PHP (their argument is still type-checked);
Blade::if('admin')synthesizes the full family (@admin,@elseadmin,@endadmin,@unlessadmin);- directive name completion (
DIRECTIVE_COMPLETIONSinsrc/blade/directives.rs) includes them.
BL1. Blade-aware code actions¶
Impact: Medium · Complexity: Medium-High
Code actions are currently disabled for .blade.php files because
text edits target virtual PHP coordinates and actions like "Import
class" insert use statements at the top of the file rather than
inside a @php / <?php block. Re-enable code actions with:
- Range translation (virtual PHP → Blade) for all text edits.
- Blade-aware code generation (e.g. insert
useinside@php). - Filtering out actions that don't make sense in Blade context.
Deliverable: Code actions are re-enabled for .blade.php files.
BL23. Unbalanced component tag diagnostics¶
Impact: Low-Medium · Complexity: Medium
src/blade/balance.rs pairs up block directives, but a component tag
body is a block too: <x-alert> … </x-alert>, <x-slot:title> …
</x-slot>. A tag nobody closes renders the rest of the template inside
it, and a stray closing tag renders nothing at all, neither of which is
reported today.
- Extend the block stack with component tags, reusing the raw-text tag
scan in
src/blade/component_tags.rsrather than a second parser. - A self-closing
<x-alert />opens nothing, and an attribute value may hold a>(<x-alert :items="$a > $b">), so the scan has to read the whole tag rather than stop at the first>. - Report against the tag, in the same three shapes the directive check uses: a closing tag for another component, a closing tag with nothing open, and a tag the template never closes.
Tests¶
In tests/integration/diagnostics_blade.rs:
<x-alert>closed by</x-card>→ mismatched-tag diagnostic<x-alert>with no closing tag → unclosed-tag diagnostic- self-closing tags, and tags whose attributes contain
>, report nothing
BL14. Folding ranges for Blade files¶
Impact: Low-Medium · Complexity: Medium
textDocument/foldingRange on a .blade.php file currently returns
ranges in virtual-PHP coordinates, which don't line up with the
original template, because folding.rs never translates through the
source map.
- Translate each
FoldingRangethroughsource_map.php_to_bladebefore returning, matching the pattern ininlay_hints.rs. - Add Blade-native fold regions the underlying PHP has no concept of:
@if/@endifand friends (the block stacksrc/blade/balance.rsalready walks),<x-component>...</x-component>tag bodies,@section/@endsection,@push/@endpush. - Matches the folding behaviour other Blade-aware editors already provide.
Tests¶
New file tests/integration/folding_blade.rs:
@foreach/@endforeachfolds<x-alert>...</x-alert>folds- fold ranges land on the correct Blade lines, not virtual-PHP lines
BL15. Document outline (symbols) for Blade files¶
Impact: Low-Medium · Complexity: Medium-High
A .blade.php file today reports no outline, or an outline positioned
in virtual-PHP coordinates, because document_symbols.rs never
translates through the source map.
- Translate symbol ranges/selection ranges through
source_map.php_to_blade. - Build a Blade-native symbol tree on top of the translated PHP
symbols:
@sections and@push/@stackblocks as top-level symbols,<x-component>tags as child symbols showing the resolved component FQN — degrade to the bare tag name if the component doesn't resolve. - Matches the structure-view behaviour other Blade-aware editors already provide.
Tests¶
New file tests/integration/document_symbols_blade.rs:
@section('content')appears as an outline entry<x-alert>appears as an outline entry with the resolved FQN
BL24. Named slot variables scoped to the component that receives them¶
Impact: Low-Medium · Complexity: Medium
Deferred from the component-tag work: <x-slot:title> and its legacy
<x-slot name="title"> form become a comment in the virtual PHP, so
nothing declares $title.
Declaring it where the tag is written would be wrong. A named slot is a
variable of the component's template, not of the template that fills
it, and the filling template is where the tag sits. Emitting $title =
new \Illuminate\View\ComponentSlot(); there would put a name in the
caller's scope that Blade never binds, and a slot named after a variable
the caller already holds (<x-slot:item> inside @foreach ($items as
$item)) would silently retype it for the rest of the block.
The right shape is the one the backing class already uses: the slot
names a template's tags fill are part of what the component's template
receives, alongside $slot and $attributes (COMPONENT_VARS in
src/blade/preprocessor.rs, fed from super::backing_class). That
means scanning the tags that render a component for their <x-slot:…>
children and declaring those names in the component template's prologue
as \Illuminate\View\ComponentSlot, the same way
scan_component_tag_calls already turns a tag's attributes into that
template's variables.
Tests¶
<x-slot:title>in a caller declares$titlein the component's template, not in the caller's.<x-slot name="title">(the legacy form) does the same.- A slot name that collides with a caller variable leaves the caller's variable alone.
BL16. Blade-aware formatting¶
Impact: Low-Medium · Complexity: High
formatting.rs has no Blade awareness. mago's formatter runs against
the virtual PHP buffer generated for .blade.php files, and its output
has no fixed relationship to the original directive/HTML structure —
there is no path today that safely reformats the original Blade
markup.
- Medium term: extend
formatting.rs's existing external-tool resolution (currently php-cs-fixer/Pint/phpcbf via Composerrequire-dev) to also detect a project-installedblade-formatter(npm, viapackage.json/node_modules/.bin) and proxy it over--stdin, matching how Pint is already invoked. See the feasibility research below for why this is a good fit despiteblade-formatternot being a Composer tool. - Long term: a Blade-native indentation model (directive nesting depth
- HTML tag depth + embedded
@php/{{ }}PHP formatting viamago) as the built-in fallback for projects withoutblade-formatterinstalled. This is the highest-effort item in the Blade backlog; most Blade projects already reach for a dedicated Blade formatter, which is why the external-tool path above should land first.
Feasibility research: proxying blade-formatter vs. a native basic formatter¶
Investigated blade-formatter (the shufo/blade-formatter npm
package) as a possible thing to shell out to.
Proxying it fits the existing external-tool pattern.
formatting.rs already resolves php-cs-fixer/Pint/phpcbf by detecting
them in the project (composer.json require-dev, resolved via
Composer's bin-dir) and falls back to the built-in mago-formatter when
absent — the same "use the project's own tool if it's there, otherwise
built-in" shape the user wants here. blade-formatter is a reasonable
addition to that resolution chain, not something to reject outright.
The one wrinkle: it is an npm package, not a Composer one, so detection
needs its own path parallel to (not reusing) the vendor/bin resolver
— check package.json devDependencies/dependencies for
blade-formatter and resolve the binary via node_modules/.bin/
blade-formatter, with the same .phpantom.toml override/disable
shape (blade-formatter = "..." / blade-formatter = "") as the
existing PHP tools. This also means Node must be present on the
machine for the external path to trigger at all; when it isn't, or the
package isn't installed, fall back to the built-in formatter exactly
like the PHP tools do today.
The two operational concerns from the initial pass (no result cache;
--write deletes-then-rewrites the target file) turn out not to be
blockers once invoked the way we already invoke Pint: it supports
--stdin (format code from stdin, formatted result on stdout), so we
would never let blade-formatter touch the file directly — we own the
write, the same way we already do for Pint via --stdin-filename. And
since we'd invoke it once per format request (never in a long-lived
watch mode), the lack of an internal cache is no different from how
php-cs-fixer/phpcs are already invoked fresh per request; there is no
extra cost specific to this tool. Net: prefer the project's own
blade-formatter install when present (best fidelity with what the
team already uses and reviews), built-in native formatter as the
fallback and long-term goal — matching the existing PHP formatter
precedent.
How it actually formats, and what that implies for a native
implementation. It is not AST-based. The pipeline
(formatContentPipeline.ts) runs ~30 regex-based string processors in
a pre-process/post-process sandwich around two off-the-shelf
formatters: js-beautify for the HTML "shell" and
@prettier/plugin-php for isolated PHP/Blade-brace expressions. Content
that would confuse those formatters (raw @php blocks, <script>,
<style>, comments, Alpine.js x-data/x-init attributes, component
props) is regex-extracted to placeholder tokens before beautifying and
spliced back in afterward. Directive-nesting indent is a separate
pass (formatter.ts's processTokenizeResult/processKeyword):
it tokenizes each line with a bundled Blade TextMate grammar
(syntaxes/blade.tmLanguage.json, run through vscode-textmate +
vscode-oniguruma/WASM) purely to classify which tokens are Blade
keywords, then walks a hardcoded stack of directive-start/-end/-else
token lists (indent.ts) to raise/lower indent level per line, with
special-cased exceptions (@case inside @switch dedents one extra
level, @break inside @if doesn't indent, @section/@push/@slot
are self-closing when given a second argument, @hasSection is
"unbalanced" and never closes). It is a large surface of hand-tuned
edge cases, not a formal grammar.
The directive-nesting indent pass is the one part of this design that
translates cleanly to PHPantom, and we are better positioned to do it
than blade-formatter was: src/blade/directives.rs already has the
full directive table blade-formatter hardcodes in indent.ts, and
unlike blade-formatter (which had to pull in a TextMate grammar +
oniguruma WASM just to find directive tokens on a line), our
preprocessor already tokenizes Blade source precisely. What's missing
is (a) classifying each directive as indent-start / indent-end / else
(mechanical, from the existing table) and (b) an HTML tag-depth
counter interleaved with directive depth on the same line (open/close/
void/self-closing elements) — new code, but a single self-contained
pass, not a full HTML parser. Embedded @php/{{ }} expression
formatting can reuse mago's formatter on isolated snippets the same
way diagnostics already isolate virtual-PHP buffers, rather than
needing a second PHP formatter dependency like blade-formatter does.
Recommended scope for the native fallback formatter: directive-
nesting indent + HTML tag indent only, reindenting existing lines
without rewriting their content (no attribute sorting, no line-wrap/
wrapping of long tags, no quote-style normalization, no Tailwind class
sorting). That covers the visible majority of blade-formatter's example
output (consistent indentation) while skipping almost all of its
~30-processor edge-case surface, which exists to handle content
rewriting we are choosing not to do. This is still a real chunk of work
(a new directive-classification table + an HTML depth scanner + the
mago-snippet-reformat glue), but is meaningfully smaller than full
parity with blade-formatter, does not require adding a
TextMate/oniguruma dependency the way blade-formatter's own approach
does, and is worth having on its own merits: it is the only
option for projects that don't have blade-formatter installed, and
if it ends up faster and at least as correct as blade-formatter (a
real possibility, given we skip the regex/placeholder round-tripping
entirely), it can become the default rather than staying a fallback.
See BL17 below for exposing it as a standalone CI check once it
reaches that bar.
Tests¶
formattingon a.blade.phpfile returns no edits (short-term behaviour) rather than corrupting the file, until the long-term model lands.
BL17. format --check CLI subcommand for CI¶
Impact: Low-Medium · Complexity: Medium (depends on BL16)
Filed while researching BL16: once the native Blade formatter (and,
more generally, PHPantom's resolved formatting strategy — external
tool or built-in) is trustworthy enough to enforce, projects want a
non-editor way to verify a PR ran it, the same role blade-formatter
-c/--check-formatted plays today. main.rs currently only exposes
analyse and fix as CLI subcommands; there is no way to invoke
textDocument/formatting's resolution logic (formatting.rs) outside
the LSP connection at all.
- Add a
formatsubcommand (phpantom_lsp format --project-root <DIR> [--check]) that walks project PHP/Blade files, runs the sameresolve_strategyexternal-tool-or-built-in logicformatting.rsalready uses, and either writes the formatted result back or (with--check) exits non-zero and lists files that would change, without writing them — mirroringblade-formatter -c -dandphpcs --dry-run/php-cs-fixer --dry-runconventions projects already use in CI. - This depends on the native Blade formatter model (BL16 long term)
existing and being fast/correct enough that a maintainer would want
it enforced in CI; do not build the CLI surface before that bar is
met, since the CLI is only useful once there's a trustworthy
formatter behind it (or a detected
blade-formatter/php-cs-fixerexternal tool, which--checkshould honour identically toformatwithout--check).
Tests¶
format --checkon an already-formatted project exits 0 with no output.format --checkon a project with an unformatted.blade.phpfile exits non-zero and names the file.format(no--check) rewrites the file in place and a second run is a no-op.