Skip to content

Commit 3ab54e8

Browse files
committed
Implement virtual properties for model created_at / updated_at with
rename and toggle support.
1 parent 969837e commit 3ab54e8

12 files changed

Lines changed: 855 additions & 38 deletions

File tree

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+
- **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.
1213
- **`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.
1314
- **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.
1415
- **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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ within the same impact tier.
2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ---------- |
2626
| 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 |
2730
| 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 |
2831
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
2932
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |

docs/todo/diagnostics.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,39 @@ own diagnostics.
368368
`str-contains`/`str-starts-with` modernization,
369369
`prefer-arrow-function`, `constant-condition`, `no-self-assignment`,
370370
`explicit-nullable-param`, `valid-docblock`.
371+
372+
## D13. Unify diagnostic subject resolution with completion/hover
373+
374+
`unknown_members.rs` has two secondary resolvers that run their own
375+
independent type resolution when `resolve_target_classes_expr` returns
376+
empty:
377+
378+
- `resolve_scalar_subject_type` (~130 lines) re-resolves variables,
379+
property chains, and call expressions to detect scalar types.
380+
- `resolve_unresolvable_class_subject` (~80 lines) re-resolves
381+
variables and call expressions to detect class names that can't be
382+
loaded.
383+
384+
Both duplicate logic from `resolver.rs` and
385+
`variable/resolution.rs` but can diverge, producing diagnostics for
386+
types that completion and hover cannot see (or vice versa).
387+
388+
### Goal
389+
390+
The diagnostic path should use the same resolution result that
391+
completion and hover use. All three consumers should see identical
392+
outcomes for the same subject text at the same cursor position.
393+
394+
### Approach
395+
396+
Extend the shared resolver's return type (or add a secondary result)
397+
to carry scalar type information and unresolvable class names
398+
alongside the resolved `ClassInfo` list. The diagnostic collector
399+
would then inspect this enriched result instead of running its own
400+
resolution. This eliminates the secondary resolvers entirely.
401+
402+
### Files
403+
404+
- `src/diagnostics/unknown_members.rs` — remove
405+
`resolve_scalar_subject_type` and `resolve_unresolvable_class_subject`
406+
- `src/completion/resolver.rs` — enrich the resolution result

docs/todo/laravel.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,46 @@ today and what is still missing.
152152

153153
---
154154

155+
#### L13. `where{PropertyName}()` dynamic methods on Builder
156+
157+
**Impact: High · Effort: Medium**
158+
159+
**Depends on:** All model property gathering gaps being resolved first
160+
(L3, L11, and any others that feed into `column_names` or
161+
`casts_definitions`). The `where{PropertyName}` methods are derived
162+
from the model's known properties, so incomplete property lists produce
163+
incomplete method coverage.
164+
165+
Eloquent's `Builder::__call()` translates calls like
166+
`whereLangCode($value)` into `where('lang_code', $value)` and returns
167+
`$this`. This is a dynamic convention based on the model's column
168+
names: `where` + StudlyCase column name.
169+
170+
PHPantom currently has no knowledge of this pattern. The `__call`
171+
return type preservation (shipped in 0.5.0) keeps chains alive when
172+
`__call` exists, but the analyzer still reports `unknown_member` for
173+
each `where{PropertyName}` call because no actual method by that name
174+
exists on the Builder or Model.
175+
176+
From the luxplus/shared triage, this pattern accounts for ~17
177+
diagnostics (29% of remaining errors): 11 direct `unknown_member`
178+
errors plus ~6 cascading `unresolved_member_access` failures.
179+
180+
Affected examples: `whereBrandId()`, `whereSubcategoryId()`,
181+
`whereLangCode()`, `whereLanguage()`, `whereIsLuxury()`,
182+
`whereIsDerma()`, `whereIsProHairCare()`, `whereIsVisible()`.
183+
184+
**Implementation approach:** In the `LaravelModelProvider` (or a new
185+
virtual-member pass), for each model that extends
186+
`Illuminate\Database\Eloquent\Model`, synthesize `where{StudlyCase}()`
187+
methods on the associated Builder class. Each method should accept one
188+
parameter (the value) and return `$this` (the Builder). The column
189+
names come from all property sources: `$casts`, `$attributes`,
190+
`$fillable`/`$guarded`/`$hidden`/`$visible`, `$dates`, timestamps,
191+
relationship `*_count`, and `@property` annotations.
192+
193+
---
194+
155195
#### L2. `morphedByMany` missing from relationship method map
156196

157197
**Impact: Low-Medium · Effort: Low**

docs/todo/refactor.md

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -214,38 +214,4 @@ Each item must include:
214214

215215
## Outstanding items
216216

217-
### Tighter `mago-type-syntax` integration
218-
219-
The project already wraps `mago-type-syntax` via `PhpType` in
220-
`src/php_type.rs` and uses it for structured type operations (substitution,
221-
equivalence, display). Several areas still do manual string-level type
222-
manipulation that could be replaced or simplified by leaning on `PhpType`
223-
and the mago parser/lexer. See also
224-
`scratch/mago_type_syntax_integration.md` for completed items (#1, #2)
225-
and additional context.
226-
227-
Grouped by size:
228-
229-
#### Medium
230-
231-
- **`ArrayShapeEntry.value_type: String``PhpType`.**
232-
`src/types.rs` `ArrayShapeEntry` stores the value type as a `String`.
233-
`src/docblock/shapes.rs` `shape_entries_to_array_entries()` converts
234-
`PhpType::ShapeEntry` (which already has `value_type: PhpType`) back
235-
to strings via `.to_string()`, and callers re-parse with
236-
`PhpType::parse()`. Store `PhpType` directly to eliminate the
237-
round-trip. Files: `src/types.rs`, `src/docblock/shapes.rs`,
238-
`tests/integration/completion_array_shapes.rs`.
239-
240-
#### Large
241-
242-
- **`split_type_token()``mago_type_syntax::lexer::TypeLexer`.**
243-
`src/docblock/type_strings.rs` L107-215 is a 108-line hand-rolled
244-
tokenizer tracking `<>`, `{}`, `()` nesting, quoted strings, and
245-
union/intersection suffixes. It is called from 24 sites across 7
246-
files. The `TypeLexer` from `mago-type-syntax` is available but
247-
unused. Replacing `split_type_token` with lexer-based boundary
248-
detection would eliminate the largest piece of manual type parsing.
249-
High risk due to the number of callers; should be done as a
250-
standalone task with a parallel implementation and extensive
251-
comparison testing.
217+
No outstanding items.

src/definition/member/mod.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,27 @@ impl Backend {
198198
}
199199
}
200200

201+
// ── Timestamp constant redirect ─────────────────────────
202+
// When the property name matches a timestamp column,
203+
// jump straight to the CREATED_AT / UPDATED_AT constant.
204+
if extends_eloquent_model(lookup_class, &class_loader)
205+
&& let Some(const_name) =
206+
Self::timestamp_property_to_constant(lookup_class, &effective_name)
207+
&& let Some((const_class, const_fqn)) =
208+
Self::find_declaring_class(lookup_class, const_name, &class_loader)
209+
&& let Some((class_uri, class_content)) =
210+
self.find_class_file_content(&const_fqn, uri, content)
211+
&& let Some(position) = Self::find_member_position(
212+
&class_content,
213+
const_name,
214+
MemberKind::Constant,
215+
const_class.member_name_offset(const_name, "constant"),
216+
)
217+
&& let Ok(parsed_uri) = Url::parse(&class_uri)
218+
{
219+
return Some(point_location(parsed_uri, position));
220+
}
221+
201222
// ── Scope method mapping ────────────────────────────────
202223
// Laravel scope methods are defined as `scopeActive()` but
203224
// invoked as `active()`. When the effective name doesn't
@@ -810,4 +831,34 @@ impl Backend {
810831
let (declaring, fqn) = Self::find_declaring_class(&model, member_name, class_loader)?;
811832
Some((declaring, fqn, member_name.to_string()))
812833
}
834+
835+
/// Map a timestamp virtual property name to its defining constant.
836+
///
837+
/// Returns `Some("CREATED_AT")` or `Some("UPDATED_AT")` when the
838+
/// property name matches the model's configured timestamp column,
839+
/// or `None` when the property is not a timestamp.
840+
fn timestamp_property_to_constant<'a>(
841+
class: &ClassInfo,
842+
property_name: &str,
843+
) -> Option<&'a str> {
844+
if let Some(laravel) = class.laravel() {
845+
let created_col = match &laravel.created_at_name {
846+
Some(Some(name)) => Some(name.as_str()),
847+
Some(None) => None,
848+
None => Some("created_at"),
849+
};
850+
let updated_col = match &laravel.updated_at_name {
851+
Some(Some(name)) => Some(name.as_str()),
852+
Some(None) => None,
853+
None => Some("updated_at"),
854+
};
855+
if created_col == Some(property_name) {
856+
return Some("CREATED_AT");
857+
}
858+
if updated_col == Some(property_name) {
859+
return Some("UPDATED_AT");
860+
}
861+
}
862+
None
863+
}
813864
}

src/parser/classes.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,80 @@ fn infer_type_from_literal(value: &str) -> Option<String> {
653653
None
654654
}
655655

656+
/// Extract timestamp configuration from a model class.
657+
///
658+
/// Reads three sources:
659+
///
660+
/// - `$timestamps` property — `true` (default) or `false`.
661+
/// - `CREATED_AT` constant — column name string or `null`.
662+
/// - `UPDATED_AT` constant — column name string or `null`.
663+
///
664+
/// Returns `(timestamps, created_at_name, updated_at_name)` using the
665+
/// same `Option` semantics as `LaravelMetadata`: outer `None` means
666+
/// "not declared", `Some(None)` means "explicitly `null`".
667+
fn extract_timestamp_config<'a>(
668+
members: impl Iterator<Item = &'a ClassLikeMember<'a>>,
669+
content: &str,
670+
) -> (Option<bool>, Option<Option<String>>, Option<Option<String>>) {
671+
let mut timestamps: Option<bool> = None;
672+
let mut created_at: Option<Option<String>> = None;
673+
let mut updated_at: Option<Option<String>> = None;
674+
675+
for member in members {
676+
match member {
677+
ClassLikeMember::Property(Property::Plain(plain)) => {
678+
for item in plain.items.iter() {
679+
let var_name = item.variable().name.to_string();
680+
let stripped = var_name.strip_prefix('$').unwrap_or(&var_name);
681+
if stripped != "timestamps" {
682+
continue;
683+
}
684+
if let PropertyItem::Concrete(concrete) = item {
685+
let span = concrete.value.span();
686+
let start = span.start.offset as usize;
687+
let end = span.end.offset as usize;
688+
if let Some(text) = content.get(start..end) {
689+
let trimmed = text.trim();
690+
if trimmed == "false" {
691+
timestamps = Some(false);
692+
} else if trimmed == "true" {
693+
timestamps = Some(true);
694+
}
695+
}
696+
}
697+
}
698+
}
699+
ClassLikeMember::Constant(constant) => {
700+
for item in constant.items.iter() {
701+
let name = item.name.value.to_string();
702+
if name != "CREATED_AT" && name != "UPDATED_AT" {
703+
continue;
704+
}
705+
let span = item.value.span();
706+
let start = span.start.offset as usize;
707+
let end = span.end.offset as usize;
708+
let value = content.get(start..end).map(|t| t.trim());
709+
let parsed = match value {
710+
Some("null") | Some("NULL") => Some(None),
711+
Some(v) => extract_string_literal(v).map(Some),
712+
None => None,
713+
};
714+
if let Some(val) = parsed {
715+
if name == "CREATED_AT" {
716+
created_at = Some(val);
717+
} else {
718+
updated_at = Some(val);
719+
}
720+
}
721+
}
722+
}
723+
_ => {}
724+
}
725+
}
726+
727+
(timestamps, created_at, updated_at)
728+
}
729+
656730
/// Extract column names from `$fillable`, `$guarded`, and `$hidden` arrays.
657731
///
658732
/// These properties contain simple string lists of column names without
@@ -839,6 +913,9 @@ impl Backend {
839913

840914
let column_names = extract_column_names(class.members.iter(), content);
841915

916+
let (timestamps, created_at_name, updated_at_name) =
917+
extract_timestamp_config(class.members.iter(), content);
918+
842919
let attr_targets = extract_attribute_targets(&class.attribute_lists, content);
843920

844921
let class_depr = merge_deprecation_info(
@@ -884,6 +961,9 @@ impl Backend {
884961
casts_definitions,
885962
attributes_definitions,
886963
column_names,
964+
timestamps,
965+
created_at_name,
966+
updated_at_name,
887967
})),
888968
});
889969

src/types.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,28 @@ pub struct LaravelMetadata {
12851285
/// properties as a last-resort fallback when a column is not
12861286
/// already covered by `$casts` or `$attributes`.
12871287
pub column_names: Vec<String>,
1288+
/// Whether `$timestamps` is explicitly set on the model.
1289+
///
1290+
/// - `None` — not declared (inherits the default, which is `true`
1291+
/// on `Illuminate\Database\Eloquent\Model`).
1292+
/// - `Some(true)` — explicitly enabled.
1293+
/// - `Some(false)` — explicitly disabled; no timestamp properties
1294+
/// should be synthesized.
1295+
pub timestamps: Option<bool>,
1296+
/// Override for the `CREATED_AT` column name constant.
1297+
///
1298+
/// - `None` — not declared (inherits the default `"created_at"`).
1299+
/// - `Some(None)` — explicitly set to `null`; no created-at
1300+
/// property should be synthesized.
1301+
/// - `Some(Some("created"))` — custom column name.
1302+
pub created_at_name: Option<Option<String>>,
1303+
/// Override for the `UPDATED_AT` column name constant.
1304+
///
1305+
/// - `None` — not declared (inherits the default `"updated_at"`).
1306+
/// - `Some(None)` — explicitly set to `null`; no updated-at
1307+
/// property should be synthesized.
1308+
/// - `Some(Some("modified"))` — custom column name.
1309+
pub updated_at_name: Option<Option<String>>,
12881310
}
12891311

12901312
/// Stores extracted class information from a parsed PHP file.

src/virtual_members/laravel/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,32 @@ impl VirtualMemberProvider for LaravelModelProvider {
568568
}
569569
properties.push(PropertyInfo::virtual_property(column, Some("mixed")));
570570
}
571+
572+
// ── Timestamp properties ────────────────────────────────
573+
// When $timestamps is true (the default), synthesize
574+
// created_at and updated_at as Carbon\Carbon. The column
575+
// names can be overridden via CREATED_AT / UPDATED_AT
576+
// constants, and either can be disabled by setting the
577+
// constant to null.
578+
let timestamps_enabled = laravel.timestamps.unwrap_or(true);
579+
if timestamps_enabled {
580+
let created_col = match &laravel.created_at_name {
581+
Some(Some(name)) => Some(name.as_str()),
582+
Some(None) => None, // explicitly null
583+
None => Some("created_at"), // default
584+
};
585+
let updated_col = match &laravel.updated_at_name {
586+
Some(Some(name)) => Some(name.as_str()),
587+
Some(None) => None, // explicitly null
588+
None => Some("updated_at"), // default
589+
};
590+
for col in [created_col, updated_col].into_iter().flatten() {
591+
if seen_props.insert(col.to_string()) {
592+
properties
593+
.push(PropertyInfo::virtual_property(col, Some("Carbon\\Carbon")));
594+
}
595+
}
596+
}
571597
}
572598

573599
for method in &class.methods {

0 commit comments

Comments
 (0)