Repository navigation
Description
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 objectHasTimestamps::initializeHasTimestamps()(HasTimestamps.php:38) —resolveClassAttribute(Table::class), expects the objectModel::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:)] ignoredisIgnoringTouch() 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.
Activity