From 847915debdbe4a54fbb43b0a9f945123387fd0a9 Mon Sep 17 00:00:00 2001 From: Pieter Willekens <3403851+pataar@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:56:30 +0100 Subject: [PATCH] Type singular relationship properties as nullable Reading a hasOne, belongsTo, morphOne, or hasOneThrough relationship as a property gave the related model, although the related row may not exist and the property is then null. Laravel declares these relations with a nullable result (BelongsTo extends Relation), and Larastan types the property from that result. The property is now ?Related, as the comment on build_property_type already said. --- docs/CHANGELOG.md | 1 + src/virtual_members/laravel/relationships.rs | 2 +- src/virtual_members/laravel/relationships_tests.rs | 4 ++-- src/virtual_members/laravel/tests.rs | 6 +++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d1165bcd1..18a5709d1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -521,6 +521,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Laravel +- **Singular relationships can be `null`.** Reading a `hasOne`, `belongsTo`, `morphOne`, or `hasOneThrough` relationship as a property, such as `$post->author`, now gives `?Author` rather than `Author`, as Laravel's own relation generics and Larastan type it, since the related row may not exist. - **Route groups in `bootstrap/app.php`.** Routes registered from the `then:` or `using:` callback of `withRouting()`, including route files grouped there under a name prefix, are now known by their full names and are no longer reported as unknown routes. - **Imported `#[Table]` and `#[Connection]` attributes.** A model's table and connection attributes are now read when the attribute is imported with `use`, not only when written fully qualified, in files open in the editor and in `analyze`. Contributed by @shuvroroy. - **Nullable columns stay nullable through a cast.** A model attribute cast to `boolean`, `integer`, a date, an enum or another cast that hands `null` back untouched is now nullable when its column is, and so is a nullable `updated_at` or other timestamp column. A null check on a nullable date column, the soft-delete column included, leaves the date class with all its members. diff --git a/src/virtual_members/laravel/relationships.rs b/src/virtual_members/laravel/relationships.rs index f476cf155..3a4c3bf9a 100644 --- a/src/virtual_members/laravel/relationships.rs +++ b/src/virtual_members/laravel/relationships.rs @@ -309,7 +309,7 @@ pub(super) fn build_property_type( custom_collection: Option<&str>, ) -> Option { match kind { - RelationshipKind::Singular => related_type.cloned(), + RelationshipKind::Singular => related_type.cloned().map(PhpType::nullable), RelationshipKind::Collection => { let inner = related_type.cloned().unwrap_or_else(eloquent_model_type); let collection_class = custom_collection.unwrap_or(ELOQUENT_COLLECTION_FQN); diff --git a/src/virtual_members/laravel/relationships_tests.rs b/src/virtual_members/laravel/relationships_tests.rs index 0b9b59667..4e1beb077 100644 --- a/src/virtual_members/laravel/relationships_tests.rs +++ b/src/virtual_members/laravel/relationships_tests.rs @@ -203,7 +203,7 @@ fn singular_with_related() { Some(&PhpType::named(atom("App\\Models\\Post"))), None ), - Some(PhpType::named(atom("App\\Models\\Post"))) + Some(PhpType::nullable(PhpType::named(atom("App\\Models\\Post")))) ); } @@ -295,7 +295,7 @@ fn singular_ignores_custom_collection() { Some(&PhpType::named(atom("App\\Models\\Post"))), Some("App\\Collections\\PostCollection") ), - Some(PhpType::named(atom("App\\Models\\Post"))) + Some(PhpType::nullable(PhpType::named(atom("App\\Models\\Post")))) ); } diff --git a/src/virtual_members/laravel/tests.rs b/src/virtual_members/laravel/tests.rs index 0f624fff1..a2c3eb157 100644 --- a/src/virtual_members/laravel/tests.rs +++ b/src/virtual_members/laravel/tests.rs @@ -606,7 +606,7 @@ fn synthesizes_has_one_property() { .iter() .find(|p| p.name == "profile") .unwrap(); - assert_eq!(rel_prop.type_hint_str().as_deref(), Some("Profile")); + assert_eq!(rel_prop.type_hint_str().as_deref(), Some("?Profile")); } #[test] @@ -625,7 +625,7 @@ fn synthesizes_belongs_to_property() { .iter() .find(|p| p.name == "author") .unwrap(); - assert_eq!(rel_prop.type_hint_str().as_deref(), Some("User")); + assert_eq!(rel_prop.type_hint_str().as_deref(), Some("?User")); } #[test] @@ -965,7 +965,7 @@ fn provides_fqn_related_type_singular() { .unwrap(); assert_eq!( rel_prop.type_hint_str().as_deref(), - Some("\\App\\Models\\Profile") + Some("?\\App\\Models\\Profile") ); }