Skip to content

resolveClassAttribute() cache key omits $property, so #[Table(timestamps:)] collides with isIgnoringTouch() #60803

Closed

Description

@alies-dev

Laravel Version

13.20.0

PHP Version

8.5.8

Database Driver & Version

Not applicable — reproduces without a database connection.

Description

Model::resolveClassAttribute() builds its cache key from the class and the attribute class only:

// Illuminate/Database/Eloquent/Model.php:2681
$cacheKey = $class.'@'.$attributeClass;

but when $property is passed it stores the property value under that same key:

// Illuminate/Database/Eloquent/Model.php:2696
return static::$classAttributes[$cacheKey] = $property ? $instance->{$property} : $instance;

$property is not part of the key, so the 2-arg and 3-arg forms share one cache slot. For Table::class both forms are live:

  • Model::initializeModelAttributes() (Model.php:436) — resolveClassAttribute(Table::class), expects the object
  • HasTimestamps::initializeHasTimestamps() (HasTimestamps.php:38) — resolveClassAttribute(Table::class), expects the object
  • Model::isIgnoringTouch() (Model.php:545) — resolveClassAttribute(Table::class, 'timestamps', $class), expects ?bool

Whichever runs first poisons the other, and it breaks in both directions.

Steps To Reproduce

use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Model;

#[Table(name: 't', timestamps: false)] class Z1 extends Model {}
#[Table(name: 't', timestamps: false)] class Z2 extends Model {}

// A) construct first (what the framework normally does) -> the Table OBJECT is cached.
new Z1();
var_dump(Z1::isIgnoringTouch());   // false  — expected true

// B) isIgnoringTouch() first on a cold cache -> the BOOL is cached, poisoning construction.
var_dump(Z2::isIgnoringTouch());   // true
$z2 = new Z2();
var_dump($z2->usesTimestamps());   // true    — expected false, #[Table(timestamps:)] ignored
var_dump($z2->getTable());         // 'z2_s'  — expected 't',   #[Table(name:)] ignored

isIgnoringTouch() is a protected static; in A it is reachable through Relation::touch() (Relation.php:224) and BelongsToMany (BelongsToMany.php:1318), both of which hold an already-constructed instance — so A is the order that occurs in normal use.

In A, isIgnoringTouch() reads a cached Table object where it expects ?bool. The object is truthy, so ! $timestamps is false and it returns false: a model that declared #[Table(timestamps: false)] still gets touched by its relations.

In B, the cached bool is handed back to initializeModelAttributes() and initializeHasTimestamps(), which expect a Table, and both #[Table] arguments are silently dropped — the model keeps the derived table name and stays timestamped.

Expected

#[Table(timestamps: false)] disables timestamps and suppresses touches; #[Table(name: 't')] sets the table. Neither should depend on which method warmed the cache first.

Possible fix

Include $property in the key, so the two forms cannot share a slot:

$cacheKey = $class.'@'.$attributeClass.'@'.$property;

Found while implementing a mirror of initializeHasTimestamps() in a static-analysis tool (psalm-plugin-laravel#1276); the plugin resolves attributes uncached, which is what surfaced the divergence.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions