Skip to content

PHPLARA-32 Fix attribute masked by a set-only Attribute mutator - #3557

Open
GromNaN wants to merge 1 commit into
mongodb:5.xfrom
GromNaN:phplara-32-set-only-attribute-mutator
Open

PHPLARA-32 Fix attribute masked by a set-only Attribute mutator#3557
GromNaN wants to merge 1 commit into
mongodb:5.xfrom
GromNaN:phplara-32-set-only-attribute-mutator

Conversation

@GromNaN

@GromNaN GromNaN commented Jul 29, 2026

Copy link
Copy Markdown
Member

Fixes PHPLARA-32, reported from laravel/passport#1850 (comment).

An attribute whose name collides with a method typed : Attribute that declares only a set: callback is unreadable: $model->attr always returns null.

laravel/passport Client is exactly this shape:

protected $hidden = ['secret'];

protected function secret(): Attribute
{
    return Attribute::make(
        set: function (?string $value): ?string {
            $this->plainSecret = $value;

            return $this->castAttributeAsHashedString('secret', $value);
        },
    );
}

Client::confidential() could not read the secret, so createToken() failed with Personal access client not found for '$provider' user provider.. Passport worked around it in laravel/passport#1850 by reading getAttributes()['secret'] directly, but any userland code doing $client->secret is still affected.

Note this has nothing to do with $hidden, which only filters serialization. The collision with the mutator method name is what breaks it.

Cause

DocumentModel::getAttribute() short-circuits to getRelationValue() when a method matches the attribute name, so that embedded relations take precedence over the raw stored array. It guarded that branch with hasAttributeGetMutator(), which returns false when the Attribute has no get: callback. The attribute was therefore treated as a relation.

That path was a dead end: Laravel's getRelationValue() calls isRelation(), which itself returns false as soon as hasAttributeMutator() is true, so it fell through to a bare return;, hence the null.

The fix uses hasAttributeMutator(), which is what Laravel core uses in both hasAttribute() and isRelation(). This removes the disagreement between getAttribute() and isRelation().

Backward compatibility

hasAttributeMutator() is a strict superset of hasAttributeGetMutator(), so the only keys taking a different path are methods typed : Attribute whose get is not callable. I ran a matrix over every method/attribute collision shape, before and after:

Case Before After
Attribute get + set 'GET:stored' 'GET:stored'
Attribute get only 'GET:stored' 'GET:stored'
Attribute set only null 'stored'
Attribute with neither null 'stored'
Method typed with an unrelated class LogicException LogicException
Plain method, no return type LogicException LogicException
Legacy getValAttribute() accessor 'LEGACY:stored' 'LEGACY:stored'
Genuine embedded relation relation relation

The two rows that change previously returned null unconditionally, so there was no reachable behavior to depend on. A method declared : Attribute can never return a Relation instance either, so it can never legitimately be an embedded relation.

Other points checked:

  • isset($model->attr) goes from false to true, which is the same correction and matches Eloquent on SQL.
  • Serialization is unchanged: $hidden filtering happens in attributesToArray(), and the new test asserts the attribute stays out of toArray().
  • hasAttributeMutator() is public, not @internal, not deprecated, and present in both illuminate/database 12.x and 13.x, covering the ^12.51|^13.0 constraint.
  • The old guard invoked the userland accessor method just to inspect ->get. The new one only uses reflection, so slightly less work and no userland code executed during an attribute read.

Pre-existing limitation left untouched: a plain method whose name collides with a stored attribute still shadows it and throws LogicException. Making the stored attribute win there would break embedded relations, which rely on that precedence.

An attribute whose name collides with a method typed `: Attribute` that
declares only a `set:` callback was routed to getRelationValue() and
always resolved to null. laravel/passport Client::secret() is such a
case, which made Client::confidential() unable to read the secret.

DocumentModel::getAttribute() guarded the embedded relation branch with
hasAttributeGetMutator(), which is false when no `get:` callback is set.
Use hasAttributeMutator() instead, matching what Laravel core uses in
both hasAttribute() and isRelation().
@GromNaN
GromNaN requested a review from a team as a code owner July 29, 2026 09:17
@GromNaN
GromNaN requested review from Copilot and paulinevos July 29, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an Eloquent attribute-read edge case in DocumentModel where a stored attribute could be incorrectly treated as an embedded relation when there is a colliding : Attribute mutator method that defines only a set: callback (e.g., Laravel Passport Client::secret()), causing $model->attr to return null.

Changes:

  • Adjust DocumentModel::getAttribute() to treat any : Attribute mutator (including set-only) as an attribute, preventing the embedded-relation short-circuit.
  • Add a regression test ensuring a hidden attribute with a set-only Attribute mutator remains readable via property access while still excluded from toArray().
  • Extend the HiddenAnimal test model to include a secret attribute and a set-only Attribute mutator to reproduce the collision scenario.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/Eloquent/DocumentModel.php Fixes attribute-vs-relation precedence by switching the guard to hasAttributeMutator() to align with Laravel’s relation detection.
tests/Models/HiddenAnimal.php Adds a hidden secret attribute and a set-only Attribute mutator to reproduce the Passport-like collision.
tests/PropertyTest.php Adds a regression test asserting $model->secret is readable while remaining hidden in serialization.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/PropertyTest.php
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.

2 participants