Skip to content

Commit 01af2ac

Browse files
committed
Add support for Eloquent $dates array as Carbon properties
1 parent 3ab54e8 commit 01af2ac

12 files changed

Lines changed: 619 additions & 127 deletions

File tree

docs/CHANGELOG.md

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

1212
- **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.
13+
- **Eloquent `$dates` array.** Legacy models using `protected $dates = [...]` now get `Carbon\Carbon`-typed virtual properties for each listed column. Explicit `$casts` entries take priority when both define the same column.
1314
- **`fix` CLI subcommand.** `phpantom_lsp fix` applies automated code fixes across a project, modeled after php-cs-fixer. Specify rules with `--rule unused_import` (multiple allowed) or omit to run all preferred native fixers. `--dry-run` reports what would change without writing files (exit code 2). `--with-phpstan` enables PHPStan-based fixers (not yet implemented). The first shipped rule, `unused_import`, removes all unused `use` statements project-wide, handling simple imports, group imports, and blank-line collapsing. Supports path filtering (`fix src/`) and single-file mode.
1415
- **Array element type extraction from property generics.** Bracket access on properties annotated with generic array types (e.g. `$this->cache[$key]->` where `cache` is `array<string, IntCollection>`, or `$this->translations[0]->` where `translations` is `Collection<int, Translation>`) now resolves the element type correctly. Previously the generic parameters were lost during property chain resolution, causing "subject type could not be resolved" on any member access after the bracket. Works for `$this->prop`, `$obj->prop`, nested chains like `$this->nested->prop[0]->`, string-literal keys, and method chains after the bracket access.
1516
- **Template binding from closure return types.** Methods like `Collection::reduce()` that declare a method-level `@template` parameter bound through a callable's return type now resolve correctly. When the closure or arrow function argument has a return type annotation (e.g. `fn(Decimal $carry, $item): Decimal => ...`), the return type binds the template parameter, so the method's own return type resolves to the concrete class. This works for `fn()` arrow functions, `function()` closures, and closures with `use()` clauses.

docs/todo.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,14 @@ within the same impact tier.
2121

2222
## Sprint 4 — Refactoring toolkit & type inference
2323

24-
| # | Item | Impact | Effort |
25-
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ---------- |
26-
| T18 | [Method-level template parameter resolution at call sites](todo/type-inference.md#t18-method-level-template-parameter-resolution-at-call-sites) | Medium | Medium |
27-
| L3 | [`$dates` array (deprecated)](todo/laravel.md#l3-dates-array-deprecated) | Low-Med | Low |
28-
| L11 | [`$appends` array](todo/laravel.md#l11-appends-array) | Low | Low |
29-
| L13 | [`where{PropertyName}()` dynamic methods on Builder](todo/laravel.md#l13-wherepropertyname-dynamic-methods-on-builder) | High | Medium |
30-
| H17 | [`missingType.iterableValue` — add `@return` with inferred element type](todo/phpstan-actions.md#h17-missingtype-iterablevalue-return-type--add-return-with-iterable-type) | Medium | High |
31-
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
32-
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
33-
| | **Release 0.7.0** | | |
24+
| # | Item | Impact | Effort |
25+
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------ |
26+
| L11 | [`$appends` array](todo/laravel.md#l11-appends-array) | Low | Low |
27+
| L13 | [`where{PropertyName}()` dynamic methods on Builder](todo/laravel.md#l13-wherepropertyname-dynamic-methods-on-builder) | High | Medium |
28+
| H17 | [`missingType.iterableValue` — add `@return` with inferred element type](todo/phpstan-actions.md#h17-missingtype-iterablevalue-return-type--add-return-with-iterable-type) | Medium | High |
29+
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
30+
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
31+
| | **Release 0.7.0** | | |
3432

3533
## Sprint 5 — Polish for office adoption
3634

docs/todo/laravel.md

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -213,27 +213,6 @@ relationship. It returns a `MorphToMany` instance (the same class as
213213
No other changes needed since `MorphToMany` is already in
214214
`COLLECTION_RELATIONSHIPS`.
215215

216-
#### L3. `$dates` array (deprecated)
217-
218-
**Impact: Low-Medium · Effort: Low**
219-
220-
Only affects legacy codebases that haven't migrated to `$casts`.
221-
Decreasing relevance over time. New `extract_dates_definitions`
222-
function in `parser/classes.rs` + merge logic in the provider at
223-
lower priority than `$casts`.
224-
225-
Before `$casts`, Laravel used `protected $dates = [...]` to mark
226-
columns as Carbon instances. This was deprecated in favour of
227-
`casts()` with a `datetime` type, but older codebases still use it.
228-
Columns listed in `$dates` should be typed as `\Carbon\Carbon`.
229-
230-
**Where to change:** Add a new `extract_dates_definitions` function in
231-
`parser/classes.rs` (similar to `extract_column_names` but returning
232-
`Vec<(String, String)>` with each column mapped to `\Carbon\Carbon`).
233-
Merge these into `casts_definitions` at a lower priority than explicit
234-
`$casts` entries, or add a separate field on `ClassInfo` and handle
235-
priority in the provider.
236-
237216
#### L4. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
238217

239218
**Impact: High · Effort: Medium**

docs/todo/type-inference.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -412,20 +412,6 @@ emits the concrete callable signature in the `@param` tag.
412412

413413
---
414414

415-
## ~~T18. Method-level template parameter resolution at call sites~~ ✅ Resolved
416-
417-
**Resolution:** Added `substitute_template_param_bounds()` in
418-
`src/completion/variable/resolution.rs` which replaces bare template
419-
parameter names (e.g. `T`, `TRelation`) with their `@template T of Bound`
420-
upper bounds before class resolution. Applied in both
421-
`resolve_variable_in_members` (method-level) and `try_resolve_in_function`
422-
(standalone function-level). Call-site substitution was already handled by
423-
`build_method_template_subs`; this fix addresses the **inside-the-body**
424-
case where `$query->where(...)` previously failed with "subject type 'T'
425-
could not be resolved".
426-
427-
---
428-
429415
## T19. Structured type representation
430416
**Impact: High · Effort: Very High**
431417

example.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,6 +1629,7 @@ public function demo(): void
16291629
$bakery->baguettes; // relationship HasMany → Collection<Loaf>
16301630
$bakery->baguettes_count; // relationship count → int
16311631
$bakery->croissant; // $attributes default → string
1632+
$bakery->defrosted_at; // $dates (deprecated) → Carbon\Carbon
16321633
$bakery->dough_temp; // $casts 'float' → float
16331634
$bakery->egg_count; // $attributes default → int
16341635
$bakery->flour; // $fillable (no cast/attr) → mixed
@@ -4670,6 +4671,8 @@ class Bakery extends \Illuminate\Database\Eloquent\Model
46704671

46714672
protected $hidden = ['oven_code'];
46724673

4674+
protected $dates = ['defrosted_at'];
4675+
46734676
protected $visible = ['rye_blend'];
46744677

46754678
protected $casts = [

src/completion/variable/resolution.rs

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -625,11 +625,8 @@ fn try_resolve_in_function(
625625
);
626626
}
627627
// Bare template param → bound (e.g. `T` → `Builder|QueryBuilder`).
628-
rt.type_string = substitute_template_param_bounds(
629-
rt.type_string.clone(),
630-
ctx.content,
631-
func_start,
632-
);
628+
rt.type_string =
629+
substitute_template_param_bounds(rt.type_string.clone(), ctx.content, func_start);
633630
}
634631

635632
walk_statements_for_assignments(func.body.statements.iter(), &body_ctx, &mut results, false);
@@ -1012,14 +1009,14 @@ fn resolve_variable_in_members<'b>(
10121009
vec![]
10131010
}
10141011

1015-
/// Walk statements collecting variable assignment types.
1016-
///
1017-
/// The `conditional` flag indicates whether we are inside a conditional
1018-
/// block (if/else, try/catch, loop). When `conditional` is `false`,
1019-
/// a new assignment **replaces** all previous candidates (the variable
1020-
/// is being unconditionally reassigned). When `conditional` is `true`,
1021-
/// a new assignment **adds** to the list (the variable *might* be this
1022-
/// type).
1012+
// Walk statements collecting variable assignment types.
1013+
//
1014+
// The `conditional` flag indicates whether we are inside a conditional
1015+
// block (if/else, try/catch, loop). When `conditional` is `false`,
1016+
// a new assignment **replaces** all previous candidates (the variable
1017+
// is being unconditionally reassigned). When `conditional` is `true`,
1018+
// a new assignment **adds** to the list (the variable *might* be this
1019+
// type).
10231020

10241021
/// Substitute method/function-level template parameter names with their
10251022
/// upper bounds from `@template T of Bound` annotations.

src/parser/classes.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,44 @@ fn extract_column_names<'a>(
770770
names
771771
}
772772

773+
/// Extract column names from the deprecated `$dates` property array.
774+
///
775+
/// Before `$casts`, Laravel used `protected $dates = [...]` to mark
776+
/// columns as Carbon instances. Each column listed here should be
777+
/// typed as `\Carbon\Carbon` by the virtual member provider.
778+
fn extract_dates_definitions<'a>(
779+
members: impl Iterator<Item = &'a ClassLikeMember<'a>>,
780+
content: &str,
781+
) -> Vec<String> {
782+
let mut names = Vec::new();
783+
784+
for member in members {
785+
if let ClassLikeMember::Property(Property::Plain(plain)) = member {
786+
for item in plain.items.iter() {
787+
let var_name = item.variable().name.to_string();
788+
let stripped = var_name.strip_prefix('$').unwrap_or(&var_name);
789+
if stripped != "dates" {
790+
continue;
791+
}
792+
if let PropertyItem::Concrete(concrete) = item {
793+
let span = concrete.value.span();
794+
let start = span.start.offset as usize;
795+
let end = span.end.offset as usize;
796+
if let Some(text) = content.get(start..end) {
797+
for name in parse_string_list(text) {
798+
if !names.contains(&name) {
799+
names.push(name);
800+
}
801+
}
802+
}
803+
}
804+
}
805+
}
806+
}
807+
808+
names
809+
}
810+
773811
/// Parse a PHP array literal containing only string values.
774812
///
775813
/// Accepts text starting with `[` and extracts bare string values
@@ -913,6 +951,9 @@ impl Backend {
913951

914952
let column_names = extract_column_names(class.members.iter(), content);
915953

954+
let dates_definitions =
955+
extract_dates_definitions(class.members.iter(), content);
956+
916957
let (timestamps, created_at_name, updated_at_name) =
917958
extract_timestamp_config(class.members.iter(), content);
918959

@@ -959,6 +1000,7 @@ impl Backend {
9591000
laravel: Some(Box::new(LaravelMetadata {
9601001
custom_collection,
9611002
casts_definitions,
1003+
dates_definitions,
9621004
attributes_definitions,
9631005
column_names,
9641006
timestamps,

src/types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,15 @@ pub struct LaravelMetadata {
12681268
/// properties, mapping cast type strings to PHP types (e.g.
12691269
/// `datetime` to `Carbon\Carbon`, `boolean` to `bool`).
12701270
pub casts_definitions: Vec<(String, String)>,
1271+
/// Column names extracted from the deprecated `$dates` property
1272+
/// array.
1273+
///
1274+
/// Before `$casts`, Laravel used `protected $dates = [...]` to mark
1275+
/// columns as Carbon instances. Each column listed here is typed as
1276+
/// `Carbon\Carbon`. The `LaravelModelProvider` merges these at lower
1277+
/// priority than `$casts`: if a column appears in both `$casts` and
1278+
/// `$dates`, the cast type wins.
1279+
pub dates_definitions: Vec<String>,
12711280
/// Eloquent attribute defaults extracted from the `$attributes`
12721281
/// property initializer.
12731282
///

src/virtual_members/laravel/mod.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,8 +550,22 @@ impl VirtualMemberProvider for LaravelModelProvider {
550550
properties.push(PropertyInfo::virtual_property(column, Some(&php_type)));
551551
}
552552

553+
// ── $dates properties (deprecated, lower priority than $casts) ──
554+
// Columns in `$dates` are typed as Carbon\Carbon unless already
555+
// covered by an explicit `$casts` entry.
556+
for column in &laravel.dates_definitions {
557+
if !seen_props.insert(column.clone()) {
558+
continue;
559+
}
560+
properties.push(PropertyInfo::virtual_property(
561+
column,
562+
Some("Carbon\\Carbon"),
563+
));
564+
}
565+
553566
// ── Attribute default properties (fallback) ─────────────
554-
// Only add properties for columns not already covered by $casts.
567+
// Only add properties for columns not already covered by $casts
568+
// or $dates.
555569
for (column, php_type) in &laravel.attributes_definitions {
556570
if !seen_props.insert(column.clone()) {
557571
continue;

0 commit comments

Comments
 (0)