Skip to content

Commit 233483b

Browse files
committed
Refactor Laravel demo methods into a single Demo class
1 parent 5cb6455 commit 233483b

3 files changed

Lines changed: 187 additions & 41 deletions

File tree

examples/laravel/app/Demo.php

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/**
33
* Laravel Demo Classes for PHPantom LSP
44
*
5-
* Open any demo() method and trigger completion inside it.
5+
* Open any method and trigger completion inside it.
66
* Requires a real Laravel installation via `composer install`.
77
*/
88

@@ -17,13 +17,13 @@
1717
use Illuminate\Support\Facades\Lang;
1818
use Illuminate\Support\Facades\View;
1919

20-
// ── Eloquent Virtual Properties ─────────────────────────────────────────────
21-
// Alphabetical — every property a through w should appear in order.
22-
// Trigger completion on `$bakery->` and scan the list.
23-
24-
class EloquentPropertyDemo
20+
class Demo
2521
{
26-
public function demo(): void
22+
// ── Eloquent Virtual Properties ─────────────────────────────────────────
23+
// Alphabetical — every property a through w should appear in order.
24+
// Trigger completion on `$bakery->` and scan the list.
25+
26+
public function eloquentProperty(): void
2727
{
2828
$bakery = new Bakery();
2929

@@ -35,7 +35,7 @@ public function demo(): void
3535
$bakery->dough_temp; // $casts 'float' → float
3636
$bakery->egg_count; // $attributes default → int
3737
$bakery->flour; // $fillable (no cast/attr) → mixed
38-
$bakery->fresh(); // #[Scope] method → Builder
38+
$bakery->freshlyBaked(); // #[Scope] attribute method → Builder
3939
$bakery->gluten_free; // $attributes default → bool
4040
$bakery->headBaker; // relationship HasOne → Baker
4141
$bakery->head_baker_count; // relationship count → int
@@ -63,14 +63,11 @@ public function demo(): void
6363
$post->author; // relationship BelongsTo → BlogAuthor
6464
$post->author()->associate($post->author); // associate() on BelongsTo
6565
}
66-
}
6766

6867

69-
// ── Eloquent Query Builder ──────────────────────────────────────────────────
68+
// ── Eloquent Query Builder ──────────────────────────────────────────────
7069

71-
class EloquentQueryDemo
72-
{
73-
public function demo(): void
70+
public function eloquentQuery(): void
7471
{
7572
// Builder-as-static forwarding
7673
BlogAuthor::where('active', true);
@@ -93,7 +90,7 @@ public function demo(): void
9390

9491
// Scopes on Builder instances (convention and #[Scope] attribute)
9592
BlogAuthor::where('active', 1)->active()->ofGenre('sci-fi')->get();
96-
Bakery::where('open', true)->fresh()->get();
93+
Bakery::where('open', true)->freshlyBaked()->get();
9794
$query = BlogAuthor::where('genre', 'fiction');
9895
$query->active();
9996
$query->orderBy('name')->get();
@@ -106,20 +103,17 @@ public function demo(): void
106103
Bakery::whereKitchenId(42); // from $guarded
107104
Bakery::whereOvenCode('X9'); // from $hidden
108105
Bakery::whereFlour('rye')->whereApricot(true)->get();
109-
Bakery::where('open', true)->whereFlour('spelt')->fresh()->first();
106+
Bakery::where('open', true)->whereFlour('spelt')->freshlyBaked()->first();
110107

111108
// Conditionable when()/unless() chain continuation
112109
BlogAuthor::where('active', 1)->when(true, fn($q) => $q)->get();
113110
BlogAuthor::where('active', 1)->unless(false, fn($q) => $q)->first();
114111
}
115-
}
116112

117113

118-
// ── Custom Eloquent Collections ─────────────────────────────────────────────
114+
// ── Custom Eloquent Collections ─────────────────────────────────────────
119115

120-
class CustomCollectionDemo
121-
{
122-
public function demo(): void
116+
public function customCollection(): void
123117
{
124118
// Builder chain → custom collection via #[CollectedBy]
125119
$reviews = Review::where('published', true)->get();
@@ -131,14 +125,11 @@ public function demo(): void
131125
$review = new Review();
132126
$review->replies->topRated(); // HasMany<Review> → ReviewCollection
133127
}
134-
}
135128

136129

137-
// ── Eloquent Closure Parameter Inference ────────────────────────────────────
130+
// ── Eloquent Closure Parameter Inference ────────────────────────────────
138131

139-
class EloquentClosureDemo
140-
{
141-
public function demo(): void
132+
public function eloquentClosure(): void
142133
{
143134
// Eloquent chunk — $orders inferred as Collection
144135
BlogAuthor::where('active', true)->chunk(100, function ($orders) {
@@ -162,13 +153,10 @@ public function demo(): void
162153
$q->where('active', true); // resolves to Builder<BlogAuthor>
163154
});
164155
}
165-
}
166156

167157

168-
// ── Laravel Config & Env Navigation ─────────────────────────────────────────
158+
// ── Laravel Config & Env Navigation ─────────────────────────────────────
169159

170-
class LaravelConfigEnvDemo
171-
{
172160
/**
173161
* "Go to Definition" and "Find All References" for config keys and env vars.
174162
*
@@ -177,7 +165,7 @@ class LaravelConfigEnvDemo
177165
* 2. Ctrl+Click "APP_KEY" to jump to .env.
178166
* 3. "Find All References" on "app.name" to see all usage sites.
179167
*/
180-
public function demo(): void
168+
public function laravelConfigEnv(): void
181169
{
182170
// Global helper
183171
config('app.name');
@@ -190,13 +178,10 @@ public function demo(): void
190178
env('APP_KEY');
191179
env('DB_PASSWORD', 'secret');
192180
}
193-
}
194181

195182

196-
// ── Laravel View, Route & Translation Navigation ───────────────────────────
183+
// ── Laravel View, Route & Translation Navigation ───────────────────────
197184

198-
class LaravelNavigationDemo
199-
{
200185
/**
201186
* "Go to Definition" and "Find All References" for Laravel identifiers.
202187
*
@@ -206,7 +191,7 @@ class LaravelNavigationDemo
206191
* 3. Ctrl+Click "home" to jump to the ->name('home') declaration in routes/web.php.
207192
* 4. Ctrl+Click "auth.failed" to jump to lang/en/auth.php.
208193
*/
209-
public function demo(): void
194+
public function laravelNavigation(): void
210195
{
211196
// Blade Views
212197
view('welcome');
@@ -224,14 +209,11 @@ public function demo(): void
224209
Lang::get('pagination.next');
225210
Lang::has('validation.required');
226211
}
227-
}
228212

229213

230-
// ── Laravel Config (definition & references) ────────────────────────────────
214+
// ── Laravel Config (definition & references) ────────────────────────────
231215

232-
class LaravelConfigDemo
233-
{
234-
public function demo(): void
216+
public function laravelConfig(): void
235217
{
236218
config('app.name');
237219
Config::get('database.default');

examples/laravel/app/Models/Bakery.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public function scopeUnbaked(\Illuminate\Database\Eloquent\Builder $query): void
6464
}
6565

6666
#[Scope]
67-
protected function fresh(\Illuminate\Database\Eloquent\Builder $query): void
67+
protected function freshlyBaked(\Illuminate\Database\Eloquent\Builder $query): void
6868
{
6969
$query->where('fresh', true);
7070
}

examples/laravel/assertions.php

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
/**
3+
* Laravel Demo Assertions
4+
*
5+
* Run: php examples/laravel/assertions.php
6+
*
7+
* These assertions verify that our assumptions about Laravel's runtime
8+
* behaviour are correct, so the LSP can model them accurately.
9+
* Uses only reflection (no database or app boot required).
10+
*/
11+
12+
require_once __DIR__ . '/vendor/autoload.php';
13+
14+
// Boot Eloquent with an in-memory SQLite database
15+
$capsule = new \Illuminate\Database\Capsule\Manager();
16+
$capsule->addConnection([
17+
'driver' => 'sqlite',
18+
'database' => ':memory:',
19+
]);
20+
$capsule->setAsGlobal();
21+
$capsule->bootEloquent();
22+
23+
$passed = 0;
24+
$failed = 0;
25+
26+
function check(string $label, bool $condition): void
27+
{
28+
global $passed, $failed;
29+
if ($condition) {
30+
$passed++;
31+
} else {
32+
$failed++;
33+
echo "FAIL: $label\n";
34+
}
35+
}
36+
37+
function assertMethodVisibility(string $class, string $method, string $expected): void
38+
{
39+
$ref = new ReflectionMethod($class, $method);
40+
$actual = $ref->isPublic() ? 'public' : ($ref->isProtected() ? 'protected' : 'private');
41+
check("$class::$method() is $expected", $actual === $expected);
42+
}
43+
44+
function assertMethodReturnType(string $class, string $method, string $expected): void
45+
{
46+
$ref = new ReflectionMethod($class, $method);
47+
$type = $ref->getReturnType();
48+
$actual = $type ? $type->__toString() : 'mixed';
49+
check("$class::$method() returns $expected (got $actual)", $actual === $expected);
50+
}
51+
52+
// ─── Scope vs Model method shadowing ────────────────────────────────────────
53+
54+
// Model::fresh() is public — a subclass CANNOT define a #[Scope] named "fresh"
55+
// because PHP forbids changing the signature of an inherited public method.
56+
// Our demo uses "freshlyBaked" instead.
57+
check(
58+
'Model::fresh() exists',
59+
method_exists(\Illuminate\Database\Eloquent\Model::class, 'fresh')
60+
);
61+
assertMethodVisibility(\Illuminate\Database\Eloquent\Model::class, 'fresh', 'public');
62+
63+
// Our Bakery uses "freshlyBaked" to avoid the conflict
64+
check(
65+
'Bakery::freshlyBaked() exists',
66+
method_exists(\App\Models\Bakery::class, 'freshlyBaked')
67+
);
68+
assertMethodVisibility(\App\Models\Bakery::class, 'freshlyBaked', 'protected');
69+
70+
// Verify #[Scope] attribute is present on freshlyBaked
71+
$ref = new ReflectionMethod(\App\Models\Bakery::class, 'freshlyBaked');
72+
$attrs = $ref->getAttributes(\Illuminate\Database\Eloquent\Attributes\Scope::class);
73+
check('Bakery::freshlyBaked() has #[Scope] attribute', count($attrs) === 1);
74+
75+
// ─── Convention-based scopes ────────────────────────────────────────────────
76+
77+
// scopeXxx methods are public and accessible via __call as xxx()
78+
check(
79+
'Bakery::scopeUnbaked() exists',
80+
method_exists(\App\Models\Bakery::class, 'scopeUnbaked')
81+
);
82+
assertMethodVisibility(\App\Models\Bakery::class, 'scopeUnbaked', 'public');
83+
84+
check(
85+
'Bakery::scopeTopping() exists',
86+
method_exists(\App\Models\Bakery::class, 'scopeTopping')
87+
);
88+
assertMethodVisibility(\App\Models\Bakery::class, 'scopeTopping', 'public');
89+
90+
// ─── Relationship methods ───────────────────────────────────────────────────
91+
92+
check(
93+
'Bakery::baguettes() exists',
94+
method_exists(\App\Models\Bakery::class, 'baguettes')
95+
);
96+
check(
97+
'Bakery::headBaker() exists',
98+
method_exists(\App\Models\Bakery::class, 'headBaker')
99+
);
100+
check(
101+
'Bakery::masterRecipe() exists',
102+
method_exists(\App\Models\Bakery::class, 'masterRecipe')
103+
);
104+
105+
// ─── Accessor methods ───────────────────────────────────────────────────────
106+
107+
// Legacy accessor
108+
check(
109+
'Bakery::getLoafNameAttribute() exists (legacy accessor)',
110+
method_exists(\App\Models\Bakery::class, 'getLoafNameAttribute')
111+
);
112+
113+
// Modern Attribute accessor
114+
check(
115+
'Bakery::sprinkle() exists (modern accessor)',
116+
method_exists(\App\Models\Bakery::class, 'sprinkle')
117+
);
118+
119+
// ─── Runtime scope behaviour ────────────────────────────────────────────────
120+
121+
// Convention-based scopes via __call on instance return Builder
122+
$bakery = new \App\Models\Bakery();
123+
$result = $bakery->unbaked();
124+
check(
125+
'$bakery->unbaked() returns Builder via __call',
126+
$result instanceof \Illuminate\Database\Eloquent\Builder
127+
);
128+
129+
$result = $bakery->topping('choc');
130+
check(
131+
'$bakery->topping("choc") returns Builder via __call',
132+
$result instanceof \Illuminate\Database\Eloquent\Builder
133+
);
134+
135+
// #[Scope] attribute scopes are available on the query builder
136+
$result = \App\Models\Bakery::query()->freshlyBaked();
137+
check(
138+
'Bakery::query()->freshlyBaked() returns Builder',
139+
$result instanceof \Illuminate\Database\Eloquent\Builder
140+
);
141+
142+
// Static scope forwarding
143+
$result = \App\Models\Bakery::where('flour', 'rye');
144+
check(
145+
'Bakery::where() returns Builder',
146+
$result instanceof \Illuminate\Database\Eloquent\Builder
147+
);
148+
149+
// Model::fresh() on instance (non-existing model returns null)
150+
$result = $bakery->fresh();
151+
check(
152+
'$bakery->fresh() returns null (Model::fresh on non-persisted)',
153+
$result === null
154+
);
155+
156+
// ─── Summary ────────────────────────────────────────────────────────────────
157+
158+
echo "\n";
159+
if ($failed === 0) {
160+
echo "\033[32m✓ All $passed assertions passed.\033[0m\n";
161+
} else {
162+
echo "\033[31m✗ $failed failed, $passed passed.\033[0m\n";
163+
exit(1);
164+
}

0 commit comments

Comments
 (0)