Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **UUID and ULID model keys resolve as strings.** Models using Laravel's `HasUuids` or `HasUlids` traits now expose their primary keys as `string` in completion, hover, and type checking, including traits inherited from parent models or composed through other traits. Custom primary-key names are respected. Contributed by @shuvroroy.
- **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit.
- **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy.
- **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run --format json` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. Contributed by @calebdw.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ unlikely to move the needle for most users.
| L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Medium |
| L42 | [Morph alias completion in array positions](todo/laravel.md#l42-morph-alias-completion-in-array-positions) | Low-Medium | Medium |
| L3 | `$dates` array (deprecated) | Low-Medium | Medium |
| L12 | [`HasUuids` / `HasUlids` trait — `$id` typed as `string`](todo/laravel.md#l12-hasuuids-hasulids-trait-id-typed-as-string) | Low-Medium | Medium |
| L44 | [Sibling resource registrations and degenerate resource names](todo/laravel.md#l44-sibling-resource-registrations-and-degenerate-resource-names) | Low-Medium | Medium |
| L50 | ["Create route" quick-fix for an unresolved route name](todo/laravel.md#l50-create-route-quick-fix-for-an-unresolved-route-name) | Low-Medium | Medium |
| L47 | [Morph aliases in `*_type` column comparisons](todo/laravel.md#l47-morph-aliases-in-_type-column-comparisons) | Low-Medium | Medium-High |
Expand Down
25 changes: 0 additions & 25 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,31 +354,6 @@ methods, or document this as a known limitation.

---

#### L12. `HasUuids` / `HasUlids` trait — `$id` typed as `string`

**Impact: Low-Medium · Complexity: Medium**

Models that use `Illuminate\Database\Eloquent\Concerns\HasUuids` or
`HasUlids` have their primary key (`$id` by default) typed as
`string` instead of `int`. Currently PHPantom does not inspect these
traits, so `$model->id` resolves to `int` (from the default Model
stub) instead of `string`.

Larastan's `bug-2188.php` tests this: `assertType('string', $uuidModel->id)`.

**Where to change:** In `LaravelModelProvider::provide`, after
synthesizing other virtual properties, check whether the model's
`used_traits` (recursively, including parent traits) contains
`HasUuids` or `HasUlids`. If so, synthesize a virtual `id` property
typed as `string` (or override the existing one). The trait also
overrides `getKeyType()` to return `'string'` and
`getIncrementing()` to return `false`, but for virtual property
purposes just the `id` type is the main gap.

Alternatively, if the stubs for these traits include `@property`
tags or a typed `$id` override, the PHPDoc provider may handle it
automatically once the traits are loaded.

#### L47. Morph aliases in `*_type` column comparisons

**Impact: Low-Medium · Complexity: Medium-High**
Expand Down
12 changes: 12 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
use App\Http\Requests\UpdateBakeryRequest;
use App\Models\Baker;
use App\Models\Bakery;
use App\Models\BakeryOrder;
use App\Models\BlogAuthor;
use App\Models\BlogPost;
use App\Models\Customer;
use App\Models\Delivery;
use App\Models\Loaf;
use App\Models\PostCollection;
use App\Models\Review;
Expand Down Expand Up @@ -54,6 +56,16 @@

class Demo
{
// Try: hover or complete the primary keys. HasUuids and HasUlids
// make them strings without a $keyType override or @property tag.
public function uniqueIdentifiers(BakeryOrder $order, Delivery $delivery): string
{
$orderId = $order->id; // HasUuids → string
$trackingId = $delivery->tracking_id; // HasUlids, custom primary key → string

return $orderId . ':' . $trackingId;
}

// ── Eloquent Virtual Properties ─────────────────────────────────────────
// Alphabetical — every property a through w should appear in order.
// Trigger completion on `$bakery->` and scan the list.
Expand Down
11 changes: 11 additions & 0 deletions examples/laravel/app/Models/BakeryOrder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;

class BakeryOrder extends Model
{
use HasUuids;
}
13 changes: 13 additions & 0 deletions examples/laravel/app/Models/Delivery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;

class Delivery extends Model
{
use HasUlids;

protected $primaryKey = 'tracking_id';
}
22 changes: 22 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,28 @@ public function toArray(): array

\Illuminate\Container\Container::setInstance($previousContainer);

// ─── UUID and ULID primary keys ─────────────────────────────────────────────

$uuidOrder = new \App\Models\BakeryOrder();
$ulidDelivery = new \App\Models\Delivery();
$uuidOrder->setUniqueIds();
$ulidDelivery->setUniqueIds();

check('HasUuids generates a string id', is_string($uuidOrder->id));
check('HasUuids generates a valid UUID', \Illuminate\Support\Str::isUuid($uuidOrder->id));
check('HasUuids overrides the default key type', $uuidOrder->getKeyType() === 'string');
check('HasUuids disables incrementing', $uuidOrder->getIncrementing() === false);
check('HasUlids respects a custom primary key', $ulidDelivery->getKeyName() === 'tracking_id');
check('HasUlids generates a string key', is_string($ulidDelivery->tracking_id));
check('HasUlids generates a valid ULID', \Illuminate\Support\Str::isUlid($ulidDelivery->tracking_id));
check('HasUlids overrides the default key type', $ulidDelivery->getKeyType() === 'string');
check('HasUlids disables incrementing', $ulidDelivery->getIncrementing() === false);
check(
'Unique identifier demo reads both string keys',
(new \App\Demo())->uniqueIdentifiers($uuidOrder, $ulidDelivery)
=== $uuidOrder->id . ':' . $ulidDelivery->tracking_id
);

// ─── Summary ────────────────────────────────────────────────────────────────

echo "\n";
Expand Down
8 changes: 6 additions & 2 deletions src/virtual_members/laravel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@
//! - **Implicit primary key.** Every model exposes a primary key column
//! (`id` by default, respecting `$primaryKey`/`$keyType` overrides)
//! even when no schema or cast entry describes it, unless the model
//! overrides `getKeyName()`.
//! overrides `getKeyName()`. `HasUuids` and `HasUlids` make the key a
//! string, including when inherited or composed through other traits.
//!
//! - **Timestamp properties.** `created_at`/`updated_at` (or their
//! configured names) are added as the configured Laravel date class,
Expand Down Expand Up @@ -134,6 +135,7 @@ mod scopes;
mod storage;
mod string_keys;
mod trans_keys;
mod unique_ids;
pub(crate) mod validated_shape;
pub(crate) mod validation_rules;
mod view_data;
Expand Down Expand Up @@ -847,7 +849,9 @@ impl VirtualMemberProvider for LaravelModelProvider {
if !laravel.has_get_key_name_method {
let primary_key = laravel.primary_key.as_deref().unwrap_or("id");
if seen_props.insert(primary_key.to_string()) {
let php_type = if laravel.key_type.as_deref() == Some("string") {
let php_type = if laravel.key_type.as_deref() == Some("string")
|| unique_ids::uses_unique_string_ids(class, class_loader)
{
PhpType::string()
} else {
PhpType::int()
Expand Down
71 changes: 71 additions & 0 deletions src/virtual_members/laravel/unique_ids.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Primary-key types supplied by Laravel's UUID and ULID traits.

use std::sync::Arc;

use crate::atom::AtomSet;
use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH, MAX_TRAIT_DEPTH};

use super::ELOQUENT_MODEL_FQN;

/// Whether a model uses `HasUuids` or `HasUlids`, including through
/// composed traits and parent models. Base resolution retains only the
/// leaf class's trait names, so inspect the original declarations too.
pub(super) fn uses_unique_string_ids(
class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> bool {
let mut visited = AtomSet::default();
if traits_use_unique_string_ids(class, class_loader, &mut visited, 0) {
return true;
}

let mut parent_name = class.parent_class;
for _ in 0..MAX_INHERITANCE_DEPTH {
let Some(name) = parent_name else {
break;
};
// The framework base model has no UUID/ULID trait. Avoid walking
// its internal concerns for every ordinary application model.
if name.eq_ignore_ascii_case(ELOQUENT_MODEL_FQN) {
break;
}
let Some(parent) = class_loader(&name) else {
break;
};
if traits_use_unique_string_ids(&parent, class_loader, &mut visited, 0) {
return true;
}
parent_name = parent.parent_class;
}
false
}

fn traits_use_unique_string_ids(
class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
visited: &mut AtomSet,
depth: u32,
) -> bool {
if depth >= MAX_TRAIT_DEPTH {
return false;
}
for name in &class.used_traits {
let fqn = name.trim_start_matches('\\');
if fqn.eq_ignore_ascii_case("Illuminate\\Database\\Eloquent\\Concerns\\HasUuids")
|| fqn.eq_ignore_ascii_case("Illuminate\\Database\\Eloquent\\Concerns\\HasUlids")
{
return true;
}
if visited.insert(*name)
&& let Some(trait_class) = class_loader(name)
&& traits_use_unique_string_ids(&trait_class, class_loader, visited, depth + 1)
{
return true;
}
}
false
}

#[cfg(test)]
#[path = "unique_ids_tests.rs"]
mod tests;
Loading
Loading