Skip to content

Commit 18499a6

Browse files
authored
feat(laravel): support custom Eloquent builders and improve LSP responsiveness (#118)
Nice work, wasn't an easy one to solve.
1 parent 74d8c3d commit 18499a6

29 files changed

Lines changed: 2842 additions & 278 deletions

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,11 @@ harness = false
5959
[[bench]]
6060
name = "references"
6161
harness = false
62+
63+
[[bench]]
64+
name = "laravel_completion"
65+
harness = false
66+
67+
[[bench]]
68+
name = "custom_builder"
69+
harness = false

benches/custom_builder.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
use criterion::{Criterion, black_box, criterion_group, criterion_main};
2+
use phpantom_lsp::Backend;
3+
use std::collections::HashMap;
4+
use tower_lsp::LanguageServer;
5+
use tower_lsp::lsp_types::*;
6+
7+
fn rt() -> tokio::runtime::Runtime {
8+
tokio::runtime::Builder::new_current_thread()
9+
.enable_all()
10+
.build()
11+
.unwrap()
12+
}
13+
14+
fn leak_str(s: String) -> &'static str {
15+
Box::leak(s.into_boxed_str())
16+
}
17+
18+
async fn setup_laravel_backend() -> Backend {
19+
let mut stubs = HashMap::new();
20+
stubs.insert("Illuminate\\Database\\Eloquent\\Model", "<?php namespace Illuminate\\Database\\Eloquent; class Model { public static function query(): CustomBuilder {} }");
21+
stubs.insert("Illuminate\\Database\\Eloquent\\Builder", "<?php namespace Illuminate\\Database\\Eloquent; class Builder { public function where($column): self {} }");
22+
23+
let mut user_src = String::from(
24+
"<?php namespace App\\Models; class User extends \\Illuminate\\Database\\Eloquent\\Model {\n",
25+
);
26+
for i in 0..200 {
27+
user_src.push_str(&format!(
28+
" public function scopeActive{}($query) {{}}\n",
29+
i
30+
));
31+
user_src.push_str(&format!(" public $prop{};\n", i));
32+
}
33+
user_src.push('}');
34+
stubs.insert("App\\Models\\User", leak_str(user_src));
35+
36+
// Deep inheritance for the builder
37+
for i in 0..10 {
38+
let parent = if i == 0 {
39+
"Illuminate\\Database\\Eloquent\\Builder"
40+
} else {
41+
leak_str(format!("App\\QueryBuilders\\BaseBuilder{}", i - 1))
42+
};
43+
let current = leak_str(format!("App\\QueryBuilders\\BaseBuilder{}", i));
44+
let src = leak_str(format!(
45+
"<?php namespace App\\QueryBuilders; class BaseBuilder{} extends {} {{ public function m{}(): void {{}} }}",
46+
i, parent, i
47+
));
48+
stubs.insert(current, src);
49+
}
50+
51+
stubs.insert("App\\QueryBuilders\\CustomBuilder", "<?php namespace App\\QueryBuilders; use App\\QueryBuilders\\BaseBuilder9; class CustomBuilder extends BaseBuilder9 { public function customMethod(): void {} }");
52+
53+
Backend::new_test_with_stubs(stubs)
54+
}
55+
56+
async fn open_file(backend: &Backend, uri_str: &str, content: &str) -> Url {
57+
let uri = Url::parse(uri_str).unwrap();
58+
let params = DidOpenTextDocumentParams {
59+
text_document: TextDocumentItem {
60+
uri: uri.clone(),
61+
language_id: "php".to_string(),
62+
version: 1,
63+
text: content.to_string(),
64+
},
65+
};
66+
backend.did_open(params).await;
67+
uri
68+
}
69+
70+
fn generate_source() -> String {
71+
r#"<?php
72+
namespace App\Models;
73+
use App\QueryBuilders\CustomBuilder;
74+
75+
/** @var CustomBuilder<\App\Models\User> $query */
76+
$query->wher
77+
"#
78+
.to_string()
79+
}
80+
81+
fn bench_custom_builder_completion(c: &mut Criterion) {
82+
let runtime = rt();
83+
let backend = runtime.block_on(setup_laravel_backend());
84+
let source = generate_source();
85+
let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source));
86+
let lines: Vec<&str> = source.lines().collect();
87+
let line = lines.len() as u32 - 1;
88+
let last_line = lines.last().unwrap();
89+
let col = last_line.len() as u32;
90+
91+
let mut group = c.benchmark_group("custom_builder_completion");
92+
93+
group.bench_function("custom_builder_deep_inheritance", |b| {
94+
b.iter(|| {
95+
runtime.block_on(async {
96+
backend.clear_completion_cache();
97+
let params = CompletionParams {
98+
text_document_position: TextDocumentPositionParams {
99+
text_document: TextDocumentIdentifier { uri: uri.clone() },
100+
position: Position {
101+
line,
102+
character: col,
103+
},
104+
},
105+
work_done_progress_params: WorkDoneProgressParams::default(),
106+
partial_result_params: PartialResultParams::default(),
107+
context: Some(CompletionContext {
108+
trigger_kind: CompletionTriggerKind::INVOKED,
109+
trigger_character: None,
110+
}),
111+
};
112+
let _ = black_box(backend.completion(params).await);
113+
})
114+
})
115+
});
116+
117+
group.finish();
118+
}
119+
120+
criterion_group!(benches, bench_custom_builder_completion);
121+
criterion_main!(benches);

benches/laravel_completion.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
use criterion::{Criterion, black_box, criterion_group, criterion_main};
2+
use phpantom_lsp::Backend;
3+
use std::collections::HashMap;
4+
use tower_lsp::LanguageServer;
5+
use tower_lsp::lsp_types::*;
6+
7+
fn rt() -> tokio::runtime::Runtime {
8+
tokio::runtime::Builder::new_current_thread()
9+
.enable_all()
10+
.build()
11+
.unwrap()
12+
}
13+
14+
async fn setup_laravel_backend() -> Backend {
15+
let mut stubs = HashMap::new();
16+
stubs.insert("Illuminate\\Database\\Eloquent\\Model", "<?php namespace Illuminate\\Database\\Eloquent; class Model { public static function query(): Builder {} }");
17+
stubs.insert("Illuminate\\Database\\Eloquent\\Builder", "<?php namespace Illuminate\\Database\\Eloquent; class Builder { public function where($column): self {} public function whereIn($column, $values): self {} public function orWhere($column): self {} }");
18+
19+
Backend::new_test_with_stubs(stubs)
20+
}
21+
22+
async fn open_file(backend: &Backend, uri_str: &str, content: &str) -> Url {
23+
let uri = Url::parse(uri_str).unwrap();
24+
let params = DidOpenTextDocumentParams {
25+
text_document: TextDocumentItem {
26+
uri: uri.clone(),
27+
language_id: "php".to_string(),
28+
version: 1,
29+
text: content.to_string(),
30+
},
31+
};
32+
backend.did_open(params).await;
33+
uri
34+
}
35+
36+
fn generate_laravel_model_source() -> String {
37+
r#"<?php
38+
namespace App\Models;
39+
use Illuminate\Database\Eloquent\Model;
40+
41+
/**
42+
* @property string $name
43+
* @property string $email
44+
*/
45+
class User extends Model {}
46+
47+
$user = new User();
48+
$user->wher
49+
"#
50+
.to_string()
51+
}
52+
53+
fn bench_laravel_model_completion(c: &mut Criterion) {
54+
let runtime = rt();
55+
let backend = runtime.block_on(setup_laravel_backend());
56+
let source = generate_laravel_model_source();
57+
let uri = runtime.block_on(open_file(&backend, "file:///app/Models/User.php", &source));
58+
let lines: Vec<&str> = source.lines().collect();
59+
let line = lines.len() as u32 - 1;
60+
let last_line = lines.last().unwrap();
61+
let col = last_line.len() as u32; // After 'wher'
62+
63+
let mut group = c.benchmark_group("laravel_completion");
64+
65+
group.bench_function("model_where_prefix", |b| {
66+
b.iter(|| {
67+
runtime.block_on(async {
68+
let params = CompletionParams {
69+
text_document_position: TextDocumentPositionParams {
70+
text_document: TextDocumentIdentifier { uri: uri.clone() },
71+
position: Position {
72+
line,
73+
character: col,
74+
},
75+
},
76+
work_done_progress_params: WorkDoneProgressParams::default(),
77+
partial_result_params: PartialResultParams::default(),
78+
context: Some(CompletionContext {
79+
trigger_kind: CompletionTriggerKind::INVOKED,
80+
trigger_character: None,
81+
}),
82+
};
83+
let _ = black_box(backend.completion(params).await);
84+
})
85+
})
86+
});
87+
88+
// Simulate typing: Model::w -> Model::wh -> Model::whe -> Model::wher
89+
group.bench_function("model_typing_sequence", |b| {
90+
b.iter(|| {
91+
runtime.block_on(async {
92+
for i in 1..=4 {
93+
let current_col = col - 4 + i;
94+
let params = CompletionParams {
95+
text_document_position: TextDocumentPositionParams {
96+
text_document: TextDocumentIdentifier { uri: uri.clone() },
97+
position: Position {
98+
line,
99+
character: current_col,
100+
},
101+
},
102+
work_done_progress_params: WorkDoneProgressParams::default(),
103+
partial_result_params: PartialResultParams::default(),
104+
context: Some(CompletionContext {
105+
trigger_kind: CompletionTriggerKind::INVOKED,
106+
trigger_character: None,
107+
}),
108+
};
109+
let _ = black_box(backend.completion(params).await);
110+
}
111+
})
112+
})
113+
});
114+
115+
group.finish();
116+
}
117+
118+
criterion_group!(benches, bench_laravel_model_completion);
119+
criterion_main!(benches);

docs/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Laravel custom Eloquent builder support.** Models using the `#[UseEloquentBuilder]` attribute now have their custom builder's methods forwarded as static methods on the model. `query()`, `newQuery()`, and `newModelQuery()` return the custom builder type with correct generic model substitution. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.
13+
1014
### Fixed
1115

1216
- **`@mixin` with union types.** `@mixin Foo|Bar` now correctly exposes members from all classes in the union. Previously only single-class mixins were recognized.
@@ -15,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1519
- **Consolidated class completion passes.** The previous 5-pass architecture (use-map, same-namespace, fqn_uri_index, fqn_uri_index duplicate, stub_index) has been simplified to 2 passes (fqn_uri_index + stub_index) with an inline `classify` closure that determines tier (`'0'` use-imported, `'1'` same/sub-namespace, `'2'` everything else) per candidate. The redundant pass 4 (identical to pass 3) is eliminated, and tier assignment is now based on proximity checks rather than which data source produced the item.
1620
- **Analysis deadlock.** Lazily-parsed vendor files acquired two internal locks in the opposite order from the editor's file-change handler, causing a deadlock when both ran concurrently.
1721

22+
### Changed
23+
24+
- **Improved LSP responsiveness.** File parsing (`update_ast`) and diagnostics now run in background tasks, preventing interactive requests (completion, hover) from being blocked by full-file parses during typing. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.
25+
- **Member completion caching.** Unfiltered member lists are cached per-target to speed up subsequent completions during keyword entry. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.
26+
- **Laravel startup performance.** Common Laravel builder classes are warmed in the background at startup to eliminate the first-access penalty on Eloquent completions. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118.
27+
1828
## [0.8.0] - 2026-05-14
1929

2030
### Added

docs/todo.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ within the same impact tier.
2929

3030
> **Note:** F1 (Workspace symbol search), F2 (Document symbols), A8
3131
> (Implement interface methods), A9 (Auto import), D1 (Unknown class
32-
> diagnostic), and D3 (Unknown member diagnostic) were originally
33-
> planned here but have already shipped.
32+
> diagnostic), D3 (Unknown member diagnostic), and L4 (Custom Eloquent
33+
> builders) were originally planned here but have already shipped.
3434
3535
## Sprint 6 — 1.0 release, editor plugins & type intelligence
3636

@@ -151,8 +151,7 @@ unlikely to move the needle for most users.
151151
| S4 | Named argument awareness in active parameter | Low-Medium | Medium |
152152
| S5 | Language construct signature help and hover | Low | Low |
153153
| | **[Laravel](todo/laravel.md)** | | |
154-
| L4 | Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`) | Medium | Medium |
155-
| L3 | `$dates` array (deprecated) | Low-Medium | Low |
154+
| L3 | `$dates` array (deprecated) | Low-Medium | Low |
156155
| L6 | Factory `has*`/`for*` relationship methods | Low-Medium | Medium |
157156
| L7 | `$pivot` property on BelongsToMany | Medium | Medium-High |
158157
| L8 | `withSum`/`withAvg`/`withMin`/`withMax` aggregate properties | Low-Medium | Medium-High |

docs/todo/laravel.md

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -238,45 +238,6 @@ today and what is still missing.
238238

239239
---
240240

241-
#### L4. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
242-
243-
**Impact: High · Effort: Medium**
244-
245-
Custom builders are the recommended pattern for complex query scoping
246-
in modern Laravel. Without this, users get zero completions for
247-
builder-specific methods via static model calls.
248-
249-
Laravel 11+ introduced the `HasBuilder` trait and
250-
`#[UseEloquentBuilder(UserBuilder::class)]` attribute to let models
251-
declare a custom builder class. When present, `User::query()` and
252-
all static builder-forwarded calls should resolve to the custom
253-
builder instead of the base `Illuminate\Database\Eloquent\Builder`.
254-
255-
```php
256-
/** @extends Builder<User> */
257-
class UserBuilder extends Builder {
258-
/** @return $this */
259-
public function active(): static { ... }
260-
}
261-
262-
class User extends Model {
263-
/** @use HasBuilder<UserBuilder> */
264-
use HasBuilder;
265-
}
266-
267-
User::query()->active()->get(); // active() should resolve on UserBuilder
268-
```
269-
270-
Larastan handles this via `BuilderHelper::determineBuilderName()`,
271-
which inspects `newEloquentBuilder()`'s return type or the
272-
`#[UseEloquentBuilder]` attribute to find the custom builder class.
273-
274-
**Where to change:** In `build_builder_forwarded_methods`, before
275-
loading the standard `Eloquent\Builder`, check whether the model
276-
declares a custom builder via `@use HasBuilder<X>` in `use_generics`
277-
or a `newEloquentBuilder()` method with a non-default return type.
278-
If found, load and resolve that builder class instead.
279-
280241
#### L5. `abort_if`/`abort_unless` type narrowing
281242

282243
**Impact: High · Effort: Medium**

examples/laravel/app/Models/Baker.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace App\Models;
44

55
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
67

8+
#[UseEloquentBuilder(BakerBuilder::class)]
79
class Baker extends Model
810
{
911
public function getName(): string { return ''; }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
7+
/**
8+
* @template TModel of \App\Models\Baker
9+
* @extends Builder<TModel>
10+
*/
11+
class BakerBuilder extends Builder
12+
{
13+
/**
14+
* @return $this
15+
*/
16+
public function active()
17+
{
18+
return $this->where('active', true);
19+
}
20+
}

0 commit comments

Comments
 (0)