Skip to content

Commit ab6d00e

Browse files
committed
Clean up framework patching
1 parent cc071c3 commit ab6d00e

11 files changed

Lines changed: 854 additions & 49 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -551,9 +551,14 @@ resolve_class_fully(class)
551551
├── 1. resolve_class_with_inheritance(class)
552552
│ └── Returns base-resolved ClassInfo
553553
554-
└── 2. For each provider (in priority order):
555-
└── if applies_to(class): merge provide(class) into result
556-
└── Skips members that already exist (no overwrites)
554+
├── 2. For each provider (in priority order):
555+
│ └── if applies_to(class): merge provide(class) into result
556+
│ └── Skips members that already exist (no overwrites)
557+
558+
├── 3. Merge members from implemented interfaces
559+
560+
└── 4. apply_laravel_patches(class, fqn)
561+
└── Post-resolution fixups for Laravel classes (see below)
557562
```
558563

559564
Provider priority order (highest first):
@@ -566,6 +571,18 @@ Two providers are currently registered in `default_providers()`:
566571
- **`LaravelModelProvider`** (`virtual_members/laravel.rs`): synthesizes virtual members for classes extending `Illuminate\Database\Eloquent\Model`. Produces relationship properties (methods returning `HasMany`, `HasOne`, `BelongsTo`, etc. generate a virtual property typed from the relationship's generic parameters), scope methods (both the `scopeActive` naming convention and the `#[Scope]` attribute from Laravel 11+ are supported; either style becomes `active()` as both static and instance), Builder-as-static forwarding (`User::where()->get()` resolves end-to-end), accessors (legacy `getXAttribute()` and modern `Attribute` casts), and cast properties (`$casts` array or `casts()` method entries are mapped to PHP types like `datetime` to `\Carbon\Carbon`, `boolean` to `bool`, custom cast classes to their `get()` return type). Highest priority among virtual member providers. Scope methods are also injected onto `Builder<Model>` instances via a post-generic-substitution hook in `type_hint_to_classes_depth` (see "Scope Methods on Builder Instances" below).
567572
- **`PHPDocProvider`** (`virtual_members/phpdoc.rs`): parses `@method`, `@property`, `@property-read`, `@property-write`, and `@mixin` tags from the class-level docblock stored in `ClassInfo.class_docblock`. Explicit `@method` / `@property` tags are not parsed eagerly during AST extraction; instead, the raw docblock string is preserved and parsed lazily when `provide` is called. For `@mixin` tags, the provider loads the referenced classes and merges their public members. Within the provider, explicit tags take precedence over mixin members. Recurses into mixin-of-mixin chains up to `MAX_MIXIN_DEPTH`.
568573

574+
### Laravel Class Patches
575+
576+
After virtual members and interface members are merged, `resolve_class_fully_inner` calls `apply_laravel_patches(class, fqn)` from `virtual_members/laravel/patches.rs`. Unlike virtual member providers (which *add* new members), patches *modify* existing members' type information to fix framework-specific type inaccuracies that break chain resolution.
577+
578+
The patch system is centralized in a single module with one entry point. Dispatching is based on the fully-qualified class name and trait usage. Current patches:
579+
580+
1. **`Eloquent\Builder::__call` / `__callStatic` return type.** Laravel's `Builder::__call()` is declared as returning `mixed`, but in practice always returns `$this` (scope dispatch, macro dispatch, Query\Builder forwarding). The patch overrides the return type to `static` so that method chains through unknown calls preserve the Builder type.
581+
582+
2. **`Conditionable::when()` / `unless()` return type.** The trait declares `@return $this|TWhenReturnType` but the unresolved method-level template parameter `TWhenReturnType` prevents `is_self_like_type` from recognizing the return as self-referential, breaking method chain resolution on Builder and Collection. The patch replaces the return type with `$this`. Applied to the `Conditionable` trait itself, to `Eloquent\Builder` (which uses the trait), and to any class whose `used_traits` includes `Conditionable`.
583+
584+
3. **Bare `Builder` return types on scope methods.** Handled separately in `scopes.rs` (`is_bare_builder_type`) because it runs at scope-injection time (post-generic-substitution), not during `resolve_class_fully_inner`. Documented in the patch module as part of the inventory but not dispatched from it.
585+
569586
### Precedence Rules
570587

571588
- **Class own members** always win.

docs/CHANGELOG.md

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

1010
### Added
1111

12+
- **`Conditionable::when()` / `unless()` chain resolution.** Chained calls after `when()` or `unless()` on Eloquent Builder, Collection, and any class using the `Conditionable` trait now resolve correctly. Previously the unresolved `TWhenReturnType` template parameter in the return type broke chain continuation, so `Builder::where(...)->when(...)->get()` lost type information after `when()`.
1213
- **`whereHas` / `whereDoesntHave` closure parameter from relation traversal.** The closure parameter in `whereHas`, `orWhereHas`, `whereDoesntHave`, `orWhereDoesntHave`, `withWhereHas`, and related methods is now typed as `Builder<RelatedModel>` instead of `Builder<CallingModel>`. The relation name string (first argument) is resolved by walking the model's relationship methods. Dot-notation chains like `whereHas('category.articles', fn($q) => ...)` follow each segment to the final related model. Works for static calls (`Brand::whereHas(...)`), builder instance calls (`$query->whereHas(...)`), and body-inferred relationships (no `@return` annotation). Falls back to the calling model's builder when the relation cannot be resolved.
1314
- **Eloquent `where{PropertyName}()` dynamic methods.** Calls like `User::whereBrandId(42)` and `$query->whereLangCode('en')` now resolve instead of producing `unknown_member` diagnostics. For each known column on an Eloquent model (from `$casts`, `$attributes`, `$fillable`/`$guarded`/`$hidden`/`$appends`, `$dates`, timestamps, and `@property` annotations), a virtual `where{StudlyCase}()` method is synthesized on both the model (as a static method) and the Builder (as an instance method). Each method accepts a `mixed` value parameter and returns `Builder<ConcreteModel>`, so chains like `Bakery::whereFlour('rye')->whereApricot(true)->get()` resolve end-to-end.
1415
- **Eloquent timestamp properties.** Models extending `Illuminate\Database\Eloquent\Model` now automatically get `created_at` and `updated_at` virtual properties typed as `Carbon\Carbon`. Setting `$timestamps = false` suppresses both. Overriding `CREATED_AT` or `UPDATED_AT` constants changes the column name; setting either to `null` disables that column. Explicit `$casts` entries (e.g. `'created_at' => 'immutable_datetime'`) take priority over the timestamp defaults.

docs/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ unlikely to move the needle for most users.
154154
| E2 | Project-level stubs as type resolution source | Medium | Medium |
155155
| E3 | IDE-provided and `.phpantom.toml` stub paths | Low-Medium | Low |
156156
| E6 | Stub install prompt for non-Composer projects | Low | Low |
157+
| E7 | [Stub-based framework patches](todo/external-stubs.md#e7-stub-based-framework-patches) | Medium | Medium |
157158
| | **[Performance](todo/performance.md)** | | |
158159
| P1.5 | [Layered class resolution (zero-copy inheritance)](todo/performance.md#p15-layered-class-resolution-zero-copy-inheritance) | High | Very High |
159160
| P13 | [Tiered storage: drop per-file maps for non-open files](todo/performance.md#p13-tiered-storage-drop-per-file-maps-for-non-open-files) | Medium-High | Medium-High |

docs/todo/external-stubs.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ embedded stubs, built-in symbols would be invisible.
591591
| E2 | Project-level stubs as a type resolution source | Medium | indexing.md (byte-level function/constant scanner) |
592592
| E3 | IDE-provided and `.phpantom.toml` stub paths | Low | E2 |
593593
| E4 | Ship SPL overlay stubs, let external stubs override | Low | E2 |
594+
| E7 | Stub-based framework patches (replace Rust patch system) | Medium | E2 or E3 |
594595

595596
E1 can be done immediately and independently. It provides
596597
immediate value (GTD on `array_map`, `PDO`, `Iterator`, etc.) with
@@ -663,3 +664,117 @@ answer (`true` or `false`) is written to `[stubs] install` in
663664
This is not implemented yet. The config writing infrastructure
664665
(using `toml_edit` to preserve comments and formatting) is a
665666
prerequisite.
667+
668+
---
669+
670+
## E7. Stub-based framework patches
671+
672+
**Impact: Medium · Effort: Medium · Dependencies: E2 or E3**
673+
674+
Replace the Rust-coded Laravel class patch system
675+
(`virtual_members/laravel/patches.rs`) with plain PHP stub files that
676+
override specific declarations. Instead of patching return types in
677+
Rust after resolution, ship corrected stubs that the normal stub
678+
loading pipeline picks up at a higher priority than the framework's
679+
own declarations.
680+
681+
### Motivation
682+
683+
The current patch system (`apply_laravel_patches`) fixes framework
684+
type inaccuracies by mutating resolved `ClassInfo` in Rust code.
685+
This works but has drawbacks:
686+
687+
- **Contributor barrier.** Adding a patch for a new framework (Symfony,
688+
WordPress, Drupal) or fixing a Laravel type requires Rust knowledge.
689+
- **Maintenance burden.** Framework updates may change signatures;
690+
keeping Rust code in sync is harder than updating a PHP stub file.
691+
- **Not user-extensible.** Users cannot add their own patches for
692+
project-specific quirks without forking PHPantom.
693+
694+
Stub overrides solve all three. A PHP developer who understands the
695+
framework can write a corrected stub, submit a PR, and never touch
696+
Rust. Users can drop override stubs into their `.phpantom.toml`
697+
`[stubs] paths` to fix types locally.
698+
699+
### What the stubs would look like
700+
701+
For the Conditionable `when()`/`unless()` patch, the override stub
702+
would be:
703+
704+
```php
705+
namespace Illuminate\Support\Traits;
706+
707+
trait Conditionable {
708+
/** @return $this */
709+
public function when(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed {}
710+
711+
/** @return $this */
712+
public function unless(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed {}
713+
}
714+
```
715+
716+
For the Eloquent Builder `__call` patch:
717+
718+
```php
719+
namespace Illuminate\Database\Eloquent;
720+
721+
class Builder {
722+
/** @return static */
723+
public function __call(string $method, array $parameters): mixed {}
724+
725+
/** @return static */
726+
public static function __callStatic(string $method, array $parameters): mixed {}
727+
}
728+
```
729+
730+
These are standard PHP stub files. They declare only the members that
731+
need overriding. The stub loading pipeline merges them at a higher
732+
priority than the framework's own declarations, so the corrected
733+
`@return` types win.
734+
735+
### Bundled override stubs
736+
737+
PHPantom would ship a set of override stubs for common frameworks,
738+
organized by framework:
739+
740+
```
741+
stubs/overrides/
742+
├── laravel/
743+
│ ├── Conditionable.stub.php
744+
│ ├── EloquentBuilder.stub.php
745+
│ └── ...
746+
├── symfony/
747+
│ └── ...
748+
└── wordpress/
749+
└── ...
750+
```
751+
752+
These would be embedded at build time (like phpstorm-stubs today) and
753+
loaded into the stub index at a priority above vendor stubs but below
754+
user `.phpantom.toml` paths. Framework detection (already done for
755+
Laravel via `composer.json` inspection) controls which set is loaded.
756+
757+
### User-provided override stubs
758+
759+
Once E3 lands, users can place their own override stubs in a
760+
`.phpantom.toml` `[stubs] paths` directory. Since user paths have the
761+
highest priority, they override both bundled overrides and vendor
762+
stubs. This makes the system fully extensible without code changes.
763+
764+
### Migration path
765+
766+
1. Implement E2 or E3 (external stub loading with priority).
767+
2. Write override stubs for the patches currently in
768+
`patches.rs` (Builder `__call`, Conditionable `when`/`unless`).
769+
3. Add a bundled-overrides loading phase to the stub pipeline,
770+
between vendor stubs and user stubs.
771+
4. Remove the Rust patch functions from `patches.rs`.
772+
5. Document how to contribute framework override stubs (just PHP,
773+
no Rust needed).
774+
775+
### Scope of the Rust patch system until then
776+
777+
The current `patches.rs` module handles the known cases correctly
778+
and is well-tested. It stays in place until the stub override
779+
infrastructure from E2/E3 is ready. New patches can still be added
780+
in Rust in the interim.

docs/todo/laravel.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,3 @@ hard-coding the two known classes. A simpler approach: add
428428
`@method` tags to bundled stubs for the most common dynamic `with*`
429429
methods, or document this as a known limitation.
430430

431-
432-
433-

example.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,6 +1768,12 @@ public function demo(): void
17681768
Bakery::whereOvenCode('X9'); // from $hidden
17691769
Bakery::whereFlour('rye')->whereApricot(true)->get();
17701770
Bakery::where('open', true)->whereFlour('spelt')->fresh()->first();
1771+
1772+
// Conditionable when()/unless() chain continuation
1773+
// The patch replaces the unresolved TWhenReturnType template param
1774+
// with $this so that chained calls after when()/unless() resolve.
1775+
BlogAuthor::where('active', 1)->when(true, fn($q) => $q)->get();
1776+
BlogAuthor::where('active', 1)->unless(false, fn($q) => $q)->first();
17711777
}
17721778
}
17731779

@@ -5819,6 +5825,7 @@ public function hasOneThrough(string $related, string $through) {}
58195825
class Builder implements \Illuminate\Contracts\Database\Eloquent\Builder {
58205826
/** @use \Illuminate\Database\Concerns\BuildsQueries<TModel> */
58215827
use \Illuminate\Database\Concerns\BuildsQueries;
5828+
use \Illuminate\Support\Traits\Conditionable;
58225829

58235830
/**
58245831
* @param (\Closure(static): mixed)|string|array $column
@@ -5963,6 +5970,27 @@ public function getIterator(): \ArrayIterator { return new \ArrayIterator([]); }
59635970
}
59645971
}
59655972

5973+
namespace Illuminate\Support\Traits {
5974+
5975+
/**
5976+
* @template TWhenReturnType
5977+
* @template TUnlessReturnType
5978+
*/
5979+
trait Conditionable {
5980+
/**
5981+
* @param (callable($this): TWhenReturnType)|null $callback
5982+
* @return $this|TWhenReturnType
5983+
*/
5984+
public function when(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { return $this; }
5985+
5986+
/**
5987+
* @param (callable($this): TUnlessReturnType)|null $callback
5988+
* @return $this|TUnlessReturnType
5989+
*/
5990+
public function unless(mixed $value = null, ?callable $callback = null, ?callable $default = null): mixed { return $this; }
5991+
}
5992+
}
5993+
59665994
namespace Illuminate\Contracts\Database\Eloquent {
59675995
/**
59685996
* @mixin \Illuminate\Database\Eloquent\Builder

src/completion/call_resolution.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,12 @@ fn is_self_like_type(ty: &PhpType) -> bool {
114114
PhpType::Generic(n, _) => is_self_keyword(n),
115115
PhpType::Nullable(inner) => is_self_like_type(inner),
116116
PhpType::Union(members) => {
117-
// `static|null`, `$this|void` — filter out non-class types
118-
// that cannot produce a class resolution, then check that
119-
// every remaining member is self-like.
120-
let significant: Vec<_> = members
117+
// `static|null` — every non-null member is self-like.
118+
let non_null: Vec<_> = members
121119
.iter()
122-
.filter(|m| !matches!(m, PhpType::Named(n) if n == "null" || n == "void" || n == "never"))
120+
.filter(|m| !matches!(m, PhpType::Named(n) if n == "null"))
123121
.collect();
124-
!significant.is_empty() && significant.iter().all(|m| is_self_like_type(m))
122+
!non_null.is_empty() && non_null.iter().all(|m| is_self_like_type(m))
125123
}
126124
_ => false,
127125
}

src/virtual_members/laravel/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ mod builder;
8080
mod casts;
8181
mod factory;
8282
mod helpers;
83+
pub(crate) mod patches;
8384
mod relationships;
8485
mod scopes;
8586
mod where_property;

0 commit comments

Comments
 (0)