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.
Known issues in the current implementation:
Hover works (correct type info is shown) but the highlight range
appears at the wrong position. The hover handler in server.rs
translates the inbound cursor position (blade→php) but does not
translate the returned Hover.range back (php→blade). Other
handlers (rename, highlights, linked editing) already do this
translation.
Code actions like "Import class" insert a use statement at the top
of the file rather than inside a @php / <?php block. All code
actions that produce text edits need their ranges translated, and
actions that generate new code need to be aware of Blade structure.
The @forelse directive is not in the directive list. The spec
defines @forelse ($arr as $v) → foreach ($arr as $v): and
@empty (in forelse context) → endforeach; if (false): with
@endforelse → endif;.
Remaining work in this phase:
These directives should inject implicit variables into the virtual PHP but currently fall through to the generic directive handler:
| 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 directives are recognized by the preprocessor but currently produce generic output rather than the semantic PHP they should:
@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 */
@verbatim ... @endverbatim content should become PHP comments (it
contains JS template syntax that would confuse the parser). Not yet
implemented.
The preprocessor injects $errors and $__env in the prologue, but
does not yet inject a $loop variable inside @foreach blocks:
/** @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)[];The server checks is_blade_file() (URI suffix) but does not yet
check languageId == "blade" from the did_open params. Some
editors send this for Blade files.
- Directives with implicit vars (
@error,@session,@context) - Stub directives (
@auth,@guest,@csrf, etc.) - Verbatim regions
$loop->inside a@foreach$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
Steps 1-2 are complete. The remaining steps build on the existing preprocessor and LSP pipeline.
Inject $loop inside @foreach blocks. Handle @session/@error/
@context implicit variables. Implement stub directives and verbatim
regions. Add languageId check.
Deliverable: $loop->first, $value inside @session blocks,
and $message inside @error 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.