Skip to content

Type singular relationship properties as nullable - #570

Open
pataar wants to merge 1 commit into
PHPantom-dev:mainfrom
pataar:nullable-singular-relationship-property
Open

pataar wants to merge 1 commit into
PHPantom-dev:mainfrom
pataar:nullable-singular-relationship-property

Conversation

@pataar

@pataar pataar commented Nov 12, 2026 •

Copy link
Copy Markdown

🤖 This was written by an AI agent on behalf of @pataar (claude-code/claude-opus-5-5).

Types singular relationship properties as nullable. Reading a hasOne, belongsTo, morphOne, or hasOneThrough relationship as a property, such as $post->author, gave Author. When the related row does not exist the property is null, so it is now ?Author.

This matches how the framework and Larastan type it:

  • Laravel declares the nullable result in the relation generics, e.g. BelongsTo is @extends Relation<TRelatedModel, TDeclaringModel, ?TRelatedModel>, and HasOne, MorphOne, and HasOneThrough pass ?TRelatedModel the same way.
  • Larastan's ModelRelationsExtension reads the property type from that TResult, so PHPStan already sees ?Author.
  • The comment on build_property_type already said singular relationships are nullable; the code returned the bare type.

It also matters because the Laravel model provider outranks @property tags, so a model whose docblock says @property-read null|Author $author still got Author.

Behaviour change to be aware of: code that reads straight through a relation, such as $post->author->name, or passes $post->author to a parameter typed Author, can now get the nullable reports that a ?Author from any other source already gets.

Why not use the foreign key column's nullability: PHPantom reads the schema, so a belongsTo whose key is NOT NULL could in principle stay non-null. I left that out because a non-null key does not guarantee the related row loads: the related model may be soft-deleted or filtered out by a global scope, and without a foreign key constraint the row may simply be gone. For hasOne, morphOne, and hasOneThrough the key lives on the other table. Larastan has the same schema information and does not narrow here either. Happy to add the narrowing for belongsTo + NOT NULL key if you want it.

Checks

  • cargo test: all pass, with the four unit tests that asserted the non-null type updated
  • cargo clippy --all-targets -- -D warnings and cargo fmt --check: clean
  • phpantom_lsp analyze --project-root examples/laravel: exactly the 4 expected errors
  • php examples/laravel/assertions.php: all 369 assertions pass

Checklist

  • I have updated CHANGELOG.md
  • I have updated the documentation (README.md, docs/, examples/)
  • I have updated the config schema (config-schema.json)
  • I have added/updated tests to cover my changes
  • If I used AI tools, I have reviewed what they produced and can explain
    the changes and why they are needed.
  • I have read through any AI-generated text, especially documentation,
    the changelog, and other user-facing content, and removed or corrected
    anything inaccurate, unclear, or unnecessary.

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<TRelatedModel, TDeclaringModel,
?TRelatedModel>), and Larastan types the property from that result. The
property is now ?Related, as the comment on build_property_type already said.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant