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.
This phase delivers the core value: completion and go-to-definition for PHP expressions inside Blade templates.
Create the following files:
src/blade/mod.rs— module declarations,is_blade_file()helper, publicpreprocess()entry point.src/blade/preprocessor.rs— the main Blade-to-PHP transformation engine.src/blade/directives.rs— directive pattern matching and their PHP translations.src/blade/source_map.rs—BladeSourceMap,ColumnAdjustment, and position translation functions.
Register pub mod blade; in src/lib.rs.
The preprocessor operates line-by-line and produces exactly one output line per input line. This is the single most important invariant: it makes line mapping a trivial identity function so only column adjustments are needed.
The virtual PHP document starts with an implicit <?php prologue
(occupying a synthetic first line if the Blade file doesn't start with
one). Each Blade line is then transformed:
- Blade directives become single-line PHP statements.
- Echo expressions (
{{ }},{!! !!}) are replaced inline. - HTML-only lines become single-line PHP comments (
// HTML). - Component tags are handled in Phase 2.
| Blade | Virtual PHP |
|---|---|
{{ $expr }} |
echo e($expr); |
{!! $expr !!} |
echo ($expr); |
{{-- comment --}} |
/* comment */ |
When echo expressions appear mid-line (surrounded by HTML), the
surrounding HTML becomes a comment on the same line and the echo
expression is emitted as PHP on that line. Multiple echo expressions
on the same line are joined with ; on the same output line.
The @{{ expr }} escape syntax (used for JS frameworks) strips the
leading @ and emits a comment.
| Blade | Virtual PHP |
|---|---|
@if ($cond) |
if ($cond): |
@elseif ($cond) |
elseif ($cond): |
@else |
else: |
@endif |
endif; |
@foreach ($arr as $v) |
foreach ($arr as $v): |
@endforeach |
endforeach; |
@for (...) |
for (...): |
@endfor |
endfor; |
@while (...) |
while (...): |
@endwhile |
endwhile; |
@forelse ($arr as $v) |
foreach ($arr as $v): |
@empty |
endforeach; if (false): |
@endforelse |
endif; |
@switch($v) |
switch($v): |
@case(...) |
case (...): |
@break |
break; |
@default |
default: |
@endswitch |
endswitch; |
@unless (...) |
if (!( ... )): |
@endunless |
endif; |
@isset($v) |
if (isset($v)): |
@endisset |
endif; |
@empty($v) (directive) |
if (empty($v)): |
@endempty |
endif; |
| Blade | Virtual PHP |
|---|---|
@session('key') |
if (true): $value = ''; |
@endsession |
endif; |
@context('key') |
if (true): $value = ''; |
@endcontext |
endif; |
@error('field') |
if (true): $message = ''; |
@enderror |
endif; |
These are parsed but produce no semantic PHP effect:
@auth/@endauth,@guest/@endguest,@env(...)/@endenv,@production/@endproduction,@once/@endonce→if (true):/endif;@csrf,@method(...),@push/@endpush,@prepend/@endprepend,@stack(...),@yield(...),@section(...)/@endsection/@show,@extends(...),@include(...)and variants,@includeIf(...),@includeWhen(...),@includeUnless(...),@includeFirst(...),@each(...)→/* @directive */
| Blade | Virtual PHP |
|---|---|
@php ... @endphp |
Raw PHP content (unwrap the delimiters) |
@use('App\Models\Flight') |
use App\Models\Flight; |
@use('App\Models\Flight', 'F') |
use App\Models\Flight as F; |
@use('App\Models\{A, B}') |
use App\Models\{A, B}; |
@inject('metrics', 'App\Services\MetricsService') →
$metrics = new \App\Services\MetricsService();
This gives the variable the correct type for completion.
@class([...]), @style([...]), @checked(...), @selected(...),
@disabled(...), @readonly(...), @required(...) → echo (...);
so their PHP expressions get parsed.
@verbatim ... @endverbatim content becomes PHP comments (it
contains JS template syntax that would confuse the parser).
Any @directive(...) not recognized becomes /* @directive(...) */.
Because the preprocessor maintains one output line per input line, the
BladeSourceMap only stores per-line column adjustments:
pub struct BladeSourceMap {
/// Per-line column adjustment regions.
/// Lines with no adjustments have an empty vec (identity mapping).
adjustments: Vec<Vec<ColumnAdjustment>>,
}
pub struct ColumnAdjustment {
pub blade_col_start: u32,
pub blade_col_end: u32,
pub php_col_start: u32,
pub php_col_end: u32,
}Functions on BladeSourceMap:
blade_to_php(line, col) -> (line, col)— translate a position in the original Blade file to the virtual PHP.php_to_blade(line, col) -> (line, col)— translate a position in the virtual PHP back to the original Blade file.
/// Virtual PHP content generated from Blade files.
/// Maps file URI -> preprocessed PHP source.
pub(crate) blade_virtual_content: Arc<Mutex<HashMap<String, String>>>,
/// Source maps from virtual PHP back to original Blade positions.
/// Maps file URI -> BladeSourceMap.
pub(crate) blade_source_maps: Arc<Mutex<HashMap<String, BladeSourceMap>>>,Add these to Backend::defaults() with empty HashMaps.
A public function in src/blade/mod.rs:
pub fn is_blade_file(uri: &str) -> bool {
uri.ends_with(".blade.php")
}The server should also check languageId == "blade" in did_open
when available (some editors send this for Blade files).
After storing the file content in open_files, check
is_blade_file(&uri). If true:
- Run
blade::preprocess(&content)which returns(virtual_php: String, source_map: BladeSourceMap). - Store the virtual PHP in
blade_virtual_content. - Store the source map in
blade_source_maps. - Call
self.update_ast(&uri, &virtual_php)(not the original content).
If the file is not Blade, the existing path is unchanged.
At the top of handle_completion, after reading content from
open_files, check if this is a Blade file. If so:
- Read the virtual PHP from
blade_virtual_content(this is what gets parsed and resolved against). - Read the source map from
blade_source_maps. - Translate the cursor
positionfrom Blade coordinates to virtual PHP coordinates usingsource_map.blade_to_php(). - Run the normal completion pipeline against the virtual PHP content with the translated position.
- Translate result positions in
CompletionItemtext edits back to Blade coordinates usingsource_map.php_to_blade().
Same pattern: translate the incoming position to virtual PHP coordinates before resolution, translate the result location back if it points into the same Blade file.
Clean up blade_virtual_content and blade_source_maps entries
for the closed file.
The preprocessor injects these declarations at the top of the virtual
PHP (after the <?php prologue):
/** @var \Illuminate\Support\ViewErrorBag $errors */
$errors = new \Illuminate\Support\ViewErrorBag();
/** @var \Illuminate\View\Factory $__env */
$__env = new \Illuminate\View\Factory();Inside @foreach blocks, inject a $loop variable declaration
immediately after the foreach opening:
/** @var object{index: int, iteration: int, remaining: int, count: int, first: bool, last: bool, even: bool, odd: bool, depth: int, parent: ?object} $loop */
$loop = (object)[];Users declare variable types in their Blade templates by writing:
@php
/**
* @bladestan-signature
* @var string $name
* @var \App\Models\User $user
*/
@endphpThe @php ... @endphp block becomes raw PHP. The @var tags are
standard PHPDoc that mago-syntax already parses. The
@bladestan-signature marker is just a comment we ignore. This works
out of the box with the preprocessor and is compatible with the
Bladestan ecosystem. No special handling needed.
Create tests/blade_preprocessor.rs with unit tests:
- Echo expressions:
{{ $var }},{!! $html !!},{{-- comment --}} - Each control directive (if/else/endif, foreach, for, while, switch, forelse, unless, isset, empty)
- Directives with implicit vars (@error, @session, @context)
- Stub directives (@auth, @guest, @csrf, etc.)
- PHP blocks (@php/@endphp, @use)
- Service injection (@inject)
- Inline attribute directives (@class, @checked, etc.)
- Verbatim regions
- Unknown directives
- Mixed lines (HTML with embedded
{{ }}) - Line count preservation (critical invariant)
Create tests/completion_blade.rs with integration tests:
$this->inside a@phpblock$var->where$varis declared via@varPHPDoc$user->inside a@foreachwith typed collection$loop->inside a@foreach$errors->(implicit variable)$valueinside@sessionblock$messageinside@errorblock
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+class_index/classmapto 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
The items below are ordered for incremental delivery. Each step produces a testable, shippable improvement.
Create src/blade/mod.rs, preprocessor.rs, directives.rs,
source_map.rs. Implement the preprocess() function that handles
echo expressions and all control/stub directives. Write the
BladeSourceMap with blade_to_php() and php_to_blade().
Deliverable: A pure function
preprocess(blade_source: &str) -> (String, BladeSourceMap) with
comprehensive unit tests proving line count preservation and correct
PHP output.
Add blade_virtual_content and blade_source_maps to Backend.
Modify did_open, did_change, did_close, completion, and
goto_definition in server.rs to route Blade files through the
preprocessor and translate positions.
Deliverable: Completion works for $var-> inside {{ }} and
@php blocks in .blade.php files.
Inject $errors, $__env, and $loop declarations. Handle
@session/@error/@context implicit $value/$message.
Deliverable: $errors->, $loop->first, and $value inside
@session blocks all produce completions.
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.