Skip to content

Add Laravel-aware completions and diagnostics to the snippet editor - #13

Merged
moessimple merged 13 commits into
mainfrom
feature/laravel-lsp
Aug 24, 2026
Merged

Add Laravel-aware completions and diagnostics to the snippet editor#13
moessimple merged 13 commits into
mainfrom
feature/laravel-lsp

Conversation

@moessimple

Copy link
Copy Markdown
Owner

Why

The snippet editor already understood generic PHP through intelephense, but had no notion of
Laravel's own vocabulary. Writing config('app.na or route('users.show' in a snippet got no
completion for the actual keys or route names defined in the target project, and a typo'd route
or config key wasn't caught until the snippet ran and failed.

What changed

The editor now also runs Laravel's own language server against the real target project,
alongside the existing one. route(), config(), and env() calls get completions and hover
information pulled from that project's actual routes, config, and environment; Eloquent model
completions can reflect real database columns. A reference to a route or config key that doesn't
exist is flagged before the snippet ever runs. Completion trigger characters and documentation
formatting now come from what each language server itself declares rather than being hardcoded
for the original server alone, so typing a dot or quote inside a call narrows suggestions
correctly and formatted documentation renders instead of showing raw markdown syntax.

Verification

  • Full test suite passing: 187 PHP tests, 164 JS tests, including new coverage for the
    background process that runs the language server, the new endpoint that starts it, and
    completion/diagnostic behavior with both language servers attached together.
  • Manually verified against a real Herd project: config() and route() completions return
    real project data, an unknown route is flagged, and no test-helper file is written into the
    target project as a side effect of attaching.
  • Three issues only surfaced through that manual verification, not from isolated tests: the new
    language server couldn't find a PHP interpreter when launched from a web request, a background
    process could crash once nothing was left reading its output, and the language server's own
    PHP auto-detection failed for the same underlying reason as the first issue.

Risks

  • Two language server processes now start per editor session instead of one, roughly doubling
    the background process and connection overhead per snippet opened.
  • Every keystroke now notifies both language servers instead of one, a low but real increase in
    messages sent between the browser and the local background processes.

Review focus

  • The process that keeps the new language server alive after the request that started it
    finishes: its output pipes get closed once nothing is reading them, so anything that still
    writes to them afterward can crash the whole process (app/Support/bin/language-server-bridge.mjs).
  • How the target project's PHP interpreter is resolved and passed through, since getting this
    wrong reintroduces failures that only appear when running under the real web server, not from
    a terminal.
  • A new PHP package dependency (laravel/lsp) was added to composer.json.

Documentation and visuals

Hover and completion text with formatting (code spans, links) now renders properly instead of
showing raw markdown syntax.

moessimple and others added 13 commits August 24, 2026 07:34
Framework-aware LSP for routes, config, env vars, Eloquent, and more,
to attach alongside intelephense in the snippet editor. Installed as
a normal project dependency (vendor/bin/laravel-lsp) rather than a
global tool, mirroring how intelephense already lives in node_modules.

Excludes vendor/laravel/lsp/app/ from the classmap: it autoloads its
own App\Providers\AppServiceProvider under the same App\ namespace
this project uses, which Composer otherwise flags as ambiguous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pulls the https/wss server setup, origin verification, idle timeout,
and connection-forwarding logic out of intelephense-bridge.mjs into a
reusable runBridge() so a second bridge (for laravel-lsp) doesn't have
to duplicate it. Behavior-preserving: intelephense-bridge.mjs becomes
a thin caller supplying its own binary, args, and initialize rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mirrors LanguageServerBridge/intelephense-bridge.mjs exactly, on top
of the shared runBridge() core: spawns vendor/bin/laravel-lsp,
rewrites the initialize request's rootUri/rootPath/workspaceFolders
to the target project, and sets initializationOptions
(phpEnvironment: herd; pestGenerateDocBlocks disabled so attaching
never writes into the target project's storage/ directory).

No HTTP endpoint yet - that's the next task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
POST api/projects/{project}/laravel-language-server, mirroring the
existing intelephense endpoint exactly: resolves the Herd project
path, starts the LaravelLspBridge, and returns its port as JSON.

Verified against the live app: a real request against the "demo"
Herd project returns {"port": ...} with a 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the project-string parameter with a config object carrying
what now differs between servers: the port-request URL, the Monaco
marker owner key, and optional initializationOptions merged into the
initialize request. Completion/hover/signatureHelp registration, the
JSON-RPC plumbing, and the WebSocket lifecycle stay shared - this is
a signature change, not new logic, so it can be called a second time
for laravel-lsp without duplicating any of that.

MonacoEditor.vue's single existing call site is updated to the new
shape in this same commit (still only the one intelephense call) so
the build isn't left broken until the second call is added next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
laravel-lsp is a #!/usr/bin/env php script, unlike intelephense which
is self-contained JS. When the bridge is spawned from a real HTTP
request (Herd's web server process), its PATH doesn't reliably
resolve `php` the way an interactive shell does, so the shebang fails
silently and the child process dies immediately after the WebSocket
connects - reproduced with a live request against a real Herd project.

Resolves the same way this app already resolves any project's PHP
binary (Herd::phpBinary(), via `herd which-php`) rather than PHP's own
PHP_BINARY constant, which under PHP-FPM points at the fpm binary
itself, not a script-capable CLI interpreter.

Adds a regression test that sends a real initialize request through
the bridge and asserts on the response, not just that the socket
connects - the existing tests couldn't have caught this, since the
wrapping bridge's own WebSocket server accepts a connection before the
child process is even spawned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Calls attachLanguageServer twice on mount - once for intelephense
(existing), once for laravel-lsp (new, with initializationOptions for
phpEnvironment/pestGenerateDocBlocks) - as two independent handles,
each with its own .catch(() => undefined). One server failing to
attach doesn't affect the other; both get notifyContentChanged on
every edit and dispose() on unmount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Process::options(['create_new_console' => true]) lets the bridge
survive past the HTTP request, but the parent PHP Process object
closes its read end of the bridge's own stdout/stderr pipes once
garbage collected shortly after the request ends. From that point on,
any write to those streams (e.g. createServerProcess()'s own stderr
relay of the child language server's output) throws EPIPE as an
uncaught exception, crashing the whole bridge - silently, since
nothing was watching for it. Reproduced live: a real request against
this app crashed the bridge mid-session as soon as the analyzed
language server wrote enough of its own stderr.

This affects both bridges equally (same shared core), though it
manifests far more often for laravel-lsp, which does real project
analysis and logs proportionally more than intelephense in a typical
session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
laravel-lsp's own phpEnvironment: 'herd' auto-detection (`herd
which-php`) fails the same way its own shebang did: the nested spawn
chain (PHP web request -> this app's bridge -> php laravel-lsp) never
gives that inner `herd` invocation a PATH with `herd` on it either, so
it silently falls back to an unqualified `php`, which then can't run
`artisan tinker` against the target project at all. Every completion
request returned a PHP-runner error instead of real results - even
though the bridge itself was healthy.

Resolves the target project's own PHP via Herd::phpBinary($project) -
the same method already used to resolve intelephense's - and passes
it as an explicit phpCommand in initializationOptions, bypassing
laravel-lsp's own broken auto-detection entirely.

Adds a regression test that does a real completion round trip against
a real project and asserts actual items come back, not just that the
connection stays open - the exact gap that let this reach production
undetected. Verified fixed against a live Herd project in a real
browser: route()/config() completions and diagnostics now work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
triggerCharacters was hardcoded to intelephense's needs ($, >, :),
which meant typing "." or a quote never re-queried the server for a
narrower completion list - e.g. config('app. kept showing the
first, top-level batch of keys fetched right after the opening quote
instead of asking again for app.*, since Monaco only re-invokes a
completion provider on its own declared trigger characters.

Reads completionProvider.triggerCharacters from each server's own
initialize response instead, the same thing a real LSP client (VS
Code included) already does - this is what makes the two servers
behave correctly side by side, since intelephense and laravel-lsp
trigger on different characters for good reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both servers send markdown-formatted documentation (code spans,
links) regardless of the plaintext-only documentationFormat this
client declared - confirmed directly against laravel-lsp, which
returns e.g. a config value's resolved value plus a link to its
source file as markdown. Hover already rendered this correctly since
Monaco's Hover.contents is always markdown by construction, but
CompletionItem.documentation and SignatureInformation.documentation
are `string | IMarkdownString`: passing a bare string (what
toPlainText() returns) renders it as literal text, so this markdown
was showing up as raw, unrendered syntax in completion popups and
signature help instead of formatted the way VS Code shows it.

Also declares markdown support in capabilities.textDocument (all
three: completion, hover, signatureHelp) to match what's actually
handled now, rather than a preference neither server honors anyway.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same principle as the completion provider's trigger characters:
signatureHelpTriggerCharacters was hardcoded to ['(', ','], which
happens to already match intelephense's own declaration, but
laravel-lsp doesn't declare signatureHelpProvider at all (it has no
signature help support). Reading it from the server's own initialize
response instead means laravel-lsp's connection no longer registers
trigger characters for a feature it can't answer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… and drop the unused initializationOptions plumbing
@moessimple
moessimple merged commit 0240d1a into main Aug 24, 2026
1 check passed
@moessimple
moessimple deleted the feature/laravel-lsp branch August 24, 2026 11:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant