This document is the implementation plan for Laravel Blade template
support in PHPantom. For Eloquent model support see laravel.md.
For general architecture see ARCHITECTURE.md.
- No application booting. Consistent with
laravel.md. We never run PHP or boot a Laravel application. - No call-site scanning. We do not scan controllers, mailers, or
other PHP files for
view()calls to infer template variable types. Variable types come from explicit@varPHPDoc in@phpblocks (compatible with Bladestan's@bladestan-signature),@propsdirectives, or component class constructors. - Discovery is just directory walks. Scanning
resources/views/andapp/View/Components/(plusapp/Livewire/) at init time is the full extent of external Blade file discovery. Paths are converted to view names and component names via string transforms. - PSR-4 is for class source lookup, not discovery. Once we know an
FQN (e.g.
App\View\Components\Alert), we use the existingfind_or_load_classpipeline to read its source. We do not use PSR-4 to discover component names. - 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.
Blade templates (.blade.php) mix HTML, Blade directives, component
tags (<x-alert>, <livewire:counter>), and embedded PHP. The
mago-syntax parser only understands pure PHP. The strategy:
- Preprocess
.blade.phpfiles into valid PHP. - Feed the virtual PHP through the existing pipeline (parser, resolver, completion, definition).
- Map LSP response positions back to the original Blade file via a source map.
The core preprocessor is implemented in src/blade/. It transforms
Blade templates into virtual PHP line-by-line, with a source map for
coordinate translation. The LSP pipeline (with_file_content,
update_ast, did_close) transparently handles Blade files.
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.
At initialized time (alongside PSR-4 and classmap loading), scan
the filesystem to build three maps.
New file: src/blade/discovery.rs
Recursively scan resources/views/ for *.blade.php files. Build
a map of dot-notation view names to file paths:
resources/views/users/index.blade.php→"users.index"resources/views/components/alert.blade.php→"components.alert"
Store as:
/// View dot-name -> file path.
pub(crate) blade_views: Arc<Mutex<HashMap<String, PathBuf>>>,Recursively scan app/View/Components/ for *.php files. Convert
file paths to kebab-case component names and FQNs:
app/View/Components/Alert.php→ name"alert", FQN"App\\View\\Components\\Alert"app/View/Components/Forms/Input.php→ name"forms.input", FQN"App\\View\\Components\\Forms\\Input"
Index components (where directory name matches file name) should be registered both ways:
app/View/Components/Card/Card.php→ name"card"(index) and"card.card"(explicit)
Store as:
/// Component kebab-name -> FQN.
pub(crate) blade_components: Arc<Mutex<HashMap<String, String>>>,Recursively scan app/Livewire/ for *.php files. Convert file
paths to dot-notation component names and FQNs:
app/Livewire/Counter.php→ name"counter", FQN"App\\Livewire\\Counter"app/Livewire/Admin/Users.php→ name"admin.users", FQN"App\\Livewire\\Admin\\Users"
Store as:
/// Livewire component name -> FQN.
pub(crate) livewire_components: Arc<Mutex<HashMap<String, String>>>,All three scans depend on workspace_root. Run them in initialized
after the existing Composer parsing, gated on
workspace_root.is_some().
New file: src/blade/components.rs
The preprocessor detects <x-name ...> and </x-name> tags and
converts them to PHP.
Parse <x-component-name attr="val" :attr="$expr" ...> or
<x-component-name ... /> (self-closing).
- Extract the component name (everything between
<x-and the first whitespace or>//>). - Look up the name in
blade_components. If found, resolve the FQN. - Extract attributes:
attr="literal"→ named arg with string value:attr="$expr"→ named arg with PHP expression value::attr="expr"→ ignored (Alpine.js passthrough)- Bare
attr→ named arg withtrue :$var(short syntax) → named argvar: $var
- Convert attribute names from kebab-case to camelCase for the constructor call.
- Emit
$component = new \FQN(camelAttr: value, ...);
If the component is not found in blade_components, check if it's an
anonymous component (exists in blade_views under components.
prefix). For anonymous components, emit a comment but still expose
$attributes and $slot.
For <x-dynamic-component :component="$name" ...>, emit
echo $name; so the expression gets parsed, but do not try to
resolve a target component.
</x-name> becomes a comment: /* /x-name */
<x-slot:title> → $title = new \Illuminate\Support\HtmlString('');
</x-slot> → comment
When inside a component tag region (between opening and closing tags), inject:
/** @var \Illuminate\View\ComponentAttributeBag $attributes */
$attributes = new \Illuminate\View\ComponentAttributeBag([]);
/** @var \Illuminate\Support\HtmlString $slot */
$slot = new \Illuminate\Support\HtmlString('');Parse <livewire:name :attr="$expr" ...> or
<livewire:name ... />.
- Extract the component name (everything between
<livewire:and the first whitespace or>//>). - Look up in
livewire_components. If found, resolve the FQN. - Extract attributes (same rules as
<x-...>). - Emit
$component = new \FQN();followed by property assignments for each attribute:$component->attrName = $expr;.
Livewire attribute names use camelCase on the class, so apply the same kebab-to-camelCase conversion.
@props(['type' => 'info', 'message']) becomes:
$type = 'info';
$message = null;
/** @var \Illuminate\View\ComponentAttributeBag $attributes */
$attributes = new \Illuminate\View\ComponentAttributeBag([]);
/** @var \Illuminate\Support\HtmlString $slot */
$slot = new \Illuminate\Support\HtmlString('');The preprocessor parses the array literal in the @props()
argument to extract variable names and default values. Variables
listed without a key-value pair (just 'message') get a null
default.
@aware(['color' => 'gray']) → $color = 'gray';
Same parsing as @props but without the $attributes/$slot
injection.
When the user types <x- in a Blade file, offer completions from:
blade_componentsmap (class-based components, kebab-case names)- Anonymous component templates: entries in
blade_viewswhose key starts with"components.", with the prefix stripped and dots preserved (e.g."components.forms.input"→"forms.input")
Detection: check if the characters before the cursor match
<x- (possibly with a partial name typed). This is a Blade-level
context check done before the normal PHP completion pipeline.
Items should use CompletionItemKind::Module or ::Class depending
on whether they're anonymous or class-backed.
Same pattern. When the user types <livewire:, offer completions
from the livewire_components map.
When the cursor is inside the string argument to @include,
@includeIf, @includeWhen, @includeUnless, @includeFirst,
@extends, @each, or a view() function call, offer completions
from the blade_views map (dot-notation view names).
Detection: look for @include(', @extends(', or view(' before
the cursor and check that the cursor is inside the quotes. The
trigger characters ' and " are already registered.
When the cursor is inside a <x-component tag (after the component
name, before > or />), resolve the component class and offer its
constructor parameter names as kebab-case attribute completions.
Offer both plain and : prefixed variants:
message(string literal):message(PHP expression)
For Livewire components, offer the class's public property names as attribute completions.
Create tests/blade_components.rs:
<x-alert>resolves toApp\View\Components\Alert<x-forms.input>resolves toApp\View\Components\Forms\Input<x-card>resolves to index componentApp\View\Components\Card\Card<livewire:counter>resolves toApp\Livewire\Counter- Anonymous component detection
<x-dynamic-component>does not crash- Attribute parsing: string, expression, Alpine passthrough, bare, short syntax
Extend tests/completion_blade.rs:
<x-triggers component name completions<livewire:triggers Livewire component name completions@include('triggers view name completions<x-alerttriggers attribute completions$component->after component instantiation$attributes->in component templates
Inside @include('users.index'), @extends('layouts.app'), or
view('welcome'):
- Extract the view name string at the cursor position.
- Look up in
blade_views. - Return a
Locationpointing to the resolved file.
On <x-alert>:
- Extract the component name.
- Look up in
blade_componentsto get the FQN. - Use
find_or_load_class+fqn_uri_indexto find the source file. - Return a
Locationpointing to the class definition.
On <livewire:counter>:
- Same pattern using
livewire_components.
When template A contains @extends('layouts.app'):
- Resolve
layouts.appviablade_viewsto a file path. - Read or preprocess that file.
- Extract
@vardeclarations from its@phpblocks. - Merge those declarations into template A's virtual PHP prologue,
following the Bladestan covariance model:
- Variables only in child: use child type.
- Variables only in parent: use parent type.
- Variables in both: child may narrow but not widen.
- Walk the chain recursively if the parent also
@extends.
This gives child templates access to the parent's declared variables without the user redeclaring them.
For class-based components, when editing the component's Blade template:
- Determine which component class backs this template. Convention:
resources/views/components/alert.blade.phpis backed byApp\View\Components\Alert. - Load the class via
find_or_load_class. - Read public properties and constructor parameter types.
- Inject those as
@vardeclarations in the virtual PHP prologue (unless the template already has explicit@varor@props).
Create tests/definition_blade.rs:
- Go-to-definition on
@include('users.index')→ view file - Go-to-definition on
@extends('layouts.app')→ layout file - Go-to-definition on
<x-alert>→ component class - Go-to-definition on
<livewire:counter>→ Livewire class
Extend tests/completion_blade.rs:
- Variables from parent layout available in child via
@extends - Component class constructor types available in template
When the user types @ in a Blade file (outside {{ }}, @php
blocks, and string literals), offer completions for all known Blade
directives with snippet templates.
Each completion inserts a snippet with tab stops:
@if ($1)
$0
@endif
@foreach ($1 as $2)
$0
@endforeach
@include('$1')
@props([$1])
@inject('$1', '$2')
@php
$0
@endphp
Detection: The @ trigger character is already registered. In
handle_completion, check is_blade_file and that the cursor is in
an HTML/directive context (not inside {{ }}, not inside a @php
block, not inside a string literal).
Extend tests/completion_blade.rs:
@triggers directive name completions@ifpartial triggers filtered directive completions- No directive completion inside
{{ }}or@phpblocks
Phase 1 is complete (steps 1-3): the preprocessor, LSP pipeline
integration, source mapping, $loop/@session/@error/@context
implicit variables, stub directives, verbatim regions, languageId
check, and code action suppression are all shipped.
The remaining steps build on the existing preprocessor:
Implement src/blade/discovery.rs. Scan resources/views/,
app/View/Components/, app/Livewire/ at init time. Add the three
new maps to Backend.
Deliverable: Maps are populated and logged at startup.
Implement src/blade/components.rs. Parse <x-...> and
<livewire:...> tags. Handle @props, @aware, named slots.
Deliverable: $component-> after <x-alert> produces
completions from the Alert class. $attributes-> works in component
templates.
Implement <x-, <livewire:, @include(', and component attribute
completions.
Deliverable: Typing <x- shows available components. Typing
@include(' shows available views. Typing attributes inside
<x-alert shows constructor parameter names.
Implement @ directive name completion with snippets.
Deliverable: Typing @ in a Blade file shows all known
directives with snippet templates.
Implement go-to-definition for view names and component tags.
Implement @extends signature merging. Implement component class to
template variable typing.
Deliverable: Ctrl-click on @include('users.index') jumps to
the file. Parent layout variables are available in child templates.
The server activates Blade preprocessing when:
- The URI ends with
.blade.php, OR - The
languageIdindid_openis"blade".
The Zed extension (zed-extension/extension.toml) currently
registers languages = ["PHP"]. To support Blade files, it will
need an additional language registration. This may require Zed to
have a Blade language definition (grammar, file associations), or
the extension can register .blade.php as a PHP variant. This is
an editor-side concern and may need a separate Zed extension or an
update to the existing one.
- VS Code: Extensions like Laravel Blade Snippets set
languageIdto"blade". PHPantom's VS Code integration would need to register for both"php"and"blade"language IDs. - Neovim:
lspconfigcan be configured to send.blade.phpfiles to PHPantom with the correctlanguageId.