From 7f4d308d81f62efcc774d4556ed0424f70559bd4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:51:51 +0000 Subject: [PATCH 01/22] Support keyless models and preserve model attribute semantics Port Laravel framework PRs #62113 (4b3f451bf5e6715941b609812c44ee4ad0c8d005), #62065 (4145fdea5adce1f0d38f49f9f59275767978a3b3), and #62039 (42e87f0c9c680ca4fdf4eed15ebcba64c9433844) from the frozen 13.x target 5d7797528723107cd4f495326838a7d7d67fbb20. Allow Table(key: false), align nullable primary-key declarations across models and consumers, and keep child connection attributes authoritative without reflecting on each hydration. Preserve selected columns when withWhereHas also receives a callback. Keyless inserts no longer attempt to assign a generated ID after writing. Identity-dependent instance operations and collection dictionaries fail clearly instead of losing rows, while custom save keys and unsaved keyed-model collection behavior remain supported. Document concat and explicit-column unique as the keyless collection alternatives. Validated the affected model, collection, relationship and consumer tests, including failing-before collection regressions and SQLite insertion and retrieval. Source/type analysis, formatting, and the corrected full-batch verification pass. --- .../src/Eloquent/Attributes/Table.php | 2 +- src/database/src/Eloquent/Collection.php | 6 + .../Concerns/QueriesRelationships.php | 10 +- src/database/src/Eloquent/Model.php | 26 ++- src/docs/eloquent-relationships.md | 2 + src/docs/eloquent.md | 4 +- src/telescope/src/Storage/EntryModel.php | 2 +- .../Ai/Feature/RemembersConversationsTest.php | 2 +- tests/Auth/AuthAccessGateScopeSelectTest.php | 2 +- ...abaseEloquentBelongsToManyLazyByIdTest.php | 2 +- .../DatabaseEloquentCollectionTest.php | 43 ++++ ...abaseEloquentComparesRelatedModelsTest.php | 2 +- .../DatabaseEloquentIntegrationTest.php | 19 +- .../DatabaseEloquentInverseRelationTest.php | 4 +- .../DatabaseEloquentModelAttributesTest.php | 188 +++++++++++++++++- .../Database/DatabaseEloquentMorphToTest.php | 2 +- .../Models/MorphEagerLoading/Video.php | 2 +- tests/Database/Fixtures/Models/User.php | 2 +- .../EloquentMorphEagerLoadingTest.php | 2 +- .../EloquentMorphLazyEagerLoadingTest.php | 2 +- .../Database/EloquentMorphToEagerLoadTest.php | 2 +- .../EloquentMorphToLazyEagerLoadingTest.php | 2 +- .../EloquentMorphToTargetKeysTest.php | 2 +- .../EloquentUniqueStringPrimaryKeysTest.php | 2 +- .../Database/Fixtures/PostStringyKey.php | 2 +- tests/Passkeys/Feature/PasskeyTest.php | 2 +- tests/Passport/RelationConnectionsTest.php | 2 +- .../Permission/Fixtures/Models/Permission.php | 2 +- tests/Permission/Fixtures/Models/Role.php | 2 +- .../Sanctum/PersonalAccessTokenCacheTest.php | 2 +- tests/Validation/ValidationUniqueRuleTest.php | 4 +- tests/Wayfinder/BindingResolverTest.php | 2 +- tests/Workbench/Fixtures/Member.php | 2 +- 33 files changed, 311 insertions(+), 41 deletions(-) diff --git a/src/database/src/Eloquent/Attributes/Table.php b/src/database/src/Eloquent/Attributes/Table.php index 99b343b394..99034fbc03 100644 --- a/src/database/src/Eloquent/Attributes/Table.php +++ b/src/database/src/Eloquent/Attributes/Table.php @@ -14,7 +14,7 @@ class Table */ public function __construct( public ?string $name = null, - public ?string $key = null, + public string|false|null $key = null, public ?string $keyType = null, public ?bool $incrementing = null, public ?bool $timestamps = null, diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 09a2d1b308..eb38ce687c 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -447,6 +447,9 @@ public function merge($items): static if ($key !== null) { $dictionary[$key] = $item; + } elseif ($item->getKeyName() === null) { + // Match getDictionary() without allocating a second dictionary for incoming models. + throw new LogicException('No primary key defined on model.'); } } @@ -762,6 +765,9 @@ public function getDictionary(?iterable $items = null): array if ($key !== null) { $dictionary[$key] = $value; + } elseif ($value->getKeyName() === null) { + // Keyless results cannot be deduplicated by identity. Unsaved keyed models still skip. + throw new LogicException('No primary key defined on model.'); } } diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index dd7426e492..387ac6fb58 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -162,8 +162,14 @@ public function whereHas(Relation|string $relation, ?Closure $callback = null, s */ public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static { - return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count) - ->with($callback ? [$relation => static fn ($query) => $callback($query)] : $relation); + if ($callback === null) { + return $this->whereHas(Str::before($relation, ':'), null, $operator, $count)->with($relation); + } + + [$name, $select] = $this->parseNameAndAttributeSelectionConstraint($relation); + + return $this->whereHas($name, $callback, $operator, $count) + ->with([$name => $this->combineConstraints([$select, static fn ($query) => $callback($query)])]); } /** diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index d8ffe48e12..9e570776b7 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -119,7 +119,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The primary key for the model. */ - protected string $primaryKey = 'id'; + protected ?string $primaryKey = 'id'; /** * The "type" of the primary key ID. @@ -546,10 +546,17 @@ public function initializeModelAttributes(): void $this->table ??= $table->name ?? null; } - $this->connection ??= static::resolveClassAttribute(ConnectionAttribute::class, 'name'); + // Reuse declaration metadata instead of reflecting on every hydrated model. + $declaresConnection = static::classPropertyDeclarer('connection') === static::class; + + if (! $declaresConnection && static::classDeclaresAttribute(ConnectionAttribute::class)) { + $this->connection = static::resolveClassAttribute(ConnectionAttribute::class, 'name'); + } else { + $this->connection ??= static::resolveClassAttribute(ConnectionAttribute::class, 'name'); + } if ($this->primaryKey === 'id' && $table && $table->key !== null) { - $this->primaryKey = $table->key; + $this->primaryKey = $table->key === false ? null : $table->key; } if ($this->keyType === 'int' && $table && $table->keyType !== null) { @@ -1727,7 +1734,8 @@ protected function getKeyForSaveQuery(): mixed */ private function getRawKeyForQuery(): mixed { - $keyName = $this->getKeyName(); + // Keyless models can be inserted, but these queries need a row identity. + $keyName = $this->getKeyName() ?? throw new LogicException('No primary key defined on model.'); return $this->original[$keyName] ?? ($this->isBinaryCast($this->getCasts()[$keyName] ?? null) && array_key_exists($keyName, $this->attributes) @@ -1950,7 +1958,6 @@ public function delete(): int|bool|null { $this->mergeAttributesFromCachedCasts(); - // @phpstan-ignore function.impossibleType (defensive: users may set $primaryKey = null) if (is_null($this->getKeyName())) { throw new LogicException('No primary key defined on model.'); } @@ -2633,7 +2640,7 @@ public function setTable(string $table): static /** * Get the primary key for the model. */ - public function getKeyName(): string + public function getKeyName(): ?string { return $this->primaryKey; } @@ -2641,7 +2648,7 @@ public function getKeyName(): string /** * Set the primary key for the model. */ - public function setKeyName(string $key): static + public function setKeyName(?string $key): static { $this->primaryKey = $key; @@ -2683,7 +2690,8 @@ public function setKeyType(string $type): static */ public function getIncrementing(): bool { - return $this->incrementing; + // A keyless insert must not try to store a generated ID after writing the row. + return $this->incrementing && $this->getKeyName() !== null; } /** @@ -2703,7 +2711,7 @@ public function setIncrementing(bool $value): static */ public function getKey(): mixed { - return $this->getAttribute($this->getKeyName()); + return ($key = $this->getKeyName()) !== null ? $this->getAttribute($key) : null; } /** diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 97aba5aac2..5ab429c521 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -2287,6 +2287,8 @@ $users = User::withWhereHas('posts', function ($query) { })->get(); ``` +You may also [select specific columns](#eager-loading-specific-columns) when providing a callback, such as `withWhereHas('posts:id,user_id,title', $callback)`. + For a single, simple condition, you may use the `withWhereRelation` method: ```php diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md index 4ff3604768..36daa33aae 100644 --- a/src/docs/eloquent.md +++ b/src/docs/eloquent.md @@ -209,10 +209,12 @@ class Flight extends Model } ``` +For tables without a primary key, use `#[Table(key: false)]`. These models support retrieval and insertion, but updating, refreshing, or deleting an instance throws an exception. Eloquent collection methods such as `merge()` and `unique()` also throw because they require primary keys. Use explicit query conditions for updates and deletes, `concat()` to combine collections, and `unique('column')` to remove duplicates. + #### "Composite" Primary Keys -Eloquent requires each model to have at least one uniquely identifying "ID" that can serve as its primary key. "Composite" primary keys are not supported by Eloquent models. However, you are free to add additional multi-column, unique indexes to your database tables in addition to the table's uniquely identifying primary key. +Operations that identify an individual model require a single unique primary key. "Composite" primary keys are not supported by Eloquent models. However, you are free to add additional multi-column, unique indexes to your database tables in addition to the table's uniquely identifying primary key. ### UUID and ULID Keys diff --git a/src/telescope/src/Storage/EntryModel.php b/src/telescope/src/Storage/EntryModel.php index 06deb50e6a..f57ba228ec 100644 --- a/src/telescope/src/Storage/EntryModel.php +++ b/src/telescope/src/Storage/EntryModel.php @@ -45,7 +45,7 @@ class EntryModel extends Model /** * The primary key for the model. */ - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; /** * The "type" of the auto-incrementing ID. diff --git a/tests/Ai/Feature/RemembersConversationsTest.php b/tests/Ai/Feature/RemembersConversationsTest.php index 361468feb9..1899c6d98b 100644 --- a/tests/Ai/Feature/RemembersConversationsTest.php +++ b/tests/Ai/Feature/RemembersConversationsTest.php @@ -169,7 +169,7 @@ public function testItResolvesTheParticipantIdViaGetKeyForModelsWithCustomPrimar $participant = new class extends Model { protected array $guarded = []; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; protected string $keyType = 'string'; diff --git a/tests/Auth/AuthAccessGateScopeSelectTest.php b/tests/Auth/AuthAccessGateScopeSelectTest.php index 34f22dcb96..4ccd285995 100644 --- a/tests/Auth/AuthAccessGateScopeSelectTest.php +++ b/tests/Auth/AuthAccessGateScopeSelectTest.php @@ -726,5 +726,5 @@ class QueryAwareSoftDeletingPost extends Model class QueryAwareEmptyKeyPost extends ScopablePost { - protected string $primaryKey = ''; + protected ?string $primaryKey = ''; } diff --git a/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php b/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php index 45d19ccdb4..5d137139dc 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php @@ -130,7 +130,7 @@ public function articles() class Article extends Eloquent { - protected string $primaryKey = 'aid'; + protected ?string $primaryKey = 'aid'; protected ?string $table = 'articles'; diff --git a/tests/Database/DatabaseEloquentCollectionTest.php b/tests/Database/DatabaseEloquentCollectionTest.php index 8e306e26a0..f24fc8c9ff 100755 --- a/tests/Database/DatabaseEloquentCollectionTest.php +++ b/tests/Database/DatabaseEloquentCollectionTest.php @@ -423,6 +423,49 @@ public function testCollectionMergeSkipsNullModelKeys(): void ); } + #[DataProvider('keylessDictionaryOperations')] + public function testCollectionDictionaryOperationsRejectModelsWithoutPrimaryKeys(string $method, bool $emptyReceiver): void + { + $model = (new CollectionModel)->setKeyName(null); + $collection = new Collection($emptyReceiver ? [] : [$model]); + $arguments = match ($method) { + 'unique' => [], + 'only', 'except' => [[1]], + default => [[$model]], + }; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No primary key defined on model.'); + + $collection->{$method}(...$arguments); + } + + /** + * Provide collection operations that build a primary-key dictionary. + */ + public static function keylessDictionaryOperations(): array + { + return [ + 'merge' => ['merge', false], + 'merge into empty collection' => ['merge', true], + 'unique' => ['unique', false], + 'only' => ['only', false], + 'except' => ['except', false], + 'diff' => ['diff', false], + 'intersect' => ['intersect', false], + ]; + } + + public function testKeylessCollectionsCanBeConcatenatedAndDeduplicatedByAnExplicitColumn(): void + { + $one = (new CollectionModel)->setKeyName(null)->forceFill(['name' => 'Taylor']); + $two = (new CollectionModel)->setKeyName(null)->forceFill(['name' => 'Abigail']); + $collection = (new Collection([$one]))->concat([$two, $one]); + + $this->assertSame([$one, $two, $one], $collection->all()); + $this->assertSame([$one, $two], $collection->unique('name')->values()->all()); + } + public function testMap(): void { $one = new CollectionModel; diff --git a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php index fbf2f834ff..ad29fb343f 100644 --- a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php +++ b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php @@ -123,5 +123,5 @@ class ComparesRelatedModelsStub extends Model { protected array $guarded = []; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; } diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 57ee279c03..b2487187bf 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -1653,6 +1653,23 @@ public function testWithWhereHasOnSelfReferencingBelongsToManyRelationship(): vo $this->assertSame(['abigailotwell@gmail.com'], $results->first()->friends->pluck('email')->unique()->toArray()); } + public function testWithWhereHasWithSpecificColumnsAndCallback(): void + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $user->friends()->create(['email' => 'foo@gmail.com']); + + $results = User::withWhereHas('friends:id,email', function ($query) { + $query->where('email', 'abigailotwell@gmail.com'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + $this->assertCount(1, $results->first()->friends); + $this->assertSame(['id', 'email'], array_keys($results->first()->friends->first()->getAttributes())); + $this->assertSame('abigailotwell@gmail.com', $results->first()->friends->first()->email); + } + public function testHasOnNestedSelfReferencingBelongsToManyRelationship(): void { $user = User::create(['email' => 'taylorotwell@gmail.com']); @@ -3362,7 +3379,7 @@ class UniqueUserWithCustomKey extends UniqueUser { public bool $incrementing = false; - protected string $primaryKey = 'screen_name'; + protected ?string $primaryKey = 'screen_name'; protected string $keyType = 'string'; } diff --git a/tests/Database/DatabaseEloquentInverseRelationTest.php b/tests/Database/DatabaseEloquentInverseRelationTest.php index e82165fc00..07766b06c8 100755 --- a/tests/Database/DatabaseEloquentInverseRelationTest.php +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -315,7 +315,7 @@ class HasInverseRelationParentStub extends Model { protected static bool $unguarded = true; - protected string $primaryKey = 'id'; + protected ?string $primaryKey = 'id'; public function getForeignKey(): string { @@ -327,7 +327,7 @@ class HasInverseRelationRelatedStub extends Model { protected static bool $unguarded = true; - protected string $primaryKey = 'id'; + protected ?string $primaryKey = 'id'; public function getForeignKey(): string { diff --git a/tests/Database/DatabaseEloquentModelAttributesTest.php b/tests/Database/DatabaseEloquentModelAttributesTest.php index 317f198a06..ce5ab18e53 100644 --- a/tests/Database/DatabaseEloquentModelAttributesTest.php +++ b/tests/Database/DatabaseEloquentModelAttributesTest.php @@ -19,12 +19,17 @@ use Hypervel\Database\Eloquent\Attributes\Visible; use Hypervel\Database\Eloquent\Attributes\WithoutIncrementing; use Hypervel\Database\Eloquent\Attributes\WithoutTimestamps; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Tests\TestCase; +use LogicException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; +use UnitEnum; class DatabaseEloquentModelAttributesTest extends TestCase { @@ -113,6 +118,137 @@ public function testPrimaryKeyAttribute(): void $this->assertSame('custom_id', $model->getKeyName()); } + public function testPrimaryKeyAttributeCanDisablePrimaryKey(): void + { + $model = new ModelWithoutPrimaryKeyAttribute; + + $this->assertNull($model->getKeyName()); + } + + public function testPrimaryKeyCanBeDisabledByPropertyOrSetter(): void + { + $model = new class extends Model { + protected ?string $primaryKey = null; + }; + + $this->assertNull($model->getKey()); + $this->assertSame([], $model->getCasts()); + + $model->setKeyName('id'); + $this->assertSame(['id' => 'int'], $model->getCasts()); + + $model->setKeyName(null); + $this->assertNull($model->getKeyName()); + $this->assertSame([], $model->getCasts()); + } + + public function testIncrementingUsesTheOverriddenKeyName(): void + { + $keyless = new class extends Model { + /** + * Get the primary key for the model. + */ + public function getKeyName(): ?string + { + return null; + } + }; + + $this->assertFalse($keyless->getIncrementing()); + $this->assertSame([], $keyless->getCasts()); + + $keyed = new class extends Model { + protected ?string $primaryKey = null; + + /** + * Get the primary key for the model. + */ + public function getKeyName(): string + { + return 'row_id'; + } + }; + + $this->assertTrue($keyed->getIncrementing()); + $this->assertSame(['row_id' => 'int'], $keyed->getCasts()); + } + + public function testKeylessModelsCanBeInsertedAndRetrieved(): void + { + DB::schema()->create('keyless_records', function (Blueprint $table): void { + $table->string('name'); + }); + + $model = new ModelWithoutPrimaryKeyAttribute; + $model->setTable('keyless_records'); + $model->timestamps = false; + $model->name = 'Taylor'; + + $this->assertTrue($model->save()); + $this->assertTrue($model->exists); + $this->assertNull($model->getKey()); + $this->assertSame(['name' => 'Taylor'], $model->getAttributes()); + $this->assertSame(['name' => 'Taylor'], $model->newQuery()->first()->toArray()); + + $another = $model->replicate(); + $another->setTable('keyless_records'); + $another->timestamps = false; + $another->name = 'Abigail'; + + $this->assertTrue($another->saveOrIgnore()); + $this->assertSame(['Taylor', 'Abigail'], $model->newQuery()->pluck('name')->all()); + } + + #[TestWith(['save'])] + #[TestWith(['refresh'])] + #[TestWith(['delete'])] + public function testKeylessModelsRejectOperationsRequiringAnIdentity(string $operation): void + { + $model = new ModelWithoutPrimaryKeyAttribute; + $model->timestamps = false; + $model->exists = true; + $model->name = 'Taylor'; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No primary key defined on model.'); + + $model->{$operation}(); + } + + public function testKeylessModelsCanSupplyTheirOwnSaveKeys(): void + { + DB::schema()->create('keyless_records', function (Blueprint $table): void { + $table->string('tenant'); + $table->string('name'); + $table->integer('visits'); + }); + + DB::table('keyless_records')->insert([ + ['tenant' => 'first', 'name' => 'Taylor', 'visits' => 1], + ['tenant' => 'second', 'name' => 'Taylor', 'visits' => 1], + ]); + + $model = new class extends ModelWithoutPrimaryKeyAttribute { + protected ?string $table = 'keyless_records'; + + public bool $timestamps = false; + + /** + * Set the keys for a save update query. + */ + protected function setKeysForSaveQuery(Builder $query): Builder + { + return $query->where('tenant', $this->tenant)->where('name', $this->name); + } + }; + + $record = $model->newQuery()->where('tenant', 'first')->first(); + $record->visits = 2; + + $this->assertTrue($record->save()); + $this->assertSame(['first' => 2, 'second' => 1], DB::table('keyless_records')->pluck('visits', 'tenant')->all()); + } + public function testPrimaryKeyPropertyTakesPrecedence(): void { $model = new ModelWithPrimaryKeyAttributeAndProperty; @@ -194,6 +330,27 @@ public function testConnectionAttributeWithIntegerBackedEnum(): void $this->assertSame('0', $model->getConnectionName()); } + public function testChildConnectionAttributeOverridesInheritedConnectionProperty(): void + { + $model = new ChildModelWithConnectionAttribute; + + $this->assertSame('secondary', $model->getConnectionName()); + } + + public function testChildInheritsParentConnectionAttribute(): void + { + $model = new ChildModelWithNoConnection; + + $this->assertSame('secondary', $model->getConnectionName()); + } + + public function testChildConnectionPropertyOverridesParentConnectionAttribute(): void + { + $model = new ChildModelWithConnectionProperty; + + $this->assertSame('default', $model->getConnectionName()); + } + public function testTimestampsAttribute(): void { $model = new ModelWithTimestampsFalseAttribute; @@ -853,10 +1010,15 @@ class ModelWithPrimaryKeyAttribute extends Model { } +#[Table(key: false)] +class ModelWithoutPrimaryKeyAttribute extends Model +{ +} + #[Table(key: 'attribute_id')] class ModelWithPrimaryKeyAttributeAndProperty extends Model { - protected string $primaryKey = 'property_id'; + protected ?string $primaryKey = 'property_id'; } #[Table(key: 'uuid', keyType: 'string')] @@ -894,6 +1056,30 @@ class ModelWithIntegerBackedEnumConnectionAttribute extends Model { } +class ParentModelWithConnectionProperty extends Model +{ + protected UnitEnum|string|null $connection = 'default'; +} + +#[Connection('secondary')] +class ChildModelWithConnectionAttribute extends ParentModelWithConnectionProperty +{ +} + +#[Connection('secondary')] +class ParentModelWithConnectionAttribute extends Model +{ +} + +class ChildModelWithNoConnection extends ParentModelWithConnectionAttribute +{ +} + +class ChildModelWithConnectionProperty extends ParentModelWithConnectionAttribute +{ + protected UnitEnum|string|null $connection = 'default'; +} + #[Table(timestamps: false)] class ModelWithTimestampsFalseAttribute extends Model { diff --git a/tests/Database/DatabaseEloquentMorphToTest.php b/tests/Database/DatabaseEloquentMorphToTest.php index 0f42afa586..6bf27bc2b9 100644 --- a/tests/Database/DatabaseEloquentMorphToTest.php +++ b/tests/Database/DatabaseEloquentMorphToTest.php @@ -461,7 +461,7 @@ class MorphToAssociateParentStub extends Model class MorphToAssociateRelatedStub extends Model { - protected string $primaryKey = 'model_id'; + protected ?string $primaryKey = 'model_id'; } class AccessibleMorphTo extends MorphTo diff --git a/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php b/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php index 59e631c83e..9c16095dbf 100644 --- a/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php +++ b/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php @@ -10,5 +10,5 @@ class Video extends Model { public bool $timestamps = false; - protected string $primaryKey = 'video_id'; + protected ?string $primaryKey = 'video_id'; } diff --git a/tests/Database/Fixtures/Models/User.php b/tests/Database/Fixtures/Models/User.php index b40ff2ba76..08535d38b6 100644 --- a/tests/Database/Fixtures/Models/User.php +++ b/tests/Database/Fixtures/Models/User.php @@ -11,5 +11,5 @@ class User extends Model { use Authenticatable; - protected string $primaryKey = 'internal_id'; + protected ?string $primaryKey = 'internal_id'; } diff --git a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php index 355cab6cf0..c01d36bfb7 100644 --- a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php @@ -155,7 +155,7 @@ class Post extends Model { public bool $timestamps = false; - protected string $primaryKey = 'post_id'; + protected ?string $primaryKey = 'post_id'; /** * Get the post's user. diff --git a/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php index 009848adf4..95dfae4f82 100644 --- a/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php @@ -74,7 +74,7 @@ class Post extends Model { public bool $timestamps = false; - protected string $primaryKey = 'post_id'; + protected ?string $primaryKey = 'post_id'; /** * Get the post's user. diff --git a/tests/Integration/Database/EloquentMorphToEagerLoadTest.php b/tests/Integration/Database/EloquentMorphToEagerLoadTest.php index 4926d949e4..9937eee534 100644 --- a/tests/Integration/Database/EloquentMorphToEagerLoadTest.php +++ b/tests/Integration/Database/EloquentMorphToEagerLoadTest.php @@ -118,7 +118,7 @@ class Article extends Model { public bool $timestamps = false; - protected string $primaryKey = 'slug'; + protected ?string $primaryKey = 'slug'; protected string $keyType = 'string'; diff --git a/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php index f0fdf982cf..85d4281094 100644 --- a/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php @@ -69,7 +69,7 @@ class Post extends Model { public bool $timestamps = false; - protected string $primaryKey = 'post_id'; + protected ?string $primaryKey = 'post_id'; protected array $with = ['user']; diff --git a/tests/Integration/Database/EloquentMorphToTargetKeysTest.php b/tests/Integration/Database/EloquentMorphToTargetKeysTest.php index 6d3c138210..35051a845a 100644 --- a/tests/Integration/Database/EloquentMorphToTargetKeysTest.php +++ b/tests/Integration/Database/EloquentMorphToTargetKeysTest.php @@ -109,7 +109,7 @@ class Video extends Model { public bool $timestamps = false; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; protected string $keyType = 'string'; diff --git a/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php b/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php index 8103d0c22c..7d4d302f79 100644 --- a/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php +++ b/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php @@ -200,5 +200,5 @@ class ModelWithCustomUuidPrimaryKeyName extends Eloquent protected array $guarded = []; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; } diff --git a/tests/Integration/Database/Fixtures/PostStringyKey.php b/tests/Integration/Database/Fixtures/PostStringyKey.php index de1ba44660..22279dbc1a 100644 --- a/tests/Integration/Database/Fixtures/PostStringyKey.php +++ b/tests/Integration/Database/Fixtures/PostStringyKey.php @@ -10,5 +10,5 @@ class PostStringyKey extends Model { protected ?string $table = 'my_posts'; - protected string $primaryKey = 'my_id'; + protected ?string $primaryKey = 'my_id'; } diff --git a/tests/Passkeys/Feature/PasskeyTest.php b/tests/Passkeys/Feature/PasskeyTest.php index 3b99c6dc47..86f7eca0ec 100644 --- a/tests/Passkeys/Feature/PasskeyTest.php +++ b/tests/Passkeys/Feature/PasskeyTest.php @@ -58,7 +58,7 @@ class UserWithCustomPrimaryKey extends Authenticatable implements PasskeyUser protected ?string $table = 'users_with_custom_primary_keys'; - protected string $primaryKey = 'user_id'; + protected ?string $primaryKey = 'user_id'; public bool $incrementing = false; diff --git a/tests/Passport/RelationConnectionsTest.php b/tests/Passport/RelationConnectionsTest.php index 17067133f5..9f88d9fdd8 100644 --- a/tests/Passport/RelationConnectionsTest.php +++ b/tests/Passport/RelationConnectionsTest.php @@ -162,5 +162,5 @@ class PassportConnectionClient extends Client class PassportCustomKeyClient extends Client { - protected string $primaryKey = 'client_uuid'; + protected ?string $primaryKey = 'client_uuid'; } diff --git a/tests/Permission/Fixtures/Models/Permission.php b/tests/Permission/Fixtures/Models/Permission.php index c7b70f8f18..9f4dce1554 100644 --- a/tests/Permission/Fixtures/Models/Permission.php +++ b/tests/Permission/Fixtures/Models/Permission.php @@ -12,7 +12,7 @@ class Permission extends BasePermission { use SoftDeletes; - protected string $primaryKey = 'permission_test_id'; + protected ?string $primaryKey = 'permission_test_id'; protected array $visible = [ 'permission_test_id', diff --git a/tests/Permission/Fixtures/Models/Role.php b/tests/Permission/Fixtures/Models/Role.php index 7475f84537..f42024a162 100644 --- a/tests/Permission/Fixtures/Models/Role.php +++ b/tests/Permission/Fixtures/Models/Role.php @@ -16,7 +16,7 @@ class Role extends BaseRole public const string HIERARCHY_TABLE = 'roles_hierarchy'; - protected string $primaryKey = 'role_test_id'; + protected ?string $primaryKey = 'role_test_id'; protected array $visible = [ 'role_test_id', diff --git a/tests/Sanctum/PersonalAccessTokenCacheTest.php b/tests/Sanctum/PersonalAccessTokenCacheTest.php index 2b9975c24e..72d55d253e 100644 --- a/tests/Sanctum/PersonalAccessTokenCacheTest.php +++ b/tests/Sanctum/PersonalAccessTokenCacheTest.php @@ -1828,7 +1828,7 @@ class NamespacedPersonalAccessToken extends PersonalAccessToken { protected ?string $table = 'namespaced_personal_access_tokens'; - protected string $primaryKey = 'token_id'; + protected ?string $primaryKey = 'token_id'; /** * Get cache key for token and tokenable. diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php index 9f102c19cc..e1116403fe 100644 --- a/tests/Validation/ValidationUniqueRuleTest.php +++ b/tests/Validation/ValidationUniqueRuleTest.php @@ -275,7 +275,7 @@ class EloquentModelStub extends Model { protected ?string $table = 'table'; - protected string $primaryKey = 'id_column'; + protected ?string $primaryKey = 'id_column'; protected array $guarded = []; } @@ -284,7 +284,7 @@ class PrefixedTableEloquentModelStub extends Model { protected ?string $table = 'public.table'; - protected string $primaryKey = 'id_column'; + protected ?string $primaryKey = 'id_column'; protected array $guarded = []; } diff --git a/tests/Wayfinder/BindingResolverTest.php b/tests/Wayfinder/BindingResolverTest.php index 050739bf93..8ee3f3fde2 100644 --- a/tests/Wayfinder/BindingResolverTest.php +++ b/tests/Wayfinder/BindingResolverTest.php @@ -173,7 +173,7 @@ class NonIncrementingBindingResolverModel extends Model protected ?string $table = 'external_binding_models'; - protected string $primaryKey = 'external_id'; + protected ?string $primaryKey = 'external_id'; protected string $keyType = 'string'; diff --git a/tests/Workbench/Fixtures/Member.php b/tests/Workbench/Fixtures/Member.php index 5f72b35dc2..edefd59db4 100644 --- a/tests/Workbench/Fixtures/Member.php +++ b/tests/Workbench/Fixtures/Member.php @@ -13,7 +13,7 @@ class Member extends Authenticatable protected ?string $table = 'workbench_members'; - protected string $primaryKey = 'member_id'; + protected ?string $primaryKey = 'member_id'; protected string $rememberTokenName = 'member_remember_token'; From 04db7dc53fcd15a292555f1003f435557eb341b6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:10 +0000 Subject: [PATCH 02/22] Preserve pooled read routing and recover from dead PDO cleanup Port Laravel framework PRs #62042 (6d2eefbb1476a898ecc75e2a212676cd7129a55d), #62078 (213b1e6048c8338f87815d3b3ddfe474af853be0), #62099 (4b3f2d8e1c92f80da1fbfd35301767e721866502), and facade update e50ad778f688cd76008ea817d36dfa29db9a2521 from the frozen 13.x target. Port the corresponding read-routing documentation from docs 13.x. Suppress dead-handle destruction warnings at the PDO setters and release temporary references before replacement, while preserving callback, rollback and cancellation failures. Recognize native PDO TLS connection-loss warnings. The real reconnect regression covers native success and records the existing Swoole warning-suppression defect with its version-gated test. Add PreventStaleDatabaseReads without borrowing a pool slot on query-free requests. Resolve already-held connections through the active execution or testing owner, including enum/default names. Add driver-neutral usingWritePdo callback scoping with exception-safe preference restoration and regenerate the DB facade. Explain that direct endpoints use named connections and migrations_connection so pool ownership remains intact. Validate affected PDO, lease, resolver, manager, middleware and session tests, package-mode Testbench, source/type analysis and formatting. The final six-file correction run passes 256 tests and 817 assertions. --- src/database/README.md | 2 +- src/database/src/Connection.php | 21 ++ src/database/src/ConnectionResolver.php | 34 ++- src/database/src/DatabaseManager.php | 22 ++ src/database/src/LostConnectionDetector.php | 19 +- src/database/src/PdoConnection.php | 36 ++- src/docs/database.md | 43 ++++ src/docs/porting-from-laravel.md | 2 +- .../Middleware/PreventStaleDatabaseReads.php | 58 +++++ .../Testing/DatabaseConnectionResolver.php | 12 + src/support/src/Facades/DB.php | 2 + tests/Database/ConnectionResolverTest.php | 8 + .../Database/DatabaseConnectionLeaseTest.php | 48 ++++ tests/Database/DatabaseConnectionLostTest.php | 4 + tests/Database/DatabaseManagerTest.php | 13 ++ tests/Database/DatabasePdoConnectionTest.php | 205 ++++++++++++++++++ tests/Database/DatabasePdoReconnectTest.php | 45 ++++ .../Fixtures/ReconnectAfterSocketReset.php | 175 +++++++++++++++ .../DatabaseConnectionResolverTest.php | 16 +- .../PreventStaleDatabaseReadsTest.php | 188 ++++++++++++++++ 20 files changed, 925 insertions(+), 28 deletions(-) create mode 100644 src/foundation/src/Http/Middleware/PreventStaleDatabaseReads.php create mode 100644 tests/Database/DatabasePdoReconnectTest.php create mode 100644 tests/Database/Fixtures/ReconnectAfterSocketReset.php create mode 100644 tests/Integration/Foundation/Http/Middleware/PreventStaleDatabaseReadsTest.php diff --git a/src/database/README.md b/src/database/README.md index 3391fe76fd..eb23c9609f 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -18,7 +18,7 @@ Documentation: https://hypervel.org/docs/database - `chunkById` and `lazyById` prioritize the paging column, including when stopping early. Limited or offset calls led by another order throw instead of selecting a different set, including when a global scope uses `reorder`; choose ranked subsets with `fromSub` first. The descending variants follow the same rule. See [chunking results](https://hypervel.org/docs/queries#chunking-results). - Outgoing framework HTTP requests return idle database sessions to their pools. Code using temporary tables, session locks or retained raw PDOs across these calls must use `withPinnedSession()`; ordinary queries and active transactions keep their behavior. See [releasing and pinning connections](https://hypervel.org/docs/database#releasing-and-pinning-connections). - MySQL and MariaDB connections require `strict` or `modes` in their configuration instead of implicitly inheriting the server's SQL mode, so SQL literals can be escaped correctly. -- Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix. +- Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix or its direct PDO methods such as `getDirectPdo()`. - Laravel exposes PDO methods on its base connection class. Hypervel's base `Connection` is driver-neutral, while PDO-backed drivers extend `PdoConnection`. Code that requires direct PDO access should accept or narrow to `PdoConnection`. When bringing in Laravel database updates, keep driver-neutral connection behavior on `Connection`, PDO mechanics on `PdoConnection`, and driver-specific behavior on the matching connection, grammar, schema builder, or processor. - Hypervel provides `BinaryParameter` for explicitly binding already-encoded binary strings in query builder operations and Eloquent key helpers. Laravel has no equivalent wrapper. - Laravel's `migrate:fresh` command wipes only its selected connection. Hypervel discovers the connection declared by each migration and wipes every resolved target before rebuilding the schema. diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 49febe5ca9..47bb9689d9 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1379,6 +1379,27 @@ public function useWriteConnectionWhenReading(bool $value = true): static return $this; } + /** + * Execute the given callback using the write connection for reads. + * + * @template TReturn + * + * @param Closure($this): TReturn $callback + * @return TReturn + */ + public function usingWritePdo(Closure $callback): mixed + { + $readOnWriteConnection = $this->readOnWriteConnection; + + $this->useWriteConnectionWhenReading(); + + try { + return $callback($this); + } finally { + $this->useWriteConnectionWhenReading($readOnWriteConnection); + } + } + /** * Resolve and record the connection role for an operation. * diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index f9f8cd8fe1..f2f5784be7 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -163,15 +163,23 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa } /** - * Check the current connection's transaction state without borrowing a pool slot. + * Get the current execution's connection without borrowing a pool slot. */ - public function hasApplicableTransaction(UnitEnum|string|null $name = null): bool + public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface { - $connectionName = ConnectionName::parse($this->connectionName($name)); + return $this->getContextConnection($this->connectionName($name)); + } + + /** + * Find the context-owned connection, including shared in-memory aliases. + */ + private function getContextConnection(string $name): ?ConnectionInterface + { + $connectionName = ConnectionName::parse($name); $held = CoroutineContext::get($this->getContextKey($connectionName->requested)); - if ($held instanceof Connection) { - return $held->hasApplicableTransaction(); + if ($held instanceof ConnectionInterface) { + return $held; } $pool = $this->poolManager->existing($connectionName->requested); @@ -180,12 +188,22 @@ public function hasApplicableTransaction(UnitEnum|string|null $name = null): boo if ($pool?->getSharedInMemorySqlitePdo() !== null) { $held = CoroutineContext::get($this->getContextKey($pool->getName())); - if ($held instanceof Connection) { - return $held->hasApplicableTransaction(); + if ($held instanceof ConnectionInterface) { + return $held; } } - return false; + return null; + } + + /** + * Check the current connection's transaction state without borrowing a pool slot. + */ + public function hasApplicableTransaction(UnitEnum|string|null $name = null): bool + { + $held = $this->getContextConnection($this->connectionName($name)); + + return $held instanceof Connection && $held->hasApplicableTransaction(); } /** diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index 10635c2442..ab50005498 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -103,6 +103,28 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa return $this->app->make('db.resolver')->connection($name); } + /** + * Get the connection already held by the current resolver. + * + * Resolvers without connection inspection support resolve normally. + */ + public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface + { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; + + $resolver = $this->app->make('db.resolver'); + + return $resolver instanceof ConnectionResolver + ? $resolver->getHeldConnection($name) + : $resolver->connection($name); + } + /** * Determine whether a connection has an applicable transaction without borrowing a pool slot. */ diff --git a/src/database/src/LostConnectionDetector.php b/src/database/src/LostConnectionDetector.php index 5796fb5949..b398fcda39 100644 --- a/src/database/src/LostConnectionDetector.php +++ b/src/database/src/LostConnectionDetector.php @@ -22,20 +22,25 @@ public function causedByLostConnection(Throwable $e): bool $e = $e->getPrevious(); } - // mysqlnd can report protocol failures as PHP warnings even in exception mode. + $message = $e->getMessage(); + + // mysqlnd can report protocol and TLS failures as PHP warnings even in exception mode. if ($e instanceof ErrorException) { - return Str::startsWith($e->getMessage(), [ + if (Str::startsWith($message, [ 'Packets out of order. Expected', 'Error while sending', - ]); - } + ])) { + return true; + } - if (! $e instanceof PDOException && ! $e instanceof LostConnectionException) { + // Only native PDO TLS warnings may use the connection-loss reasons below. + if (! preg_match('/^PDO(?:Statement)?::\w+\(\): SSL(?:: | operation failed with code )/', $message)) { + return false; + } + } elseif (! $e instanceof PDOException && ! $e instanceof LostConnectionException) { return false; } - $message = $e->getMessage(); - return Str::contains($message, [ 'server has gone away', 'Server has gone away', diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 39a20c9263..4fd6b872aa 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -364,9 +364,14 @@ public function getPdo(): PDO $pdo = $this->resolvePdo(); // Transaction and cleanup failures can invalidate a PDO even without session configurators. - return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) - ? $pdo - : $this->synchronizeSession($pdo, read: false); + if (static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo)) { + return $pdo; + } + + // Replacement must release the old handle through the setters, not this frame. + unset($pdo); + + return $this->synchronizeSession(read: false); } /** @@ -388,9 +393,13 @@ public function getReadPdo(): PDO $pdo = $this->resolveReadPdo(); - return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) - ? $pdo - : $this->synchronizeSession($pdo, read: true); + if (static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo)) { + return $pdo; + } + + unset($pdo); + + return $this->synchronizeSession(read: true); } /** @@ -401,6 +410,10 @@ public function getRawReadPdo(): PDO|Closure|null return $this->readPdo; } + // Laravel's direct-PDO methods, including getDirectPdo(), are intentionally omitted. + // Direct endpoints use named connections so their pools own the handles and cleanup; + // migrations select that connection through migrations_connection. + /** * Resolve the current write PDO without synchronizing session state. */ @@ -483,8 +496,9 @@ public function detachPdoResources(Closure $pdo, ?Closure $readPdo): void /** * Synchronize the desired state for a physical database session. */ - protected function synchronizeSession(PDO $pdo, bool $read): PDO + protected function synchronizeSession(bool $read): PDO { + $pdo = $read ? $this->resolveReadPdo() : $this->resolvePdo(); $sessionState = static::physicalSessionState($pdo); if ($sessionState->configuring) { @@ -497,6 +511,7 @@ protected function synchronizeSession(PDO $pdo, bool $read): PDO $sessionState->configuring = true; try { + unset($pdo); $pdo = $this->replaceUnknownSession($read); } finally { $sessionState->configuring = false; @@ -695,7 +710,8 @@ public function setPdo(PDO|Closure|null $pdo): static $this->lockForPopping = null; $this->maxBindings = null; - $this->pdo = $pdo; + // A dead PDO may warn during destruction; that must not interrupt replacement. + @$this->pdo = $pdo; return $this; } @@ -705,7 +721,8 @@ public function setPdo(PDO|Closure|null $pdo): static */ public function setReadPdo(PDO|Closure|null $pdo): static { - $this->readPdo = $pdo; + // Suppress only handle destruction, leaving callbacks and rollback errors visible. + @$this->readPdo = $pdo; return $this; } @@ -760,6 +777,7 @@ protected function disconnectDriverResources(): void } } } finally { + unset($pdo, $readPdo, $handle); $this->forgetDriverResources(); } diff --git a/src/docs/database.md b/src/docs/database.md index 10ca985ec0..c9d4b4194d 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -9,6 +9,8 @@ - [PostgreSQL Keepalives](#postgresql-keepalives) - [PostgreSQL Server Options](#postgresql-server-options) - [Read and Write Connections](#read-and-write-connections) + - [Preventing Stale Database Reads](#preventing-stale-database-reads) + - [Using the Write Connection for Reads](#using-the-write-connection-for-reads) - [Connection Pooling](#connection-pooling) - [Releasing and Pinning Connections](#releasing-and-pinning-connections) - [Configuring Database Session State](#configuring-database-session-state) @@ -248,6 +250,47 @@ The `sticky` option is an *optional* value that can be used to allow the immedia A long-running queue worker gives each job its own coroutine, so one job's writes do not send later jobs' reads to the write connection. + +#### Preventing Stale Database Reads + +While the `sticky` option only affects the current request, the `Hypervel\Foundation\Http\Middleware\PreventStaleDatabaseReads` middleware allows subsequent requests in the same session to read recently written data from the write connection. This can help prevent users from seeing stale data while your read replicas catch up with the write database. + +To enable this behavior, append the middleware to the `web` middleware group in your application's `bootstrap/app.php` file: + +```php +use Hypervel\Foundation\Configuration\Middleware; +use Hypervel\Foundation\Http\Middleware\PreventStaleDatabaseReads; + +->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + PreventStaleDatabaseReads::class, + ]); +}) +``` + +The middleware tracks writes to the default database connection in the user's session. By default, requests within two seconds of a write will use the write connection for reads on that connection. You may customize this duration using the middleware's `for` method: + +```php +$middleware->web(append: [ + PreventStaleDatabaseReads::for(5), +]); +``` + +Since this middleware relies on the session, it must run after the `Hypervel\Session\Middleware\StartSession` middleware. It does not affect reads made by other sessions or on other database connections. Child coroutines resolve their own connections, so their reads and writes are not covered by this middleware. + + +#### Using the Write Connection for Reads + +If you need to ensure that all reads on a connection use the write database while executing a callback, you may use the connection's `usingWritePdo` method: + +```php +use Hypervel\Support\Facades\DB; + +$users = DB::connection()->usingWritePdo(fn ($connection) => $connection->table('users')->get()); +``` + +This also applies to Eloquent queries and relationship queries executed within the callback using the same connection. The method returns the callback's result and restores the connection's previous read preference when the callback finishes, even if an exception is thrown. + ### Connection Pooling diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index b90ab4a35f..46f3991ac4 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -837,7 +837,7 @@ Laravel's base `Connection` class exposes PDO methods. Hypervel's base `Connecti Custom base query builders overriding `newQuery`, `forNestedWhere`, or `cloneForPaginationCount` must declare `static` returns and preserve the concrete builder class. Keep `forSubQuery` separate: join subqueries return the parent query builder. See the [database extension guide](/docs/{{version}}/database#extending-database-connections) for these return contracts. -Laravel's nested `direct` connection endpoint and `::direct` suffix are not available. Configure the direct endpoint as a normal named connection and point the pooled connection's `migrations_connection` option at it. +Use named connections instead of Laravel's nested `direct` endpoint, `::direct` suffix and direct-PDO methods such as `getDirectPdo()`. Select the migration connection with `migrations_connection`. Model casts are not applied to direct query builder operations or Eloquent key helpers. When ported code passes already-encoded binary strings to query builder `where`, bulk `update`, or `upsert` calls, or to Eloquent `find`, `whereKey`, or `whereKeyNot`, wrap them in `Hypervel\Database\BinaryParameter`. See [binding binary values](/docs/{{version}}/database#binding-binary-values) and [binary casting](/docs/{{version}}/eloquent-mutators#binary-casting). diff --git a/src/foundation/src/Http/Middleware/PreventStaleDatabaseReads.php b/src/foundation/src/Http/Middleware/PreventStaleDatabaseReads.php new file mode 100644 index 0000000000..78ba6f4a72 --- /dev/null +++ b/src/foundation/src/Http/Middleware/PreventStaleDatabaseReads.php @@ -0,0 +1,58 @@ +manager->getDefaultConnection(); + $recentlyModified = $request->session()->get('_database_modified_at', 0) >= Date::now()->subSeconds((int) $seconds)->getTimestamp(); + + // Resolving a pooled connection takes a slot even before its first query. + // Without a recent write, inspect existing state so query-free routes do not exhaust the pool. + /** @var null|Connection $connection */ + $connection = $recentlyModified + ? $this->manager->connection($name) + : $this->manager->getHeldConnection($name); + $connection?->useWriteConnectionWhenReading($recentlyModified); + + $response = $next($request); + + // The request may have resolved its first connection, or changed the default name. + $connection ??= $this->manager->getHeldConnection($name); + + /** @var null|Connection $connection */ + if ($connection?->hasModifiedRecords()) { + $request->session()->put('_database_modified_at', Date::now()->getTimestamp()); + } + + return $response; + } +} diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 0899ad293d..24fc4c4c30 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -153,6 +153,18 @@ public function getResolvedConnection(string $name): ?ConnectionInterface return static::$connections[$this->connectionCacheKey($name)] ?? null; } + /** + * Get the connection owned by the active testing mode without borrowing one. + */ + public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface + { + $name = $this->connectionName($name); + + return $this->poolEnabled(ConnectionName::parse($name)) + ? parent::getHeldConnection($name) + : $this->getResolvedConnection($name); + } + /** * Get the connections shared across the test's setup, requests and assertions. * diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 44315156dc..4b37a64297 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -24,6 +24,7 @@ * @method static void forgetExtension(string $name) * @method static array getConnections() * @method static string|null getDefaultConnection() + * @method static \Hypervel\Database\ConnectionInterface|null getHeldConnection(\UnitEnum|string|null $name = null) * @method static bool hasApplicableTransaction(\UnitEnum|string|null $name = null) * @method static bool hasMacro(string $name) * @method static void macro(string $name, callable|object $macro) @@ -121,6 +122,7 @@ * @method static void useDefaultQueryGrammar() * @method static void useDefaultSchemaGrammar() * @method static \Hypervel\Database\Connection useWriteConnectionWhenReading(bool $value = true) + * @method static mixed usingWritePdo(\Closure $callback) * @method static mixed withoutPretending(\Closure $callback) * @method static mixed withoutTablePrefix(\Closure $callback) * @method static mixed withPinnedSession(\Closure $callback) diff --git a/tests/Database/ConnectionResolverTest.php b/tests/Database/ConnectionResolverTest.php index fea12f4c38..2a83bb0b17 100644 --- a/tests/Database/ConnectionResolverTest.php +++ b/tests/Database/ConnectionResolverTest.php @@ -603,6 +603,9 @@ public function testConnectionDateFormatPrefersTheGrammarOfAHeldConnection(): vo $connection->setQueryGrammar(new ConnectionResolverTestTimestampGrammar($connection)); $this->assertSame('U', $resolver->connectionDateFormat('sqlite')); + $this->assertSame($connection, $resolver->getHeldConnection()); + $this->assertSame($connection, $resolver->getHeldConnection('')); + $this->assertSame($connection, $resolver->getHeldConnection(ConnectionResolverTestName::Sqlite)); }); } @@ -669,6 +672,11 @@ protected function makeResolver( } } +enum ConnectionResolverTestName: string +{ + case Sqlite = 'sqlite'; +} + class ConnectionResolverTestTimestampGrammar extends SQLiteGrammar { /** diff --git a/tests/Database/DatabaseConnectionLeaseTest.php b/tests/Database/DatabaseConnectionLeaseTest.php index f4c95a348c..2602a8b74f 100644 --- a/tests/Database/DatabaseConnectionLeaseTest.php +++ b/tests/Database/DatabaseConnectionLeaseTest.php @@ -6,6 +6,7 @@ use Aws\Result; use Closure; +use ErrorException; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; @@ -782,6 +783,53 @@ public function testDisconnectDropsThePhysicalPdoAndReusesTheLogicalConnection() $this->assertSame(2, $connection->selectOne('select 2 as value')->value); } + public function testReconnectReleasesWarningHandlesThroughThePhysicalConnection(): void + { + $this->app->instance('db.connector.pgsql', new class extends LeasePdoConnector { + private bool $first = true; + + /** + * Make the first physical handle warn when it is released. + */ + public function connect(array $config): PDO + { + if (! $this->first) { + return parent::connect($config); + } + + $this->first = false; + + return new class('sqlite::memory:') extends PDO { + /** + * Simulate a native teardown warning. + */ + public function __destruct() + { + trigger_error('SSL: Broken pipe', E_USER_WARNING); + } + }; + } + }); + + $connection = DB::connection('physical_leases'); + $original = WeakReference::create($connection->getPdo()); + + set_error_handler(static function (int $severity, string $message, string $file, int $line): void { + if (error_reporting() & $severity) { + throw new ErrorException($message, 0, $severity, $file, $line); + } + }); + + try { + $connection->reconnect(); + + $this->assertNull($original->get()); + $this->assertSame(2, $connection->selectFromWriteConnection('select 2 as value')[0]->value); + } finally { + restore_error_handler(); + } + } + public function testCapturedMySqlInsertIdsSurviveEarlyRelease(): void { $connection = DB::connection('insert_id_leases'); diff --git a/tests/Database/DatabaseConnectionLostTest.php b/tests/Database/DatabaseConnectionLostTest.php index 5b6b6da6c9..4dd37215dc 100755 --- a/tests/Database/DatabaseConnectionLostTest.php +++ b/tests/Database/DatabaseConnectionLostTest.php @@ -77,6 +77,8 @@ public static function driverFailures(): iterable yield 'explicit signal' => [new LostConnectionException('Lost connection and no reconnector available.')]; yield 'mysqlnd packet warning' => [new ErrorException('Packets out of order. Expected 1 received 0. Packet size=145', 0, E_WARNING)]; yield 'mysqlnd send warning' => [new ErrorException('Error while sending QUERY packet. PID=1234', 0, E_WARNING)]; + yield 'PDO TLS broken pipe' => [new ErrorException('PDOStatement::execute(): SSL: Broken pipe', 0, E_WARNING)]; + yield 'PDO TLS record failure' => [new ErrorException('PDO::query(): SSL operation failed with code 1. OpenSSL Error messages: decryption failed or bad record mac', 0, E_WARNING)]; } #[DataProvider('nonDatabaseFailures')] @@ -99,5 +101,7 @@ public static function nonDatabaseFailures(): iterable yield 'filesystem warning' => [new ErrorException('fopen(missing): Failed to open stream: No such file or directory', 0, E_WARNING)]; yield 'embedded packet warning' => [new ErrorException('fopen(Packets out of order. Expected): Failed to open stream', 0, E_WARNING)]; yield 'unsupported PDO warning mode' => [new ErrorException('PDO::query(): SQLSTATE[HY000]: General error: 2006 MySQL server has gone away', 0, E_WARNING)]; + yield 'PDO certificate configuration' => [new ErrorException('PDO::__construct(): SSL operation failed with code 1. OpenSSL Error messages: certificate verify failed', 0, E_WARNING)]; + yield 'Redis TLS failure' => [new ErrorException('Redis::get(): SSL: Connection reset by peer', 0, E_WARNING)]; } } diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index 3e588ba9c0..cfedac16bc 100644 --- a/tests/Database/DatabaseManagerTest.php +++ b/tests/Database/DatabaseManagerTest.php @@ -80,6 +80,19 @@ public function testUsingConnectionAcceptsEnumsAndRestoresTheDefaultConnection() $this->assertSame('sqlite', $manager->getDefaultConnection()); } + public function testHeldConnectionFallsBackToTheResolverAndNormalizesEnumNames(): void + { + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'analytics'); + + $manager = $this->db->getDatabaseManager(); + + $this->assertSame($manager->connection(), $manager->getHeldConnection()); + $this->assertSame($manager->connection('analytics'), $manager->getHeldConnection(DatabaseManagerTestUnitConnection::analytics)); + } + public function testReconnectorAcceptsArrayCallable(): void { $manager = $this->db->getDatabaseManager(); diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index 2e70b8b00c..906b8527a7 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -904,6 +904,57 @@ public function testQueriesRunWhileReconnectingDoNotReconnectAgain(): void } } + public function testUsingWritePdoUsesWritePdoForReadsWithinCallback(): void + { + $writePdo = new PDOStub; + $readPdo = new PDOStub; + + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + + $this->assertSame($readPdo, $connection->getReadPdo()); + + $this->assertSame('foo', $connection->usingWritePdo(function (MySqlConnection $connection) use ($writePdo): string { + $this->assertSame($writePdo, $connection->getReadPdo()); + + return 'foo'; + })); + + $this->assertSame($readPdo, $connection->getReadPdo()); + } + + public function testUsingWritePdoRestoresPreviousStateWhenCallbackThrows(): void + { + $writePdo = new PDOStub; + $readPdo = new PDOStub; + + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + $failure = new Exception('Whoops!'); + + try { + $connection->usingWritePdo(static fn (): never => throw $failure); + + $this->fail('Exception was not thrown.'); + } catch (Exception $exception) { + $this->assertSame($failure, $exception); + $this->assertSame('Whoops!', $exception->getMessage()); + } + + $this->assertSame($readPdo, $connection->getReadPdo()); + } + + public function testUsingWritePdoPreservesAnAlreadyEnabledWriteConnectionPreference(): void + { + $writePdo = new PDOStub; + $readPdo = new PDOStub; + + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + $connection->useWriteConnectionWhenReading(); + + $connection->usingWritePdo(fn () => null); + + $this->assertSame($writePdo, $connection->getReadPdo()); + } + public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned(): void { $failure = new RuntimeException('commit failure'); @@ -1391,6 +1442,108 @@ public function testDisconnectPreservesManagerFailureAfterLostPhysicalRollbackFa $this->assertNull($connection->getRawReadPdo()); } + public function testDisconnectReleasesEveryPdoWhenReleasingThemRaisesWarnings(): void + { + $connection = new PdoConnection(new PdoThatWarnsWhenReleased); + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + + $this->convertingWarningsToExceptions(fn () => $connection->disconnect()); + + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testLostConnectionIsRetriedWhenReleasingADeadReadPdoRaisesAWarning(): void + { + $statement = m::mock(PDOStatement::class); + $statement->expects('execute')->andThrow(new PDOException('server has gone away')); + $statement->expects('execute')->andReturn(true); + + $pdo = m::mock(PDO::class); + $pdo->expects('prepare')->times(2)->andReturn($statement); + $pdo->expects('inTransaction')->andReturnFalse(); + + $connection = new PdoConnection(fn () => $pdo); + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + + $connection->setReconnector(function (PdoConnection $connection) use ($pdo): void { + $connection->disconnect(); + $connection->setPdo(fn () => $pdo); + }); + + $this->assertTrue($this->convertingWarningsToExceptions(fn () => $connection->statement('foo'))); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testLostCommitReleasesWarningHandlesAndPreservesTheOriginalFailure(): void + { + $failure = new ErrorException('PDO::commit(): SSL: Broken pipe', 0, E_WARNING); + $connection = new PdoConnection(new PdoThatWarnsWhenReleased($failure)); + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + + try { + $this->convertingWarningsToExceptions(fn () => $connection->transaction(static fn () => null)); + $this->fail('Expected the lost commit failure.'); + } catch (ErrorException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + $this->assertSame(0, $connection->transactionLevel()); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testFailedSessionConfigurationReleasesTheOldHandleDuringReplacement(bool $read): void + { + $failure = new RuntimeException('Session configuration failed.'); + PdoConnection::configureSessionUsing(new class($failure) implements SessionConfigurator { + public function __construct(private ?RuntimeException $failure) + { + } + + public function state(PdoConnection $connection): ?string + { + return 'configured'; + } + + public function apply(PDO $pdo, string $state, PdoConnection $connection): void + { + if ($this->failure !== null) { + $failure = $this->failure; + $this->failure = null; + + throw $failure; + } + } + }); + + $connection = new PdoConnection($read ? new PDO('sqlite::memory:') : new PdoThatWarnsWhenReleased); + + if ($read) { + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + } + + $getter = $read ? 'getReadPdo' : 'getPdo'; + $setter = $read ? 'setReadPdo' : 'setPdo'; + $replacement = new PDO('sqlite::memory:'); + $connection->setReconnector(static function (PdoConnection $connection) use ($setter, $replacement): void { + $connection->{$setter}($replacement); + }); + + $this->convertingWarningsToExceptions(function () use ($connection, $getter, $replacement, $failure): void { + try { + $connection->{$getter}(); + $this->fail('Expected session configuration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame($replacement, $connection->{$getter}()); + }); + } + public function testRefreshTransfersOwnershipOfBothPhysicalHandles(): void { $connection = new PdoConnection(new PDO('sqlite::memory:')); @@ -1592,6 +1745,27 @@ static function () use (&$resolutions): PDO { $connection->endForeignKeyConstraintSuppression(); } + /** + * Convert unsuppressed warnings to exceptions during the callback. + */ + protected function convertingWarningsToExceptions(callable $callback): mixed + { + $errorReporting = error_reporting(E_ALL); + + set_error_handler(static function (int $level, string $message, string $file, int $line): void { + if (error_reporting() & $level) { + throw new ErrorException($message, 0, $level, $file, $line); + } + }); + + try { + return $callback(); + } finally { + restore_error_handler(); + error_reporting($errorReporting); + } + } + protected function getSqliteTransactionConnection(): PdoConnection { return new PdoConnection( @@ -1630,6 +1804,37 @@ public function __construct() } } +class PdoThatWarnsWhenReleased extends PDO +{ + /** + * Open a real handle with an optional commit failure. + */ + public function __construct(private ?Throwable $commitFailure = null) + { + parent::__construct('sqlite::memory:'); + } + + /** + * Simulate a native warning while releasing a dead connection. + */ + public function __destruct() + { + trigger_error('SSL: Broken pipe', E_USER_WARNING); + } + + /** + * Commit or raise the configured connection failure. + */ + public function commit(): bool + { + if ($this->commitFailure !== null) { + throw $this->commitFailure; + } + + return parent::commit(); + } +} + class VersionedPDOStub extends PDOStub { public function __construct(private readonly string $serverVersion) diff --git a/tests/Database/DatabasePdoReconnectTest.php b/tests/Database/DatabasePdoReconnectTest.php new file mode 100644 index 0000000000..2dbe831134 --- /dev/null +++ b/tests/Database/DatabasePdoReconnectTest.php @@ -0,0 +1,45 @@ +markTestSkipped('Swoole emits a transport notice before PDO can report the lost connection.'); + } + + $process = new Process([ + PHP_BINARY, + __DIR__ . '/Fixtures/ReconnectAfterSocketReset.php', + dirname(__DIR__, 2) . '/vendor/autoload.php', + $hooked ? 'yes' : 'no', + ]); + $process->setTimeout(10); + $process->run(); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame( + ['result' => true, 'reconnects' => 1], + json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), + ); + } +} diff --git a/tests/Database/Fixtures/ReconnectAfterSocketReset.php b/tests/Database/Fixtures/ReconnectAfterSocketReset.php new file mode 100644 index 0000000000..00caa602b8 --- /dev/null +++ b/tests/Database/Fixtures/ReconnectAfterSocketReset.php @@ -0,0 +1,175 @@ +> 16) . "\x15" . str_repeat("\0", 10) + . "abcdefghijkl\0mysql_native_password\0", 0); + receivePacket($socket); + packet($socket, "\0\0\0\x02\0\0\0", 2); + + return $socket; +} + +$listener = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); +$address = stream_socket_get_name($listener, false); +$port = (int) substr(strrchr($address, ':'), 1); +[$parentControl, $childControl] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); +$child = pcntl_fork(); +if ($child === 0) { + fclose($parentControl); + $socket = null; + + try { + $socket = acceptConnection($listener); + stream_set_timeout($childControl, 3); + fread($childControl, 1); + socket_set_option(socket_import_stream($socket), SOL_SOCKET, SO_LINGER, ['l_onoff' => 1, 'l_linger' => 0]); + fclose($socket); + fwrite($childControl, 'x'); + + $socket = acceptConnection($listener); + + if (receivePacket($socket) !== "\x03SET @test = 1") { + throw new RuntimeException('The reconnect did not retry the expected statement.'); + } + + packet($socket, "\0\0\0\x02\0\0\0", 1); + receivePacket($socket); // COM_QUIT after the successful retry. + } finally { + if (is_resource($socket)) { + fclose($socket); + } + + fclose($childControl); + fclose($listener); + } + + exit(0); +} + +fclose($childControl); +fclose($listener); +$hooked = $argv[2] === 'yes'; +Runtime::enableCoroutine($hooked ? SWOOLE_HOOK_ALL : 0); + +$execute = static function () use ($port, $parentControl): void { + $connect = static fn (): PDO => new PDO("mysql:host=127.0.0.1;port={$port}", 'user', '', [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::ATTR_TIMEOUT => 3, + ]); + $connection = new PdoConnection($connect()); + $reconnects = 0; + $connection->setReconnector(static function (PdoConnection $connection) use ($connect, &$reconnects): void { + ++$reconnects; + $connection->disconnect(); + $connection->setPdo($connect()); + }); + + set_error_handler(static function (int $severity, string $message, string $file, int $line): void { + if (error_reporting() & $severity) { + throw new ErrorException($message, 0, $severity, $file, $line); + } + }); + + try { + fwrite($parentControl, 'x'); + stream_set_timeout($parentControl, 3); + + if (fread($parentControl, 1) !== 'x') { + throw new RuntimeException('The server did not reset the connection.'); + } + + $result = $connection->statement('SET @test = 1'); + $connection->disconnect(); + + echo json_encode(['result' => $result, 'reconnects' => $reconnects], JSON_THROW_ON_ERROR); + } finally { + restore_error_handler(); + @$connection->disconnect(); + } +}; + +try { + $failure = null; + run(static function () use ($execute, &$failure): void { + try { + $execute(); + } catch (Throwable $exception) { + $failure = $exception; + } + }); + + if ($failure !== null) { + throw $failure; + } + + pcntl_waitpid($child, $status); + + if (! pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + throw new RuntimeException('The loopback server failed.'); + } +} finally { + fclose($parentControl); + + if (pcntl_waitpid($child, $status, WNOHANG) === 0) { + posix_kill($child, SIGKILL); + pcntl_waitpid($child, $status); + } +} diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index 14f4a1165e..ac9f945f12 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -247,8 +247,16 @@ public function testIntegerBackedEnumConnectionNamesAreNormalizedInTestingAndPoo $resolver = $this->app->make(DatabaseConnectionResolver::class); $this->assertSame($resolver->connection(), $resolver->connection('')); - $this->assertSame('0', $resolver->connection(DatabaseTestingConnectionName::Zero)->getName()); - $this->assertSame('1', $resolver->connection(DatabaseTestingConnectionName::One)->getName()); + $this->assertSame($resolver->connection(), $resolver->getHeldConnection()); + $this->assertSame($resolver->getHeldConnection(), $resolver->getHeldConnection('')); + + $zero = $resolver->connection(DatabaseTestingConnectionName::Zero); + $one = $resolver->connection(DatabaseTestingConnectionName::One); + + $this->assertSame('0', $zero->getName()); + $this->assertSame('1', $one->getName()); + $this->assertSame($zero, $resolver->getHeldConnection(DatabaseTestingConnectionName::Zero)); + $this->assertSame($one, $resolver->getHeldConnection(DatabaseTestingConnectionName::One)); } public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): void @@ -259,6 +267,7 @@ public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): v config(['database.connections.testing.database' => $database]); $resolver = $this->app->make(DatabaseConnectionResolver::class); $retained = $resolver->connection(); + $this->assertSame($retained, $resolver->getHeldConnection()); $pool = $this->app->make(PoolManager::class)->pool('testing'); $borrowed = $pool->getBorrowedCount(); $retained->beginTransaction(); @@ -267,6 +276,7 @@ public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): v $this->assertTrue($resolver->hasApplicableTransaction()); config(['database.connections.testing.pool.testing_enabled' => true]); $this->assertFalse($resolver->hasApplicableTransaction()); + $this->assertNull($resolver->getHeldConnection()); $this->assertSame($borrowed, $pool->getBorrowedCount()); } finally { $retained->rollBack(); @@ -274,6 +284,8 @@ public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): v $pooled = $resolver->connection(); $this->assertNotSame($retained, $pooled); + $this->assertSame($pooled, $resolver->getHeldConnection()); + $this->assertSame($retained, $resolver->getResolvedConnection('testing')); $pooled->beginTransaction(); try { diff --git a/tests/Integration/Foundation/Http/Middleware/PreventStaleDatabaseReadsTest.php b/tests/Integration/Foundation/Http/Middleware/PreventStaleDatabaseReadsTest.php new file mode 100644 index 0000000000..e12e0c790e --- /dev/null +++ b/tests/Integration/Foundation/Http/Middleware/PreventStaleDatabaseReadsTest.php @@ -0,0 +1,188 @@ +directory = ParallelTesting::tempDir('PreventStaleDatabaseReadsTest'); + $files = $app->make('files'); + $files->deleteDirectory($this->directory); + $files->ensureDirectoryExists($this->directory); + $files->put($this->directory . '/read.sqlite', ''); + $files->put($this->directory . '/write.sqlite', ''); + + // In-memory SQLite deliberately shares one PDO; distinct files exercise read routing. + $connection = [ + 'driver' => 'sqlite', + 'database' => $this->directory . '/write.sqlite', + 'read' => ['database' => $this->directory . '/read.sqlite'], + 'write' => ['database' => $this->directory . '/write.sqlite'], + ]; + + $app->make('config')->set([ + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.other' => $connection, + 'session.driver' => 'array', + ]); + } + + protected function defineRoutes(Router $router): void + { + $router->middleware([StartSession::class, PreventStaleDatabaseReads::class])->group(function (Router $router): void { + $router->get('read', fn () => DB::select('select 1')); + $router->post('write', fn () => DB::statement('select 1')); + $router->get('no-query', fn () => (string) $this->app->make(PoolManager::class)->pool('testing')->getBorrowedCount()); + $router->post('change-default', function (): string { + DB::statement('select 1'); + DB::setDefaultConnection('other'); + + return 'ok'; + }); + }); + + $router->middleware([StartSession::class, PreventStaleDatabaseReads::for(10)])->group(function (Router $router): void { + $router->get('read-later', fn () => DB::select('select 1')); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->freezeTime(); + } + + protected function tearDown(): void + { + try { + parent::tearDown(); + } finally { + (new Filesystem)->deleteDirectory($this->directory); + } + } + + public function testReadsUseTheReadConnectionWithoutARecentWrite(): void + { + DB::enableQueryLog(); + + $this->get('read')->assertOk(); + + $this->assertSame('read', DB::getQueryLog()[0]['readWriteType']); + } + + public function testReadsUseTheWriteConnectionAfterAWrite(): void + { + $this->assertNull(DB::getHeldConnection()); + + $this->post('write')->assertOk() + ->assertSessionHas('_database_modified_at', Date::now()->getTimestamp()); + + DB::forgetRecordModificationState(); + DB::enableQueryLog(); + + $this->travel(2)->seconds(); + + $this->get('read')->assertOk(); + + $this->assertSame('write', DB::getQueryLog()[0]['readWriteType']); + } + + public function testReadsUseTheReadConnectionOnceTheDelayHasPassed(): void + { + $this->post('write')->assertOk(); + + DB::forgetRecordModificationState(); + DB::enableQueryLog(); + + $this->travel(3)->seconds(); + + $this->get('read')->assertOk(); + + $this->assertSame('read', DB::getQueryLog()[0]['readWriteType']); + } + + public function testTheDelayCanBeCustomized(): void + { + $this->post('write')->assertOk(); + + DB::forgetRecordModificationState(); + DB::enableQueryLog(); + + $this->travel(10)->seconds(); + + $this->get('read-later')->assertOk(); + + $this->assertSame('write', DB::getQueryLog()[0]['readWriteType']); + } + + public function testTheConnectionStopsBeingStickyOnceTheDelayHasPassed(): void + { + $this->post('write')->assertOk(); + + DB::forgetRecordModificationState(); + + $this->get('read')->assertOk(); + + DB::enableQueryLog(); + + $this->travel(3)->seconds(); + + $this->get('read')->assertOk(); + + $this->assertSame('read', DB::getQueryLog()[0]['readWriteType']); + } + + #[WithConfig('database.connections.testing.pool.testing_enabled', true)] + public function testRequestsWithoutQueriesDoNotBorrowAConnection(): void + { + $pool = $this->app->make(PoolManager::class)->pool('testing'); + + $this->get('no-query')->assertOk()->assertContent('0'); + + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertNull(DB::getHeldConnection()); + } + + #[WithConfig('database.connections.testing.pool.testing_enabled', true)] + public function testWritesAreTrackedOnTheOriginalDefaultConnection(): void + { + $this->post('change-default')->assertOk() + ->assertSessionHas('_database_modified_at', Date::now()->getTimestamp()); + } + + #[WithConfig('database.connections.testing.pool.testing_enabled', true)] + public function testAnAlreadyHeldConnectionStopsUsingTheWriterWithoutARecentWrite(): void + { + $connection = DB::connection(); + $connection->useWriteConnectionWhenReading(); + $connection->enableQueryLog(); + $request = Request::create('/read'); + $request->setHypervelSession($this->app->make('session')->driver()); + + $this->app->make(PreventStaleDatabaseReads::class)->handle($request, function () use ($connection): array { + return $connection->select('select 1'); + }); + + $this->assertSame('read', $connection->getQueryLog()[0]['readWriteType']); + } +} From 219a52c347ce5f1ca1dd403de119bdb2e20b4d4d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:30 +0000 Subject: [PATCH 03/22] Add JPEG XL image encoding and dimension support Port Laravel PRs #62019 (f43d77daf287c75b7244e7e26c4b800970451d5e) and #62151 (6490f9d830edce276a795ae82289373567efaeeb) from framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. Add JXL output, format metadata and dimension handling while retaining image immutability, cancellation and driver cleanup. Require Intervention Image 4.2 for the encoder support and document its usage. Affected image tests, dependency platform checks, source/type analysis and formatting pass. Native JXL encoding cases retain their existing codec-availability skips on this machine. --- composer.json | 2 +- src/docs/images.md | 7 +- src/image/composer.json | 4 +- src/image/src/Drivers/InterventionDriver.php | 6 +- src/image/src/Image.php | 15 ++++- src/image/src/ImageOutputOptions.php | 2 +- tests/Image/Drivers/ImagickDriverTest.php | 37 +++++++++++ tests/Image/ImageTest.php | 68 ++++++++++++++++++++ 8 files changed, 130 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index 0f1a215259..52bd58207e 100644 --- a/composer.json +++ b/composer.json @@ -240,7 +240,7 @@ }, "conflict": { "aws/aws-sdk-php": "<3.369.1", - "intervention/image": "<4.0 || >=5.0", + "intervention/image": "<4.2 || >=5.0", "league/flysystem-ftp": "<3.13", "league/flysystem-sftp-v3": "<3.13" }, diff --git a/src/docs/images.md b/src/docs/images.md index a073580edc..8d8600441a 100644 --- a/src/docs/images.md +++ b/src/docs/images.md @@ -51,7 +51,7 @@ composer require hypervel/image If you will use the bundled GD or Imagick driver, also install Intervention Image: ```shell -composer require intervention/image:^4.0 +composer require intervention/image:^4.2 ``` You should also ensure your PHP installation has the matching GD or Imagick extension. A custom driver does not require Intervention Image or either bundled extension. @@ -251,10 +251,13 @@ $image = $image->toPng(); $image = $image->toGif(); $image = $image->toAvif(); $image = $image->toHeic(); +$image = $image->toJxl(); $image = $image->toBmp(); ``` -The public `toFormat` method accepts `webp`, `jpg`, `jpeg`, `png`, `gif`, `avif`, `heic`, `heif`, and `bmp`. The `heif` spelling is normalized to `heic`: +JPEG XL (`jxl`) requires the Imagick driver with JPEG XL support enabled in ImageMagick. + +The public `toFormat` method accepts `webp`, `jpg`, `jpeg`, `png`, `gif`, `avif`, `heic`, `heif`, `jxl`, and `bmp`. The `heif` spelling is normalized to `heic`: ```php $image = $image->toFormat('heif'); diff --git a/src/image/composer.json b/src/image/composer.json index 96bc89a145..aa88dfc8ec 100644 --- a/src/image/composer.json +++ b/src/image/composer.json @@ -44,10 +44,10 @@ "suggest": { "ext-gd": "Required to use the GD image driver.", "ext-imagick": "Required to use the Imagick image driver.", - "intervention/image": "Required to use the GD and Imagick image drivers (^4.0)." + "intervention/image": "Required to use the GD and Imagick image drivers (^4.2)." }, "conflict": { - "intervention/image": "<4.0 || >=5.0" + "intervention/image": "<4.2 || >=5.0" }, "config": { "sort-packages": true diff --git a/src/image/src/Drivers/InterventionDriver.php b/src/image/src/Drivers/InterventionDriver.php index 3664c1b537..f41ca43391 100644 --- a/src/image/src/Drivers/InterventionDriver.php +++ b/src/image/src/Drivers/InterventionDriver.php @@ -28,6 +28,7 @@ use Intervention\Image\Encoders\GifEncoder; use Intervention\Image\Encoders\HeicEncoder; use Intervention\Image\Encoders\JpegEncoder; +use Intervention\Image\Encoders\JxlEncoder; use Intervention\Image\Encoders\MediaTypeEncoder; use Intervention\Image\Encoders\PngEncoder; use Intervention\Image\Encoders\WebpEncoder; @@ -73,7 +74,7 @@ public function ensureRequirementsAreMet(): void if (! class_exists(ImageManager::class)) { throw new ImageException( 'Intervention Image is required to use this driver. ' - . 'You may install it via: composer require intervention/image:^4.0', + . 'You may install it via: composer require intervention/image:^4.2', ); } } @@ -85,7 +86,7 @@ public function process(string $contents, ImagePipeline $pipeline): string { $mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents); - if (! in_array($mimeType, ['image/jpeg', 'image/png', 'image/bmp', 'image/gif', 'image/webp', 'image/avif', 'image/x-avif', 'image/heic', 'image/x-heic', 'image/heif'], true)) { + if (! in_array($mimeType, ['image/jpeg', 'image/png', 'image/bmp', 'image/gif', 'image/webp', 'image/avif', 'image/x-avif', 'image/heic', 'image/x-heic', 'image/heif', 'image/jxl', 'image/x-jxl'], true)) { throw new ImageException("The image format [{$mimeType}] is not supported."); } @@ -133,6 +134,7 @@ public function process(string $contents, ImagePipeline $pipeline): string 'gif' => new GifEncoder, 'avif' => new AvifEncoder($quality), 'heic' => new HeicEncoder($quality), + 'jxl' => new JxlEncoder($quality), 'bmp' => new BmpEncoder, })->toString(); } diff --git a/src/image/src/Image.php b/src/image/src/Image.php index 58779cfbc4..b5742e7862 100644 --- a/src/image/src/Image.php +++ b/src/image/src/Image.php @@ -377,6 +377,14 @@ public function toHeic(): static return $this->toFormat('heic'); } + /** + * Convert the image to JPEG XL format. + */ + public function toJxl(): static + { + return $this->toFormat('jxl'); + } + /** * Convert the image to BMP format. */ @@ -392,7 +400,7 @@ public function toBmp(): static */ public function toFormat(string $format): static { - if (! in_array($format, ['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif', 'heic', 'heif', 'bmp'], true)) { + if (! in_array($format, ['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif', 'heic', 'heif', 'jxl', 'bmp'], true)) { throw new ImageException("The [{$format}] format is not supported."); } @@ -527,6 +535,7 @@ public function extension(): string 'image/webp' => 'webp', 'image/avif', 'image/x-avif' => 'avif', 'image/heic', 'image/x-heic', 'image/heif' => 'heic', + 'image/jxl', 'image/x-jxl' => 'jxl', 'image/bmp' => 'bmp', 'image/svg+xml' => 'svg', 'image/tiff' => 'tiff', @@ -557,8 +566,8 @@ public function dimensions(): array $contents = $this->toBytes(); - // getimagesizefromstring() misreports HEIC's coded / padded frame size, so HEIC dimensions belong to the selected driver. - if (in_array($this->mimeType(), ['image/heic', 'image/heif', 'image/x-heic'], true)) { + // getimagesizefromstring() misreports HEIC's coded / padded frame size and cannot read JXL, so use the selected driver. + if (in_array($this->mimeType(), ['image/heic', 'image/heif', 'image/x-heic', 'image/jxl', 'image/x-jxl'], true)) { $driver = $this->resolveDriver(); try { diff --git a/src/image/src/ImageOutputOptions.php b/src/image/src/ImageOutputOptions.php index 3d3368cf5f..0549fc32b9 100644 --- a/src/image/src/ImageOutputOptions.php +++ b/src/image/src/ImageOutputOptions.php @@ -14,7 +14,7 @@ class ImageOutputOptions /** * The output format. * - * @var null|'avif'|'bmp'|'gif'|'heic'|'jpeg'|'jpg'|'png'|'webp' + * @var null|'avif'|'bmp'|'gif'|'heic'|'jpeg'|'jpg'|'jxl'|'png'|'webp' */ public ?string $format = null; diff --git a/tests/Image/Drivers/ImagickDriverTest.php b/tests/Image/Drivers/ImagickDriverTest.php index b8d5cb0f69..2f3598e28c 100644 --- a/tests/Image/Drivers/ImagickDriverTest.php +++ b/tests/Image/Drivers/ImagickDriverTest.php @@ -154,6 +154,43 @@ public function testProcessesHeicInput(): void $this->assertSame([50, 25], array_slice(getimagesizefromstring($result), 0, 2)); } + public function testProcessesOptimizeToJxl(): void + { + $this->ensureImageFormatCanBeEncoded('jxl'); + + $driver = new ImagickDriver; + + $result = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'jxl')); + + $header = substr($result, 0, 16); + + $this->assertTrue( + str_starts_with($result, "\xff\x0a") || str_contains($header, 'JXL '), + 'Expected a JPEG XL codestream or container signature.' + ); + } + + public function testProcessesJxlInput(): void + { + $this->ensureImageFormatCanBeEncoded('jxl'); + + $driver = new ImagickDriver; + $contents = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'jxl')); + + $result = $driver->process($contents, $this->pipeline(new Cover(50, 25))); + + $header = substr($result, 0, 16); + + $this->assertTrue( + str_starts_with($result, "\xff\x0a") || str_contains($header, 'JXL '), + 'Expected a JPEG XL codestream or container signature.' + ); + + $result = $driver->process($result, $this->pipeline(format: 'jpg')); + + $this->assertSame([50, 25], array_slice(getimagesizefromstring($result), 0, 2)); + } + public function testDimensionsReturnsTheDecodedSize(): void { $driver = new ImagickDriver; diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index d67e2403b7..b337d96aab 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -33,9 +33,11 @@ use Hypervel\Image\Transformations\Scale; use Hypervel\Image\Transformations\Sharpen; use Hypervel\Tests\TestCase; +use Imagick; use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use ReflectionProperty; use RuntimeException; use Stringable; @@ -174,6 +176,13 @@ public function testToHeicReturnsNewInstance(): void $this->assertNotSame($image, $image->toHeic()); } + public function testToJxlReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toJxl()); + } + public function testToBmpReturnsNewInstance(): void { $image = $this->makeImage(); @@ -343,6 +352,35 @@ public function testExtensionReturnsAvifForAvif(): void $this->assertSame('avif', $image->extension()); } + #[RequiresPhpExtension('imagick')] + public function testExtensionReturnsJxlForJxl(): void + { + if (Imagick::queryFormats('JXL') === []) { + $this->markTestSkipped('The Imagick extension was not compiled with jxl support.'); + } + + $imagick = new Imagick; + + try { + $imagick->newImage(10, 10, 'red'); + $imagick->setImageFormat('jxl'); + $contents = $imagick->getImageBlob(); + } finally { + $imagick->clear(); + $imagick->destroy(); + } + + $mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents); + + if (! in_array($mimeType, ['image/jxl', 'image/x-jxl'], true)) { + $this->markTestSkipped('The installed fileinfo database does not recognize JXL.'); + } + + $image = new Image($contents); + + $this->assertSame('jxl', $image->extension()); + } + public function testDimensionsReturnsWidthAndHeight(): void { $image = new Image($this->fakeImageContents(300, 200)); @@ -402,6 +440,21 @@ public function transformUsing(string $transformation, callable $callback): stat } } + public function testDimensionsDefersToTheDriverForJxlImages(): void + { + // A JXL signature is recognized by fileinfo but cannot be read by getimagesizefromstring(). + $contents = "\xff\x0a\x00\x00"; + $driver = m::mock(Driver::class); + $driver->expects('dimensions')->once()->with($contents)->andReturn([123, 45]); + $this->registerDrivers(['fake' => $driver]); + + $image = (new Image($contents))->using('fake'); + + $this->assertSame([123, 45], $image->dimensions()); + $this->assertSame(123, $image->width()); + $this->assertSame(45, $image->height()); + } + public function testDimensionsPreservesDriverFailureForHeic(): void { $driverException = new ImageException('The driver cannot decode this image.'); @@ -709,6 +762,13 @@ public function testToHeicSetsFormat(): void $this->assertSame('heic', $this->getOptions($image->toHeic())->format); } + public function testToJxlSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('jxl', $this->getOptions($image->toJxl())->format); + } + public function testToBmpSetsFormat(): void { $image = $this->makeImage(); @@ -729,6 +789,7 @@ public function testToFormatSupportsEveryPublicFormatSpelling(): void 'avif' => 'avif', 'heic' => 'heic', 'heif' => 'heic', + 'jxl' => 'jxl', 'bmp' => 'bmp', ] as $format => $expected) { $this->assertSame($expected, $this->getOptions($image->toFormat($format))->format); @@ -1374,6 +1435,13 @@ public function testOptimizeAllowsHeic(): void $this->assertSame('heic', $this->getOptions($result)->format); } + public function testOptimizeAllowsJxl(): void + { + $result = $this->makeImage()->optimize('jxl'); + + $this->assertSame('jxl', $this->getOptions($result)->format); + } + public function testOptimizeNormalizesHeifToHeic(): void { $result = $this->makeImage()->optimize('heif'); From 7521919bac3ec0342013f0970a113c9b5b9e7188 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:30 +0000 Subject: [PATCH 04/22] Support strict numeric validation rules Port Laravel PR #62031, commit 9bfc37320ae3ffa45ce29ac6b8bc97d576ec7331, using the frozen framework 13.x target. Allow Rule::numeric to opt into strict numeric validation, preserve the structured builder representation, and document the option. Port all affected numeric rule cases. ValidationNumericRuleTest, source/type analysis and formatting pass. --- src/docs/validation.md | 2 +- src/validation/src/Rule.php | 4 ++-- src/validation/src/Rules/Numeric.php | 10 +++++++++- tests/Validation/ValidationNumericRuleTest.php | 9 +++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index bd11da5b27..acfa656ad5 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -2626,7 +2626,7 @@ use Hypervel\Validation\Rule; ], ``` -The fluent numeric builder also provides `integer`, `min`, `max`, `greaterThan`, `greaterThanOrEqualTo`, `lessThan`, `lessThanOrEqualTo`, `digits`, `digitsBetween`, `minDigits`, `maxDigits`, `multipleOf`, `same`, `different`, and `exactly` methods. Pass `strict: true` to the `integer` method to apply the [strict integer rule](#rule-integer). +Pass `strict: true` to `Rule::numeric()` to reject numeric strings. The fluent numeric builder also provides `integer`, `min`, `max`, `greaterThan`, `greaterThanOrEqualTo`, `lessThan`, `lessThanOrEqualTo`, `digits`, `digitsBetween`, `minDigits`, `maxDigits`, `multipleOf`, `same`, `different`, and `exactly` methods. Pass `strict: true` to the `integer` method to apply the [strict integer rule](#rule-integer). #### present diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php index c34b7c8bdc..8ac0983504 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -715,9 +715,9 @@ public static function string(): StringRule /** * Get a numeric rule builder instance. */ - public static function numeric(): Numeric + public static function numeric(bool $strict = false): Numeric { - return new Numeric; + return new Numeric(strict: $strict); } /** diff --git a/src/validation/src/Rules/Numeric.php b/src/validation/src/Rules/Numeric.php index 875d2bd52e..697c3bbf1f 100644 --- a/src/validation/src/Rules/Numeric.php +++ b/src/validation/src/Rules/Numeric.php @@ -15,7 +15,15 @@ class Numeric implements Stringable /** * The constraints for the number rule. */ - protected array $constraints = ['numeric']; + protected array $constraints = []; + + /** + * Create a numeric rule instance. + */ + public function __construct(bool $strict = false) + { + $this->addRule($strict ? 'numeric:strict' : 'numeric'); + } /** * The field under validation must have a size between the given min and max (inclusive). diff --git a/tests/Validation/ValidationNumericRuleTest.php b/tests/Validation/ValidationNumericRuleTest.php index b8dd2b18f1..836de19f85 100644 --- a/tests/Validation/ValidationNumericRuleTest.php +++ b/tests/Validation/ValidationNumericRuleTest.php @@ -23,6 +23,15 @@ public function testDefaultNumericRule(): void $this->assertSame('numeric', (string) $rule); } + public function testStrictRule(): void + { + $rule = Rule::numeric(strict: true); + $this->assertSame('numeric:strict', (string) $rule); + + $rule = new Numeric(strict: true); + $this->assertSame('numeric:strict', (string) $rule); + } + public function testBetweenRule(): void { $rule = Rule::numeric()->between(1, 10); From f73e7fbc5f16f5e215c73388045e9ac7cfe2be41 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:30 +0000 Subject: [PATCH 05/22] Remove the unavailable higher-order until proxy Port Laravel PR #62057, commit 8998ef45559600cb5aa655fa3144985eaa1a3610, using the frozen framework 13.x target. Remove the obsolete until entry from collection higher-order dispatch and its annotations. Remove the matching static-analysis expectations for eager and lazy collections. Collection coverage and the complete affected type fixtures pass, with source analysis and formatting clean. --- src/collections/src/Traits/EnumeratesValues.php | 2 -- types/Collections/Collection.php | 1 - types/Collections/LazyCollection.php | 1 - 3 files changed, 4 deletions(-) diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 0afed69226..70debc67ba 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -59,7 +59,6 @@ * @property-read HigherOrderCollectionProxy<'takeWhile', TValue, static> $takeWhile * @property-read HigherOrderCollectionProxy<'unique', TValue, static> $unique * @property-read HigherOrderCollectionProxy<'unless', TValue, static> $unless - * @property-read HigherOrderCollectionProxy<'until', TValue, static> $until * @property-read HigherOrderCollectionProxy<'when', TValue, static> $when */ trait EnumeratesValues @@ -106,7 +105,6 @@ trait EnumeratesValues 'takeWhile', 'unique', 'unless', - 'until', 'when', ]; diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 87f9a73bc7..52bb5d50c4 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -1257,7 +1257,6 @@ public function getWithoutZebras(): Collection assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeWhile', Animal, Hypervel\\Support\\Collection>", $coll->takeWhile); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unique', Animal, Hypervel\\Support\\Collection>", $coll->unique); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unless', Animal, Hypervel\\Support\\Collection>", $coll->unless); -assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'until', Animal, Hypervel\\Support\\Collection>", $coll->until); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'when', Animal, Hypervel\\Support\\Collection>", $coll->when); enum Digit diff --git a/types/Collections/LazyCollection.php b/types/Collections/LazyCollection.php index e62a6557b9..5e360bf865 100644 --- a/types/Collections/LazyCollection.php +++ b/types/Collections/LazyCollection.php @@ -1045,5 +1045,4 @@ public function getWithoutZebras(): LazyCollection assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeWhile', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->takeWhile); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unique', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->unique); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unless', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->unless); -assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'until', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->until); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'when', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->when); From 18e6b386c236aba25c18b55753d36704c4b5e610 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:30 +0000 Subject: [PATCH 06/22] Name closure-defined one-of-many joins after the related table Port Laravel PR #62051, commit e0b81354b34b275da04d833d3aa097402b1c351c, using the frozen framework 13.x target. When a one-of-many relationship is defined in a closure, use the related table name instead of the closure stack-frame name for its inferred relationship alias. Include the corresponding upstream regression in DatabaseEloquentHasOneOfManyTest. The complete affected test file, source analysis and formatting pass. --- .../Relations/Concerns/CanBeOneOfMany.php | 4 +++- .../DatabaseEloquentHasOneOfManyTest.php | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php index 65baa2d5b2..ce9f0f410b 100644 --- a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php +++ b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -285,7 +285,9 @@ protected function qualifyRelatedColumn(string $column): string */ protected function guessRelationship(): string { - return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + $relation = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + + return str_contains($relation, '{closure') ? $this->query->getModel()->getTable() : $relation; } /** diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php index 6535c8e2f1..0ee24b3426 100755 --- a/tests/Database/DatabaseEloquentHasOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -103,6 +103,22 @@ public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationNam $this->assertSame('logins_of_many', $model->logins()->getRelationName()); } + public function testItUsesTheTableNameWhenOfManyIsCalledInsideAClosure(): void + { + User::resolveRelationUsing('first_login_from_closure', function (User $user): HasOne { + return $user->hasOne(Login::class, 'user_id')->ofMany('id', 'min'); + }); + + $user = User::create(); + $first = $user->logins()->create(); + $user->logins()->create(); + + $this->assertTrue($first->is($user->first_login_from_closure)); + $this->assertTrue($first->is(User::with('first_login_from_closure')->first()->first_login_from_closure)); + $this->assertCount(1, User::has('first_login_from_closure')->get()); + $this->assertSame('logins_of_many', $user->first_login_from_closure()->getRelationName()); + } + public function testRelationNameCanBeSet(): void { $user = User::create(); From 1c2ac5a1293f372354a25d4537386d86086e7c61 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:46 +0000 Subject: [PATCH 07/22] Use the upstream HealthCheck endpoint Port Laravel PR #62049, commit e51b5fe16d6f0e547137087e52c6106118c319f1, using the frozen framework 13.x target. Replace HealthCheckController with the constructor-free HealthCheck endpoint and update application routing and Workbench. Preserve JSON and HTML responses, request-local view data, cancellation propagation and diagnosis error reporting. Resolve the dispatcher at invocation so an earlier request cannot pin it across event fake changes. Health, route-provider and Workbench tests pass, including cacheable routing and fake-after-first-request regressions. Package-mode Testbench, source/type analysis and formatting pass. --- .../src/Configuration/ApplicationBuilder.php | 4 +- src/foundation/src/Http/HealthCheck.php | 53 +++++++ .../src/Http/HealthCheckController.php | 71 --------- src/testbench/src/Workbench/Workbench.php | 4 +- .../Http/HealthCheckControllerTest.php | 145 ------------------ tests/Foundation/Http/HealthCheckTest.php | 78 ++++++++++ .../RouteServiceProviderHealthTest.php | 35 ++++- 7 files changed, 167 insertions(+), 223 deletions(-) create mode 100644 src/foundation/src/Http/HealthCheck.php delete mode 100644 src/foundation/src/Http/HealthCheckController.php delete mode 100644 tests/Foundation/Http/HealthCheckControllerTest.php create mode 100644 tests/Foundation/Http/HealthCheckTest.php diff --git a/src/foundation/src/Configuration/ApplicationBuilder.php b/src/foundation/src/Configuration/ApplicationBuilder.php index cec33fc13e..6ef826b47b 100644 --- a/src/foundation/src/Configuration/ApplicationBuilder.php +++ b/src/foundation/src/Configuration/ApplicationBuilder.php @@ -13,7 +13,7 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\RegisterProviders; use Hypervel\Foundation\Exceptions\Handler; -use Hypervel\Foundation\Http\HealthCheckController; +use Hypervel\Foundation\Http\HealthCheck; use Hypervel\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Hypervel\Foundation\Support\Providers\EventServiceProvider as AppEventServiceProvider; use Hypervel\Foundation\Support\Providers\RouteServiceProvider as AppRouteServiceProvider; @@ -188,7 +188,7 @@ protected function buildRoutingCallback( } if (is_string($health)) { - Route::get($health, HealthCheckController::class); + Route::get($health, HealthCheck::class); } if (is_string($web) || is_array($web)) { diff --git a/src/foundation/src/Http/HealthCheck.php b/src/foundation/src/Http/HealthCheck.php new file mode 100644 index 0000000000..9d0823b1d4 --- /dev/null +++ b/src/foundation/src/Http/HealthCheck.php @@ -0,0 +1,53 @@ +hasDebugModeEnabled()) { + throw $throwable; + } + + report($throwable); + + $health = 'down'; + } + + $status = $health === 'up' ? 200 : 500; + + if ($request->expectsJson()) { + return response()->json(['status' => $health], $status); + } + + return response(View::file( + __DIR__ . '/../resources/health-up.blade.php', + ['request' => $request, 'status' => $health], + ), status: $status); + } +} diff --git a/src/foundation/src/Http/HealthCheckController.php b/src/foundation/src/Http/HealthCheckController.php deleted file mode 100644 index c1d1e4f966..0000000000 --- a/src/foundation/src/Http/HealthCheckController.php +++ /dev/null @@ -1,71 +0,0 @@ -events->hasListeners(DiagnosingHealth::class)) { - $this->events->dispatch(new DiagnosingHealth); - } - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable $throwable) { - if ($this->app->hasDebugModeEnabled()) { - throw $throwable; - } - - $this->exceptions->report($throwable); - - $health = 'down'; - } - - $status = $health === 'up' ? 200 : 500; - - if ($request->expectsJson()) { - return $this->responses->json(['status' => $health], $status); - } - - return $this->responses->make($this->views->file( - __DIR__ . '/../resources/health-up.blade.php', - ['request' => $request, 'status' => $health], - ), $status); - } -} diff --git a/src/testbench/src/Workbench/Workbench.php b/src/testbench/src/Workbench/Workbench.php index edec3588db..878737ffff 100644 --- a/src/testbench/src/Workbench/Workbench.php +++ b/src/testbench/src/Workbench/Workbench.php @@ -9,7 +9,7 @@ use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Factories\Factory; -use Hypervel\Foundation\Http\HealthCheckController; +use Hypervel\Foundation\Http\HealthCheck; use Hypervel\Routing\Router; use Hypervel\Support\Collection; use Hypervel\Support\Env; @@ -127,7 +127,7 @@ public static function discoverRoutes(ApplicationContract $app, ConfigContract $ } if ($healthCheckEnabled === true) { - $router->get('/up', HealthCheckController::class); + $router->get('/up', HealthCheck::class); } if ($discoversConfig['web'] === true) { diff --git a/tests/Foundation/Http/HealthCheckControllerTest.php b/tests/Foundation/Http/HealthCheckControllerTest.php deleted file mode 100644 index 95d240d1cd..0000000000 --- a/tests/Foundation/Http/HealthCheckControllerTest.php +++ /dev/null @@ -1,145 +0,0 @@ -shouldNotReceive('hasDebugModeEnabled'); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnFalse(); - $events->shouldNotReceive('dispatch'); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->shouldNotReceive('report'); - $response = new JsonResponse(['status' => 'up'], 200); - $responses = m::mock(ResponseFactory::class); - $responses->expects('json')->with(['status' => 'up'], 200)->andReturn($response); - $responses->shouldNotReceive('make'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - $this->assertSame($response, $controller(Request::create('/up', server: [ - 'HTTP_ACCEPT' => 'application/json', - ]))); - } - - public function testDiagnosisFailureIsReportedAndReturnsTheDownResponse(): void - { - $failure = new RuntimeException('Database connection refused.'); - $application = m::mock(Application::class); - $application->expects('hasDebugModeEnabled')->andReturnFalse(); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnTrue(); - $events->expects('dispatch') - ->with(m::type(DiagnosingHealth::class)) - ->andThrow($failure); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->expects('report')->with($failure); - $response = new JsonResponse(['status' => 'down'], 500); - $responses = m::mock(ResponseFactory::class); - $responses->expects('json')->with(['status' => 'down'], 500)->andReturn($response); - $responses->shouldNotReceive('make'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - $this->assertSame($response, $controller(Request::create('/up', server: [ - 'HTTP_ACCEPT' => 'application/json', - ]))); - } - - public function testDiagnosisFailureEscapesWithoutReportingOrRenderingInDebugMode(): void - { - $failure = new RuntimeException('Database connection refused.'); - $application = m::mock(Application::class); - $application->expects('hasDebugModeEnabled')->andReturnTrue(); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnTrue(); - $events->expects('dispatch') - ->with(m::type(DiagnosingHealth::class)) - ->andThrow($failure); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->shouldNotReceive('report'); - $responses = m::mock(ResponseFactory::class); - $responses->shouldNotReceive('make', 'json'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - try { - $controller(Request::create('/up')); - $this->fail('Expected the health diagnosis failure to escape in debug mode.'); - } catch (RuntimeException $exception) { - $this->assertSame($failure, $exception); - } - } - - public function testDiagnosisCancellationEscapesWithoutReportingOrRendering(): void - { - $cancellation = new CanceledException('cancelled'); - $application = m::mock(Application::class); - $application->shouldNotReceive('hasDebugModeEnabled'); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnTrue(); - $events->expects('dispatch') - ->with(m::type(DiagnosingHealth::class)) - ->andThrow($cancellation); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->shouldNotReceive('report'); - $responses = m::mock(ResponseFactory::class); - $responses->shouldNotReceive('make', 'json'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - try { - $controller(Request::create('/up')); - $this->fail('Expected health diagnosis to preserve cancellation.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); - } - } -} diff --git a/tests/Foundation/Http/HealthCheckTest.php b/tests/Foundation/Http/HealthCheckTest.php new file mode 100644 index 0000000000..52c7e9e614 --- /dev/null +++ b/tests/Foundation/Http/HealthCheckTest.php @@ -0,0 +1,78 @@ +once()->with(DiagnosingHealth::class)->andReturnFalse(); + Event::shouldReceive('dispatch')->never(); + Exceptions::fake(); + + $response = (new HealthCheck)(Request::create('/up', server: [ + 'HTTP_ACCEPT' => 'application/json', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['status' => 'up'], $response->getData(true)); + Exceptions::assertNothingReported(); + } + + public function testDiagnosisFailureEscapesWithoutReportingOrRenderingInDebugMode(): void + { + $failure = new RuntimeException('Database connection refused.'); + config(['app.debug' => true]); + Event::listen(DiagnosingHealth::class, static fn () => throw $failure); + Exceptions::fake(); + $this->mock(ResponseFactory::class)->shouldNotReceive('make', 'json'); + $caught = null; + + try { + (new HealthCheck)(Request::create('/up')); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + Exceptions::assertNothingReported(); + } + + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cancellation through cleanup')] + public function testDiagnosisCancellationEscapesWithoutReportingOrRendering(bool $cleanupThrows): void + { + $cancellation = new CanceledException('cancelled'); + $failure = $cleanupThrows + ? new RuntimeException('Health probe cleanup failed.', previous: $cancellation) + : $cancellation; + config(['app.debug' => false]); + Event::listen(DiagnosingHealth::class, static fn () => throw $failure); + Exceptions::fake(); + $this->mock(ResponseFactory::class)->shouldNotReceive('make', 'json'); + $caught = null; + + try { + (new HealthCheck)(Request::create('/up')); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + Exceptions::assertNothingReported(); + } +} diff --git a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php index 3bb7752919..d08a382a60 100644 --- a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php +++ b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php @@ -7,9 +7,11 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; use Hypervel\Foundation\Events\DiagnosingHealth; +use Hypervel\Http\Request; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\Event; +use Hypervel\Support\Facades\Exceptions; use Hypervel\Support\Str; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; @@ -81,13 +83,15 @@ public function testItReturnsJsonWhenRequestExpectsJson(): void public function testItReturnsJsonFailureStatusWhenDiagnosisReportsAProblem(): void { - Event::listen(DiagnosingHealth::class, static function (): never { - throw new RuntimeException('Database connection refused.'); - }); + $failure = new RuntimeException('Database connection refused.'); + Exceptions::fake(); + Event::listen(DiagnosingHealth::class, static fn () => throw $failure); $this->getJson('/up') ->assertStatus(500) ->assertExactJson(['status' => 'down']); + + Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception === $failure); } public function testItRendersHtmlFailurePageWhenDiagnosisReportsAProblem(): void @@ -101,6 +105,31 @@ public function testItRendersHtmlFailurePageWhenDiagnosisReportsAProblem(): void ->assertSee('experiencing problems'); } + public function testHealthRouteCanBeCachedWithoutAbsolutePaths(): void + { + $route = $this->app->make('router')->getRoutes()->match(Request::create('/up')); + + $route->prepareForSerialization(); + + $this->assertStringNotContainsString( + realpath(__DIR__ . '/../../../../../src'), + $route->getAction('uses') + ); + + $this->get('/up')->assertOk(); + } + + public function testHealthRouteHonorsEventFakesAfterAnEarlierRequest(): void + { + $this->getJson('/up')->assertOk(); + + Event::fake([DiagnosingHealth::class]); + + $this->getJson('/up')->assertOk(); + + Event::assertDispatched(DiagnosingHealth::class); + } + public function testEmptyDiagnosisMessagesStillProduceFailureResponses(): void { Event::listen(DiagnosingHealth::class, static function (): never { From 9626e6163cd56c12f560134e8a24d641c9364d50 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:46 +0000 Subject: [PATCH 08/22] Consolidate notification failure event coverage Port Laravel PR #62074, commit 8a8401e9266c0e11688b1960df442586bd66e7ad, using the frozen framework 13.x target. Move the applicable sender failure-event assertions into NotificationSenderTest and remove the overlapping custom cases while retaining distinct integration coverage. Runtime behavior already matches the upstream fix. Both complete notification test files pass; formatting is clean. --- .../NotificationFailedEventTest.php | 41 ------------- .../Notifications/NotificationSenderTest.php | 59 +++++++++++++++++++ 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/tests/Notifications/NotificationFailedEventTest.php b/tests/Notifications/NotificationFailedEventTest.php index 2d15374d74..98588efef8 100644 --- a/tests/Notifications/NotificationFailedEventTest.php +++ b/tests/Notifications/NotificationFailedEventTest.php @@ -111,29 +111,6 @@ public function testSequentialAttemptsDoNotShareFailureState(): void $this->assertSame(['channel-owned', 'sender-owned'], $dispatched()); } - public function testReportedFailureWithoutAnExceptionDoesNotAffectTheNextAttempt(): void - { - $events = $this->app->make(Dispatcher::class); - $manager = $this->app->make(ChannelManager::class); - $notifiable = new NotificationFailedEventNotifiable; - $notification = new NotificationFailedEventNotification; - $dispatched = $this->recordFailures($events); - - $manager->extend('reported', fn () => new NotificationFailedEventReportingChannel($events)); - $manager->extend('throwing', fn () => new NotificationFailedEventThrowingChannel); - - $manager->sendNow($notifiable, $notification, ['reported']); - - // Check cleanup before another attempt can overwrite a leaked failure marker. - $this->assertNull(CoroutineContext::get(NotificationSender::FAILED_EVENT_DISPATCHED_CONTEXT_KEY)); - - $this->captureRuntimeException( - fn () => $manager->sendNow($notifiable, $notification, ['throwing']) - ); - - $this->assertSame(['reported', 'throwing'], $dispatched()); - } - public function testSuccessfulAndExceptionalAttemptsRemoveTheirContextState(): void { $manager = $this->app->make(ChannelManager::class); @@ -248,24 +225,6 @@ public function send(mixed $notifiable, Notification $notification): never } } -class NotificationFailedEventReportingChannel -{ - /** - * Create a channel that reports a failure without throwing. - */ - public function __construct(private readonly Dispatcher $events) - { - } - - /** - * Report the delivery failure without an exception. - */ - public function send(mixed $notifiable, Notification $notification): void - { - $this->events->dispatch(new NotificationFailed($notifiable, $notification, 'reported')); - } -} - class NotificationFailedEventSuccessfulChannel { /** diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 83f3680759..c0c0748668 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -9,10 +9,12 @@ use Hypervel\Bus\Queueable; use Hypervel\Config\Repository as Config; use Hypervel\Container\Container; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Events\Dispatcher as EventDispatcher; +use Hypervel\Foundation\Application; use Hypervel\Notifications\AnonymousNotifiable; use Hypervel\Notifications\ChannelManager; use Hypervel\Notifications\Events\NotificationDelivered; @@ -23,6 +25,7 @@ use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; +use Hypervel\Notifications\NotificationServiceProvider; use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue; @@ -551,6 +554,62 @@ public function testNotificationFailedSentWithoutHttpTransportException(): void $this->assertSame(TransportException::class, $failed->data['exception']::class); } + public function testNotificationFailedIsDispatchedAfterAChannelDispatchedItWithoutThrowing(): void + { + $notifiable = new AnonymousNotifiable; + $container = new Application; + $container->instance('config', new Config(['app' => ['name' => 'Name', 'logo' => 'Logo']])); + $manager = m::mock(ChannelManager::class . '[driver]', [$container]); + $bus = new BusFake(new BusDispatcher(new Container)); + $events = new EventDispatcher; + $container->instance('events', $events); + (new NotificationServiceProvider($container))->boot(); + + $failed = []; + $events->listen(NotificationFailed::class, function (NotificationFailed $event) use (&$failed): void { + $failed[] = $event->channel; + }); + + $pushChannel = new class($events) { + /** + * Create a channel that reports failures without throwing. + */ + public function __construct(protected Dispatcher $events) + { + } + + /** + * Send the notification. + */ + public function send(mixed $notifiable, Notification $notification): void + { + $this->events->dispatch(new NotificationFailed($notifiable, $notification, 'push')); + } + }; + + $smsChannel = new ChannelSpy; + $smsChannel->exception = new RuntimeException('SMS gateway down'); + + $manager->expects('driver')->with('push')->andReturn($pushChannel); + $manager->expects('driver')->with('sms')->andReturn($smsChannel); + + $sender = new NotificationSender($manager, $bus, $events); + $sender->sendNow($notifiable, new DummyNotificationWithViaConnections, ['push']); + + // Check cleanup before another attempt can overwrite a leaked failure marker. + $this->assertNull(CoroutineContext::get(NotificationSender::FAILED_EVENT_DISPATCHED_CONTEXT_KEY)); + $caught = null; + + try { + $sender->sendNow($notifiable, new DummyNotificationWithViaConnections, ['sms']); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($smsChannel->exception, $caught); + $this->assertSame(['push', 'sms'], $failed); + } + public function testItPreservesNotificationStateMutatedInViaMethod(): void { $notifiable = new AnonymousNotifiable; From d5452c16003e317b00e037c118015d5ea98c7f65 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:47 +0000 Subject: [PATCH 09/22] Cover quoted SQLite foreign-key targets during table changes Port the regression from Laravel PR #62084, commit 12be372474caf0cae30bf63c92950982a0f961e0, using the frozen framework 13.x target. Verify that altering a table preserves a foreign key referencing a table whose name needs quoting, such as group. Existing Hypervel source already quotes that target correctly, so this adds the upstream regression without changing runtime code. The complete SQLite DatabaseSchemaBuilderTest file passes. --- .../Sqlite/DatabaseSchemaBuilderTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php index a782e6c05c..f273684cd0 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php @@ -220,6 +220,29 @@ public function testAlterTableAddForeignKeyWithPrefix(): void ); } + public function testAlterTableWithForeignKeyToTableNameThatNeedsQuoting(): void + { + Schema::create('group', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('members', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('group'); + $table->string('name'); + }); + + Schema::table('members', function (Blueprint $table): void { + $table->string('name')->nullable()->change(); + }); + + $this->assertTrue(collect(Schema::getForeignKeys('members'))->contains( + fn ($foreignKey) => $foreignKey['foreign_table'] === 'group' + && $foreignKey['foreign_columns'] === ['id'] + && $foreignKey['columns'] === ['group_id'] + )); + } + public function testAlterTableAddForeignKeyWithExpressionDefault(): void { Schema::create('items', function (Blueprint $table) { From c0fa60bbc1f1cf4ca84809b1fb4aa8873e952648 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:52:47 +0000 Subject: [PATCH 10/22] Clone Eloquent relationships without losing their constraints Port Laravel PR #62081, commit 15a01b35eb9840dc9685e4532df73b62bcb2ef8e, using the frozen framework 13.x target. Add Relation::clone so callers receive a cloned relation instead of a forwarded builder. The existing cloning hook isolates the underlying query and preserves relationship constraints and custom builder behavior. Update runtime and static-analysis expectations to the relation return type. Both upstream relationship regressions, CustomBuilderForwardingTest and the affected type fixtures pass. Full-batch verification identified and resolved the previous cloned-builder assertion. --- src/database/src/Eloquent/Relations/Relation.php | 8 ++++++++ .../Eloquent/CustomBuilderForwardingTest.php | 10 ++++++---- .../Database/EloquentBelongsToManyTest.php | 13 +++++++++++++ .../Database/EloquentHasManyThroughTest.php | 10 ++++++++++ types/Database/Eloquent/CustomBuilderForwarding.php | 2 +- types/Database/Eloquent/Relations.php | 2 +- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index 4e6753f938..b48754526d 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -624,6 +624,14 @@ public static function getMorphAlias(string $className): int|string return $alias !== false ? $alias : $className; } + /** + * Clone the relationship. + */ + public function clone(): static + { + return clone $this; + } + /** * Flush all static state. */ diff --git a/tests/Database/Eloquent/CustomBuilderForwardingTest.php b/tests/Database/Eloquent/CustomBuilderForwardingTest.php index 6b1d8cd103..18442299ff 100644 --- a/tests/Database/Eloquent/CustomBuilderForwardingTest.php +++ b/tests/Database/Eloquent/CustomBuilderForwardingTest.php @@ -28,7 +28,7 @@ public function testOrdinaryForwardingDiscardsResultsButPassthruAndNativeTermina $this->assertSame($builder->getModel(), $builder->models()->sole()); } - public function testRelationsDecorateFluentResultsButRetainTerminalsAndClonedBuilders(): void + public function testRelationsDecorateFluentResultsRetainTerminalsAndCloneTheirQueries(): void { $builder = $this->builder(new ForwardingTestModel); $parent = new ForwardingTestModel; @@ -40,9 +40,11 @@ public function testRelationsDecorateFluentResultsButRetainTerminalsAndClonedBui $this->assertSame(42, $relation->rawAnswer()); $this->assertSame($builder->getModel(), $relation->models()->sole()); $clone = $relation->clone(); - $this->assertInstanceOf(ForwardingTestBuilder::class, $clone); - $this->assertNotSame($builder, $clone); - $this->assertNotSame($builder->getQuery(), $clone->getQuery()); + $this->assertInstanceOf(HasMany::class, $clone); + $this->assertNotSame($relation, $clone); + $this->assertInstanceOf(ForwardingTestBuilder::class, $clone->getQuery()); + $this->assertNotSame($builder, $clone->getQuery()); + $this->assertNotSame($builder->getQuery(), $clone->getQuery()->getQuery()); } public function testScopesWinOverQueryForwardingButNotNativeEloquentOrRelationMethods(): void diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 4328b427ac..0f70775b46 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -297,6 +297,19 @@ public function testAttachMethod(): void $this->assertEquals($tag8->name, $post->tags[7]->name); } + public function testCloneKeepsTheRelationQuery(): void + { + $user = User::create(['name' => Str::random()]); + $other = User::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + $otherPost = Post::create(['title' => Str::random()]); + + $other->posts()->attach($otherPost); + $user->posts()->attach($post); + + $this->assertEquals([$post->id], $user->posts()->clone()->get()->modelKeys()); + } + public function testDetachMethod(): void { $post = Post::create(['title' => Str::random()]); diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index 8594fcae33..e3037aee74 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -155,6 +155,16 @@ public function testHasSelfCustomOwnerKey(): void $this->assertCount(1, $users); } + public function testCloneKeepsTheRelationQuery(): void + { + $team = Team::create(); + User::create(['name' => Str::random()]); + $user = User::create(['name' => Str::random(), 'team_id' => $team->id]); + $article = Article::create(['user_id' => $user->id, 'title' => Str::random()]); + + $this->assertEquals([$article->id], $team->articles()->clone()->get()->modelKeys()); + } + public function testHasSameParentAndThroughParentTable(): void { Category::create(); diff --git a/types/Database/Eloquent/CustomBuilderForwarding.php b/types/Database/Eloquent/CustomBuilderForwarding.php index 871e209749..a392fd2fef 100644 --- a/types/Database/Eloquent/CustomBuilderForwarding.php +++ b/types/Database/Eloquent/CustomBuilderForwarding.php @@ -42,7 +42,7 @@ function test(User $user, ScopedUser $scoped, FixedUser $fixed): void assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->tenant('one')->published()); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->ignoredAnswer()); assertType('Hypervel\Support\Collection', $user->children()->tenant('one')->models()); - assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->children()->clone()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->clone()); assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->children()->applyScopes()); assertType('int', $user->children()->rawAnswer()); assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', $user->children()->dump()); diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 0ce3e917ed..f4dad7dc81 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -41,7 +41,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Types\Relations\Post|null', $user->posts()->useWritePdo()->first()); assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dump()); assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dumpRawSql()); - assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->clone()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->clone()); assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->applyScopes()); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->whereIn('id', [1])); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->whereIn('id', [1])->get()); From b67097876fe4f8ca810af2478fffe739ed58f1af Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:53:07 +0000 Subject: [PATCH 11/22] Cover unique listener locks after failed dispatch Port Laravel PR #62045, commit fbc2b3e944782ccc3e7426299bc8c19703c03994, using the frozen framework 13.x target. Add the upstream unique-listener failure regression to UniqueJobTest. Existing Hypervel dispatch cleanup already releases the owned uniqueness lock on this path; retain that implementation. The complete affected test file passes. --- tests/Integration/Queue/UniqueJobTest.php | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 0a8a9a4abb..94b3022f65 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -543,6 +543,43 @@ public function push(object|string $job, mixed $data = '', UnitEnum|string|null $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()); } + public function testListenerLockIsReleasedWhenPushingToTheQueueFails(): void + { + Queue::extend('failing', fn () => new class implements ConnectorInterface { + /** + * Establish a queue connection. + */ + public function connect(array $config): QueueContract + { + return new class extends NullQueue { + /** + * Push a new job onto the queue. + */ + public function push(object|string $job, mixed $data = '', UnitEnum|string|null $queue = null): never + { + throw new RuntimeException('Queue unavailable.'); + } + }; + } + }); + + config()->set('queue.connections.failing', ['driver' => 'failing']); + + Event::listen(UniqueTestEvent::class, UniqueTestFailingPushListener::class); + + try { + event(new UniqueTestEvent); + + $this->fail('Expected pushing the listener to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Queue unavailable.', $exception->getMessage()); + } + + $lockKey = 'laravel_unique_job:' . hash('xxh128', UniqueTestFailingPushListener::class) . ':'; + + $this->assertTrue($this->app->get(Cache::class)->lock($lockKey, 10)->get()); + } + /** * Get the unique lock key for the given job. */ @@ -826,6 +863,18 @@ public function handle(UniqueTestEvent $event): void } } +class UniqueTestFailingPushListener implements ShouldQueue, ShouldBeUnique +{ + public string $connection = 'failing'; + + /** + * Handle the event. + */ + public function handle(UniqueTestEvent $event): void + { + } +} + class NestedUniqueParentJob implements ShouldQueue, ShouldBeUnique { use Dispatchable; From 77cd2d3ccc1f2cdd5e4bf008ce90203f0e0e8049 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:53:07 +0000 Subject: [PATCH 12/22] Cover scheduled unique job failures Port Laravel PR #62083, commit 3bbfbac4a3e37c50eb70b85d2a013e6af50aaeb0, using the frozen framework 13.x target. Reconcile scheduled unique-job failure coverage with the existing owned-lock cleanup. Preserve the meaningful failure, lock and subsequent-dispatch assertions while consolidating overlapping cases. The complete UniqueJobSchedulingTest file passes. --- .../Console/UniqueJobSchedulingTest.php | 72 +++++++++++-------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/tests/Integration/Console/UniqueJobSchedulingTest.php b/tests/Integration/Console/UniqueJobSchedulingTest.php index 811dd10963..0e63954f5a 100644 --- a/tests/Integration/Console/UniqueJobSchedulingTest.php +++ b/tests/Integration/Console/UniqueJobSchedulingTest.php @@ -9,15 +9,19 @@ use Hypervel\Bus\UniqueLock; use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\Connectors\ConnectorInterface; use Hypervel\Queue\InteractsWithQueue; +use Hypervel\Queue\NullQueue; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\Queue; use Hypervel\Testbench\TestCase; use Mockery as m; use RuntimeException; +use UnitEnum; class UniqueJobSchedulingTest extends TestCase { @@ -47,6 +51,46 @@ public function testUniqueJobsPushedToQueue(): void Queue::assertPushed(UniqueTestJob::class, 1); } + public function testUniqueLockIsReleasedWhenPushingToTheQueueFails(): void + { + Queue::extend('failing', fn () => new class implements ConnectorInterface { + /** + * Establish a queue connection. + */ + public function connect(array $config): QueueContract + { + return new class extends NullQueue { + /** + * Push a new job onto the queue. + */ + public function push(object|string $job, mixed $data = '', UnitEnum|string|null $queue = null): never + { + throw new RuntimeException('Queue unavailable.'); + } + }; + } + }); + + config()->set('queue.connections.failing', ['driver' => 'failing']); + + $event = $this->app->make(Schedule::class)->job(UniqueTestJob::class, null, 'failing')->name('')->everyMinute(); + $caught = null; + + try { + $event->run($this->app); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertSame('Queue unavailable.', $caught->getMessage()); + + Queue::fake(); + $this->dispatchJobs(UniqueTestJob::class); + + Queue::assertPushed(UniqueTestJob::class, 1); + } + public function testUniqueJobsRemainLockedWhenRecordedByBusFake(): void { Bus::fake(); @@ -75,34 +119,6 @@ public function testUniqueLockIsReleasedWhenDispatchFailsBeforePayloadCreation() $exception = new RuntimeException('Dispatch failed before payload creation.'); $calls = 0; - $dispatcher = m::mock(Dispatcher::class); - $dispatcher->shouldReceive('dispatch') - ->twice() - ->andReturnUsing(function () use (&$calls, $exception): void { - if (++$calls === 1) { - throw $exception; - } - }); - - $this->app->instance(Dispatcher::class, $dispatcher); - - $schedule = $this->scheduleUniqueJobs(2); - - try { - $schedule->events()[0]->run($this->app); - $this->fail('Expected the first dispatch to fail.'); - } catch (RuntimeException $throwable) { - $this->assertSame($exception, $throwable); - } - - $schedule->events()[1]->run($this->app); - } - - public function testUniqueLockIsReleasedWhenDispatchFailsAfterPayloadCreationBegins(): void - { - $exception = new RuntimeException('Dispatch failed after payload creation began.'); - $calls = 0; - $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('dispatch') ->twice() From 7a2f1ccf19440a3ed3efbd562466659b224b7156 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:53:07 +0000 Subject: [PATCH 13/22] Dispatch skip events for duplicate unique broadcasts Port Laravel PR #62077, commit d794ce96ed2f08906e88619de1ae4b22746f2a0e, using the frozen framework 13.x target. Dispatch UniqueJobSkipped when a unique broadcast cannot acquire its dispatch lock. Keep the listener check so no skip-event object is built when nobody observes it, and document the event. The complete BroadcastManagerTest file and affected analysis pass; formatting is clean. --- src/broadcasting/src/BroadcastManager.php | 9 +++++++ src/docs/broadcasting.md | 2 ++ .../Broadcasting/BroadcastManagerTest.php | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 1340780a20..1c72395e41 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -27,6 +27,7 @@ use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Foundation\CachesRoutes; use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\ObjectPool\InvalidatesPool; @@ -41,6 +42,7 @@ use Hypervel\Queue\Attributes\Connection as ConnectionAttribute; use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\Attributes\ReadsQueueAttributes; +use Hypervel\Queue\Events\UniqueJobSkipped; use Hypervel\Redis\RedisConfig; use Hypervel\Routing\Router; use Hypervel\Support\Arr; @@ -236,6 +238,13 @@ public function queue(mixed $event): void : new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event); if ($event instanceof ShouldBeUnique && $this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) { + /** @var EventDispatcher $events */ + $events = $this->app->make('events'); + + if ($events->hasListeners(UniqueJobSkipped::class)) { + $events->dispatch(new UniqueJobSkipped($broadcastEvent)); + } + return; } diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index 9b229a8e4b..e40bf78505 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -961,6 +961,8 @@ If you would like all of your broadcast events to use the same queue without cus Broadcast events implementing `Hypervel\Contracts\Broadcasting\ShouldBeUnique` also support the `Hypervel\Queue\Attributes\UniqueFor` attribute to configure the lock's lifetime in seconds, just like [unique jobs](/docs/{{version}}/queues#unique-jobs). +When a unique broadcast is skipped because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event. Its `job` property contains the `UniqueBroadcastEvent` wrapper, whose `event` property contains your broadcast event. + If you would like to broadcast your event using the `sync` queue instead of the default queue driver, you can implement the `ShouldBroadcastNow` interface instead of `ShouldBroadcast`: ```php diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 790a7998d9..1576b72589 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -48,6 +48,7 @@ use Hypervel\Http\Request; use Hypervel\ObjectPool\PoolManager; use Hypervel\Queue\Attributes\UniqueFor; +use Hypervel\Queue\Events\UniqueJobSkipped; use Hypervel\Queue\FailoverQueue; use Hypervel\Queue\QueueManager; use Hypervel\Queue\SyncQueue; @@ -58,6 +59,7 @@ use Hypervel\Support\Facades\Broadcast; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Queue; use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; @@ -325,6 +327,28 @@ public function testUniqueEventsUseInheritedUniqueForAttribute(): void Queue::assertPushed(UniqueBroadcastEvent::class, 2); } + public function testUniqueEventsEmitUniqueJobSkippedEventWhenLockNotAcquired(): void + { + Queue::fake(); + + $skipped = []; + + Event::listen(UniqueJobSkipped::class, function (UniqueJobSkipped $event) use (&$skipped): void { + $skipped[] = $event->job; + }); + + Broadcast::queue(new TestEventUnique); + + $this->assertSame([], $skipped); + + Broadcast::queue(new TestEventUnique); + + Queue::assertPushed(UniqueBroadcastEvent::class, 1); + $this->assertCount(1, $skipped); + $this->assertInstanceOf(UniqueBroadcastEvent::class, $skipped[0]); + $this->assertInstanceOf(TestEventUnique::class, $skipped[0]->event); + } + public function testUniqueForAttributeDoesNotMakeEventsUniqueWithoutTheContract(): void { Queue::fake(); From 53719fe840b34071061e08595033a2371c26c1f4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:53:07 +0000 Subject: [PATCH 14/22] Bound transformed input keys per coroutine Port Laravel direct commits 1b8b12d30748af0a1924d61a8061dd4336479459 and 31dbc2b9b0fc775ae3bbc87ae2387e88693ad2ff using the frozen framework 13.x target. Enforce the transformed-input key budget with the existing middleware extension points intact. Store the counter in coroutine context and account for each array locally, avoiding shared middleware state and per-key context operations. Preserve supported subclasses and document the budget. All upstream cases and the nested concurrent regression pass. A shared-counter mutation reproduced both incorrect rejection and budget bypass before being reverted. Property reflection confirms the setter creates no backing state; source/type analysis and formatting pass. --- src/docs/requests.md | 2 + .../src/Http/Middleware/TransformsRequest.php | 45 ++++- .../Http/Middleware/TransformsRequestTest.php | 160 ++++++++++++++++++ .../Http/Middleware/TrimStringsTest.php | 10 +- 4 files changed, 215 insertions(+), 2 deletions(-) diff --git a/src/docs/requests.md b/src/docs/requests.md index 8af3c1aadc..d7e19fc678 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -790,6 +790,8 @@ $value = $request->cookie('name'); By default, Hypervel includes the `Hypervel\Foundation\Http\Middleware\TrimStrings` and `Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull` middleware in your application's global middleware stack. These middleware will automatically trim all incoming string fields on the request, as well as convert any empty string fields to `null`. This allows you to not have to worry about these normalization concerns in your routes and controllers. +To prevent oversized nested field names from consuming excessive resources, these middleware reject requests with an HTTP 413 response when the combined size of the processed field paths exceeds 20 MiB. This limit counts full nested paths across the query string and request body. + #### Disabling Input Normalization If you would like to disable this behavior for all requests, you may remove the two middleware from your application's middleware stack by invoking the `$middleware->remove` method in your application's `bootstrap/app.php` file: diff --git a/src/foundation/src/Http/Middleware/TransformsRequest.php b/src/foundation/src/Http/Middleware/TransformsRequest.php index e149dcedc7..5c659c25c8 100644 --- a/src/foundation/src/Http/Middleware/TransformsRequest.php +++ b/src/foundation/src/Http/Middleware/TransformsRequest.php @@ -5,16 +5,38 @@ namespace Hypervel\Foundation\Http\Middleware; use Closure; +use Hypervel\Context\CoroutineContext; +use Hypervel\Http\Exceptions\PostTooLargeException; use Hypervel\Http\Request; use Symfony\Component\HttpFoundation\ParameterBag; class TransformsRequest { + protected const string KEY_BYTES_CONTEXT_KEY = '__foundation.transforms_request.key_bytes'; + + /** + * The maximum cumulative size of the keys processed per request, in bytes. + */ + protected int $maxKeyBytes = 20 * 1024 * 1024; + + /** + * The cumulative size of the keys processed for the current request. + */ + protected int $keyBytes { + // Middleware instances are shared; custom transforms can yield during request processing. + get => CoroutineContext::get(self::KEY_BYTES_CONTEXT_KEY, 0); + set { + CoroutineContext::set(self::KEY_BYTES_CONTEXT_KEY, $value); + } + } + /** * Handle an incoming request. */ public function handle(Request $request, Closure $next): mixed { + $this->keyBytes = 0; + $this->clean($request); return $next($request); @@ -51,8 +73,29 @@ protected function cleanParameterBag(ParameterBag $bag): void */ protected function cleanArray(array $data, string $keyPrefix = ''): array { + $prefixLength = strlen($keyPrefix); + $keyBytes = $this->keyBytes; + + // Charge the array before transforming values, keeping context lookups out of the per-key loop. foreach ($data as $key => $value) { - $data[$key] = $this->cleanValue($keyPrefix . $key, $value); + $keyBytes += $prefixLength + strlen((string) $key); + + if ($keyBytes > $this->maxKeyBytes) { + $this->keyBytes = $keyBytes; + + throw new PostTooLargeException('The cumulative size of the input keys is too large.'); + } + } + + $this->keyBytes = $keyBytes; + + foreach ($data as $key => $value) { + $cleaned = $this->cleanValue($keyPrefix . $key, $value); + + // Avoid copying arrays when none of their values have changed... + if ($cleaned !== $value) { + $data[$key] = $cleaned; + } } return $data; diff --git a/tests/Foundation/Http/Middleware/TransformsRequestTest.php b/tests/Foundation/Http/Middleware/TransformsRequestTest.php index eee0a5b4e8..25ead89875 100644 --- a/tests/Foundation/Http/Middleware/TransformsRequestTest.php +++ b/tests/Foundation/Http/Middleware/TransformsRequestTest.php @@ -4,14 +4,174 @@ namespace Hypervel\Tests\Foundation\Http\Middleware\TransformsRequestTest; +use Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull; use Hypervel\Foundation\Http\Middleware\TransformsRequest; +use Hypervel\Foundation\Http\Middleware\TrimStrings; +use Hypervel\Http\Exceptions\PostTooLargeException; use Hypervel\Http\Request; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; +use function Hypervel\Coroutine\parallel; + class TransformsRequestTest extends TestCase { + #[DataProvider('middlewareProvider')] + public function testUnchangedNestedArraysAreNotCopied(string $middlewareClass): void + { + $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '[' . str_repeat('[0],', 19999) . '[0]]'); + $original = $request->json()->all(); + $middleware = new $middlewareClass; + $before = memory_get_usage(); + + $middleware->handle($request, fn () => null); + + // Copying the 20,000 unchanged inner arrays would require several MiB. + $this->assertLessThan(1024 * 1024, memory_get_usage() - $before); + $this->assertSame($original, $request->json()->all()); + } + + public static function middlewareProvider(): iterable + { + yield [TransformsRequest::class]; + yield [TrimStrings::class]; + yield [ConvertEmptyStringsToNull::class]; + } + + #[DataProvider('amplifiedInputProvider')] + public function testRejectsAmplifiedJsonKeys(string $middlewareClass, mixed $value): void + { + $key = str_repeat('รจ', 197499); + $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ + $key => array_fill(0, 100, $value), + ], JSON_UNESCAPED_UNICODE)); + + try { + (new $middlewareClass)->handle($request, function (): never { + $this->fail('An amplified request should not reach the next middleware.'); + }); + + $this->fail('An amplified request should be rejected.'); + } catch (PostTooLargeException $exception) { + $this->assertSame(413, $exception->getStatusCode()); + } + } + + public static function amplifiedInputProvider(): iterable + { + foreach ([TransformsRequest::class, TrimStrings::class, ConvertEmptyStringsToNull::class] as $middleware) { + foreach ([0, ' value ', '', []] as $value) { + yield [$middleware, $value]; + } + } + } + + public function testKeyBudgetIncludesNestedPathsAndPreservesDottedKeys(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + + protected function transform(string $key, mixed $value): mixed + { + return $key; + } + }; + + // The processed keys are "foo" (3 bytes) and "foo.bar" (7 bytes). + $middleware->handle(new Request(['foo' => ['bar' => 'value']]), function (Request $request): void { + $this->assertSame('foo.bar', $request->input('foo.bar')); + }); + + $this->expectException(PostTooLargeException::class); + + $middleware->handle(new Request(['foo' => ['bars' => 'value']]), fn () => null); + } + + public function testKeyBudgetIsSharedAcrossParameterBags(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + }; + + $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{"foobar": 0}'); + $request->query->set('hello', 0); + + $this->expectException(PostTooLargeException::class); + + $middleware->handle($request, fn () => null); + } + + public function testKeyBudgetIsSharedAcrossQueryAndFormParameters(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + }; + + $request = Request::create('/?hello=0', 'POST', ['foobar' => 0]); + + $this->expectException(PostTooLargeException::class); + + $middleware->handle($request, fn () => null); + } + + public function testKeyBudgetResetsWhenMiddlewareIsReused(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + }; + + for ($i = 0; $i < 2; ++$i) { + $middleware->handle(new Request(['1234567890' => 'value']), function (Request $request): void { + $this->assertSame('value', $request->input('1234567890')); + }); + } + + try { + $middleware->handle(new Request(['12345678901' => 'value']), fn () => null); + + $this->fail('The key budget should be exceeded.'); + } catch (PostTooLargeException $exception) { + $this->assertSame(413, $exception->getStatusCode()); + } + + $middleware->handle(new Request(['1234567890' => 'value']), function (Request $request): void { + $this->assertSame('value', $request->input('1234567890')); + }); + } + + public function testYieldingTransformsKeepEachRequestsKeyBudget(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + + protected function transform(string $key, mixed $value): mixed + { + usleep(5000); + + return $value; + } + }; + $handle = static function (array $input) use ($middleware): int { + try { + return $middleware->handle(new Request($input), static fn (): int => 200); + } catch (PostTooLargeException $exception) { + return $exception->getStatusCode(); + } + }; + + $this->assertSame([200, 200], parallel([ + static fn (): int => $handle(['a' => ['b' => 1], 'c' => ['d' => 2]]), + static fn (): int => $handle(['a' => ['b' => 1], 'c' => ['d' => 2]]), + ])); + + $this->assertSame([413, 200], parallel([ + static fn (): int => $handle(['a' => ['bbbb' => 1], 'c' => ['dddd' => 2]]), + static fn (): int => $handle([]), + ])); + } + public function testEmptyParameterBagIsNotReadOrReplaced(): void { $bag = new TrackingParameterBag; diff --git a/tests/Foundation/Http/Middleware/TrimStringsTest.php b/tests/Foundation/Http/Middleware/TrimStringsTest.php index 7cab05b29e..71084139fd 100644 --- a/tests/Foundation/Http/Middleware/TrimStringsTest.php +++ b/tests/Foundation/Http/Middleware/TrimStringsTest.php @@ -11,9 +11,17 @@ class TrimStringsTest extends TestCase { - public function testNonStringValuesDoNotPerformExclusionMatching(): void + public function testNonStringValuesDoNotCheckExclusions(): void { $middleware = new TrimStringsTrackingExclusionMatches; + $data = ['values' => [[0], [false], [null], [1.5], []]]; + + $middleware->handle(new Request($data), function (Request $request) use ($data): void { + $this->assertSame($data, $request->query->all()); + }); + + $this->assertSame(0, $middleware->exclusionMatchCount); + $symfonyRequest = new SymfonyRequest([ 'integer' => 123, 'boolean' => true, From 59f5ec419cdca910848fa0760557e9fb432df0b0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:53:07 +0000 Subject: [PATCH 15/22] Cover Redis TLS broken-pipe connection failures Port Laravel PR #62111, commit 95e37cb4b1de19196b29f47470061242e407259f, using the frozen framework 13.x target. Add both upstream TLS broken-pipe messages to the existing Redis connection failure regression. Retain the established no-replay guarantee for failed commands. The complete RedisConnectionTest file passes without external services. --- tests/Redis/RedisConnectionTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 1a359f02e1..dd074ce647 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -1642,6 +1642,8 @@ public static function connectionFailureProvider(): array 'disconnected replica' => [new RedisException('MASTERDOWN link is down'), 'get', ['foo'], true], 'read failure' => [new RedisException('Connection lost'), 'get', ['foo'], false], 'warning-shaped reset' => [new ErrorException('Redis::get(): SSL: Connection reset by peer'), 'get', ['foo'], false], + 'broken pipe while reading' => [new ErrorException('Redis::lLen(): SSL: Broken pipe'), 'llen', ['foo'], false], + 'broken pipe during a non-idempotent write' => [new ErrorException('Redis::rPush(): SSL: Broken pipe'), 'rpush', ['foo', 'bar'], false], 'stream write failure' => [new ErrorException('Redis::get(): Send of 30 bytes failed with errno=32 Broken pipe'), 'get', ['foo'], false], 'TLS protocol failure' => [new ErrorException('Redis::get(): SSL operation failed with code 1. OpenSSL Error messages: bad length'), 'get', ['foo'], false], 'transaction opening' => [new RedisException('Connection lost'), 'multi', [], false], From e446e9e6417365eaf4e4fc3cb90d7980b114ff3f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:53:07 +0000 Subject: [PATCH 16/22] Normalize view namespace references before lookup Port Laravel PR #62005, commit 0d6b451894204c0937f37de5f9f92a9f1bbd6c47, using the frozen framework 13.x target. Normalize namespaced view references consistently in the finder and preserve the existing view cache and path behavior. Include both upstream finder and Blade rendering regressions. The complete affected finder and Blade files pass; source/type analysis and formatting are clean. --- src/view/src/FileViewFinder.php | 6 +++--- tests/Integration/View/BladeTest.php | 11 +++++++++++ tests/View/ViewFileViewFinderTest.php | 11 +++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/view/src/FileViewFinder.php b/src/view/src/FileViewFinder.php index faccb1ff64..467f9f33fc 100755 --- a/src/view/src/FileViewFinder.php +++ b/src/view/src/FileViewFinder.php @@ -158,7 +158,7 @@ protected function resolvePath(string $path): string */ public function addNamespace(string $namespace, string|array $hints): void { - $hints = (array) $hints; + $hints = array_map($this->resolvePath(...), (array) $hints); if (isset($this->hints[$namespace])) { $hints = array_merge($this->hints[$namespace], $hints); @@ -175,7 +175,7 @@ public function addNamespace(string $namespace, string|array $hints): void */ public function prependNamespace(string $namespace, string|array $hints): void { - $hints = (array) $hints; + $hints = array_map($this->resolvePath(...), (array) $hints); if (isset($this->hints[$namespace])) { $hints = array_merge($hints, $this->hints[$namespace]); @@ -192,7 +192,7 @@ public function prependNamespace(string $namespace, string|array $hints): void */ public function replaceNamespace(string $namespace, string|array $hints): void { - $this->hints[$namespace] = (array) $hints; + $this->hints[$namespace] = array_map($this->resolvePath(...), (array) $hints); } /** diff --git a/tests/Integration/View/BladeTest.php b/tests/Integration/View/BladeTest.php index 6a8867fb3d..c8af3bc9ed 100644 --- a/tests/Integration/View/BladeTest.php +++ b/tests/Integration/View/BladeTest.php @@ -14,6 +14,7 @@ use Hypervel\View\Component; use Mockery as m; use Override; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; @@ -303,6 +304,16 @@ public function testViewCacheCommandDeduplicatesPathsBeforeCompiling(): void $this->artisan('view:cache'); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testViewCacheCommandCompilesNamespacedViewsAtThePathTheyRenderFrom(): void + { + View::addNamespace('components', join_paths(__DIR__, 'Fixtures', 'templates', 'components', '..', 'components')); + + $this->artisan('view:cache'); + + $this->assertFileExists(Blade::getCompiledPath(View::getFinder()->find('components::panel'))); + } + /** * Configure the Blade fixture templates. */ diff --git a/tests/View/ViewFileViewFinderTest.php b/tests/View/ViewFileViewFinderTest.php index ed517dd1f5..e6a527208f 100755 --- a/tests/View/ViewFileViewFinderTest.php +++ b/tests/View/ViewFileViewFinderTest.php @@ -163,6 +163,17 @@ public static function pathsProvider(): array ]; } + public function testNormalizedNamespaceHints(): void + { + $finder = $this->getFinder(); + $finder->addNamespace('foo', __DIR__ . '/Fixtures/../Fixtures/nested'); + $finder->prependNamespace('foo', 'incorrect_path'); + $finder->replaceNamespace('bar', [__DIR__ . '/Fixtures/nested/../namespaced']); + + $this->assertSame(['incorrect_path', realpath(__DIR__ . '/Fixtures/nested')], $finder->getHints()['foo']); + $this->assertSame([realpath(__DIR__ . '/Fixtures/namespaced')], $finder->getHints()['bar']); + } + /** * Create a view finder with a mocked filesystem. */ From 72e3c95a706c06862dc58c3a610e2db34f914dbf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:11:34 +0000 Subject: [PATCH 17/22] Keep CI image manifests intact during registry cleanup Publish the single-platform CI images without automatic provenance attestations. The Ubuntu 26.04 runner now retains these attestations, producing tagged image indexes whose required child manifests are untagged. The existing registry cleanup keeps only the two newest untagged versions and deleted the PHP 8.4 children after publishing PHP 8.5, causing CI jobs to fail before checkout. Explicitly disabling provenance restores the plain-manifest publication shape previously used by these internal images. Retain existing cleanup and document why the flag is needed; no runtime framework behavior changes. Verified the failed image digest against the registry and build logs, parsed the workflow YAML, and checked the embedded build and push commands with bash -n. Publication and pull verification will run through the corrected branch workflow after the combined correction push. --- .github/workflows/ci-images.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-images.yml b/.github/workflows/ci-images.yml index 820ad2d301..d30f1319ce 100644 --- a/.github/workflows/ci-images.yml +++ b/.github/workflows/ci-images.yml @@ -46,12 +46,15 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Publish a plain manifest: cleanup deletes untagged versions, + # including the required children of an image index. - name: Build image env: IMAGE: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.3.0-rc1 run: | docker build \ --pull \ + --provenance=false \ --build-arg PHP_VERSION=${{ matrix.php }} \ --tag "$IMAGE" \ --file .github/docker/ci/Dockerfile \ From 30e5fc6d21c1e24e247556af4c42c095a01995ad Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:56:00 +0000 Subject: [PATCH 18/22] Preserve inherited model table and connection overrides Keep Table and Connection attribute overrides effective in descendant models instead of reverting to an older property declaration. A Table attribute that configures only other settings no longer clears the inherited table property. Cache declaration precedence and property defaults per model class. Preserve different table and connection values assigned during construction, retain direct-attribute precedence, and avoid reading uninitialized properties. Keep all other model-setting precedence rules unchanged. Document the narrow case where a pre-initialization assignment equal to the inherited default is indistinguishable from the untouched default, with the after-construction alternative. Clarify inherited custom-key precedence and the selection hook required for keyless Refreshes models. Validated with model and attribute tests, including failing-before construction regressions, static-state cleanup coverage, full source/type/skeleton analysis, focused reanalysis after the construction correction, and formatting. --- src/database/README.md | 1 + src/database/src/Eloquent/Model.php | 66 +++++++--- src/docs/eloquent.md | 4 + .../DatabaseEloquentModelAttributesTest.php | 122 +++++++++++++++++- tests/Database/DatabaseEloquentModelTest.php | 4 +- 5 files changed, 168 insertions(+), 29 deletions(-) diff --git a/src/database/README.md b/src/database/README.md index eb23c9609f..1d54e194aa 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -29,6 +29,7 @@ Documentation: https://hypervel.org/docs/database - `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files. - `Blueprint::dropForeign()` widens Laravel's method signature with an optional constraint name when columns are supplied, allowing explicitly named foreign keys to be dropped portably across SQLite and the server databases. Custom `Blueprint` subclasses that override this method must accept the optional second argument. - Eloquent models that override `CREATED_AT` or `UPDATED_AT` must declare the compatible `?string` constant type, such as `public const ?string UPDATED_AT = null;`. Laravel's constants are untyped, but omitting the type from an override in Hypervel causes a fatal error. +- When an inherited `Table` or `Connection` attribute overrides an ancestor's property, assigning that property's exact default before `parent::__construct()` does not override the attribute. Set runtime table/connection values after parent construction. - Eloquent expression plucks use `Query\Builder::pluckWithColumn()` to retain the returned field name for casts and accessors. Custom query builders overriding `pluck()` must also override `pluckWithColumn()` to customize this path. String-based Eloquent plucks still call `pluck()`. - Eloquent's protected `refreshSavedAttributes()` method receives the raw attribute values each write stored, so configured attributes are refreshed from the row that write changed. Models that override this method must accept the `array $attributes` argument. - Eloquent `updateFrom()` maintains the model's `updated_at` timestamp, like `update()`. Laravel forwards this method without adding a timestamp. diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 9e570776b7..f32a34c77a 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -52,6 +52,7 @@ use LogicException; use ReflectionClass; use ReflectionMethod; +use ReflectionProperty; use RuntimeException; use Stringable; use Throwable; @@ -312,13 +313,14 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt protected static array $classAttributes = []; /** - * Cache of whether each class directly declares each class attribute. + * Cache of whether class attributes override inherited properties. * - * Both positive and negative results are retained for the worker lifetime. + * True overrides unconditionally; false keeps the property. Other values + * are inherited property defaults that may be replaced if still unchanged. * - * @var array + * @var array */ - protected static array $classDeclaredAttributes = []; + protected static array $classAttributeOverrides = []; /** * Cache of the nearest class declaring each property. @@ -538,18 +540,15 @@ public function initializeModelAttributes(): void /** @var null|Table $table */ $table = static::resolveClassAttribute(Table::class); - $declaresTable = static::classPropertyDeclarer('table') === static::class; - - if (! $declaresTable && static::classDeclaresAttribute(Table::class)) { - $this->table = $table->name ?? null; + // Keep an attribute's override in descendants, and do not clear an inherited + // table when Table only configures other model settings. + if ($table?->name !== null && $this->classAttributeOverridesProperty(Table::class, 'table')) { + $this->table = $table->name; } else { $this->table ??= $table->name ?? null; } - // Reuse declaration metadata instead of reflecting on every hydrated model. - $declaresConnection = static::classPropertyDeclarer('connection') === static::class; - - if (! $declaresConnection && static::classDeclaresAttribute(ConnectionAttribute::class)) { + if ($this->classAttributeOverridesProperty(ConnectionAttribute::class, 'connection')) { $this->connection = static::resolveClassAttribute(ConnectionAttribute::class, 'name'); } else { $this->connection ??= static::resolveClassAttribute(ConnectionAttribute::class, 'name'); @@ -604,7 +603,7 @@ public static function clearBootedModels(): void static::$booted = []; static::$bootedCallbacks = []; static::$classAttributes = []; - static::$classDeclaredAttributes = []; + static::$classAttributeOverrides = []; static::$classPropertyDeclarers = []; static::$guardConfigurations = []; self::flushScopeCaches(); @@ -2584,18 +2583,45 @@ protected static function resolveClassAttribute(string $attributeClass, ?string } /** - * Determine whether a class directly declares an attribute. + * Determine whether a class attribute should override the property. + * + * Direct attributes override inherited properties. Inherited attributes only + * replace more distant property defaults, keeping different values assigned + * during construction. Assignments equal to a default are indistinguishable + * from untouched defaults and are replaced. * * @param class-string $attributeClass + * @param 'connection'|'table' $property */ - protected static function classDeclaresAttribute(string $attributeClass): bool + protected function classAttributeOverridesProperty(string $attributeClass, string $property): bool { $class = static::class; - $cacheKey = $class . '@' . $attributeClass; + $cacheKey = $class . '@' . $attributeClass . '@' . $property; + + if (! isset(static::$classAttributeOverrides[$cacheKey])) { + $declarer = static::classPropertyDeclarer($property); + $reflection = new ReflectionClass($class); + $override = false; + + do { + if ($reflection->getName() === $declarer) { + break; + } + + if ($reflection->getAttributes($attributeClass) !== []) { + $override = $reflection->getName() === $class + ? true + : ((new ReflectionProperty($class, $property))->getDefaultValue() ?? false); + break; + } + } while ($reflection = $reflection->getParentClass()); + + static::$classAttributeOverrides[$cacheKey] = $override; + } + + $override = static::$classAttributeOverrides[$cacheKey]; - return static::$classDeclaredAttributes[$cacheKey] ??= ( - new ReflectionClass($class) - )->getAttributes($attributeClass) !== []; + return $override === true || ($override !== false && $override === $this->{$property}); } /** @@ -3029,7 +3055,7 @@ public static function flushState(): void static::$globalScopes = []; static::$resolvesDefaultConnections = []; static::$classAttributes = []; - static::$classDeclaredAttributes = []; + static::$classAttributeOverrides = []; static::$classPropertyDeclarers = []; static::$guardConfigurations = []; self::flushScopeCaches(); diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md index 36daa33aae..7b9224f62f 100644 --- a/src/docs/eloquent.md +++ b/src/docs/eloquent.md @@ -211,6 +211,8 @@ class Flight extends Model For tables without a primary key, use `#[Table(key: false)]`. These models support retrieval and insertion, but updating, refreshing, or deleting an instance throws an exception. Eloquent collection methods such as `merge()` and `unique()` also throw because they require primary keys. Use explicit query conditions for updates and deletes, `concat()` to combine collections, and `unique('column')` to remove duplicates. +A custom `$primaryKey` inherited from a parent takes precedence over this attribute; declare `protected ?string $primaryKey = null` on the child instead. + #### "Composite" Primary Keys @@ -475,6 +477,8 @@ You may specify multiple attributes as an array: After the model is written, the configured attributes will be refreshed from the database. +`Refreshes` reads the row back using the primary key, so models without one must override `setKeysForSelectQuery`. + ### Configuring Eloquent Strictness diff --git a/tests/Database/DatabaseEloquentModelAttributesTest.php b/tests/Database/DatabaseEloquentModelAttributesTest.php index ce5ab18e53..0304ddd199 100644 --- a/tests/Database/DatabaseEloquentModelAttributesTest.php +++ b/tests/Database/DatabaseEloquentModelAttributesTest.php @@ -83,6 +83,19 @@ public function testChildInheritsParentTableAttribute(): void $this->assertSame('parent_attr', $model->getTable()); } + public function testGrandchildKeepsTableAttributeOverride(): void + { + $this->assertSame('child_attr', (new GrandchildModelWithTableAttribute)->getTable()); + } + + public function testTableAttributeWithoutNamePreservesInheritedTable(): void + { + $model = new ChildModelWithNamelessTableAttribute; + + $this->assertSame('parent_prop', $model->getTable()); + $this->assertFalse($model->usesTimestamps()); + } + public function testTableAttributeIsResolvedFromATrait(): void { $model = new ModelWithTableAttributeTrait; @@ -344,6 +357,44 @@ public function testChildInheritsParentConnectionAttribute(): void $this->assertSame('secondary', $model->getConnectionName()); } + public function testGrandchildKeepsConnectionAttributeOverride(): void + { + $this->assertSame('secondary', (new GrandchildModelWithConnectionAttribute)->getConnectionName()); + } + + public function testInheritedLocationAttributesPreserveValuesAssignedDuringConstruction(): void + { + $inherited = new class extends ModelWithLocationAttributes { + use SetsRuntimeModelLocation; + }; + + $this->assertSame('runtime_table', $inherited->getTable()); + $this->assertSame('runtime_connection', $inherited->getConnectionName()); + + $grandchild = new class extends ModelWithLocationAttributeOverrides { + /** + * Set runtime configuration before initializing the model. + */ + public function __construct(array $attributes = []) + { + $this->setTable('runtime_table'); + $this->setConnection('runtime_connection'); + + parent::__construct($attributes); + } + }; + + $this->assertSame('runtime_table', $grandchild->getTable()); + $this->assertSame('runtime_connection', $grandchild->getConnectionName()); + + $direct = new #[Table('attribute_table')] #[Connection('secondary')] class extends Model { + use SetsRuntimeModelLocation; + }; + + $this->assertSame('attribute_table', $direct->getTable()); + $this->assertSame('secondary', $direct->getConnectionName()); + } + public function testChildConnectionPropertyOverridesParentConnectionAttribute(): void { $model = new ChildModelWithConnectionProperty; @@ -351,6 +402,18 @@ public function testChildConnectionPropertyOverridesParentConnectionAttribute(): $this->assertSame('default', $model->getConnectionName()); } + public function testLocationAttributesInitializePropertiesWithoutDefaults(): void + { + $model = new #[Table('attribute_table')] #[Connection('secondary')] class extends Model { + protected ?string $table; + + protected UnitEnum|string|null $connection; + }; + + $this->assertSame('attribute_table', $model->getTable()); + $this->assertSame('secondary', $model->getConnectionName()); + } + public function testTimestampsAttribute(): void { $model = new ModelWithTimestampsFalseAttribute; @@ -569,28 +632,29 @@ public function testGuardAndTableMetadataCachesAreUsedAndCleared(): void $reflection = new ReflectionClass(Model::class); $guardConfigurationsProperty = $reflection->getProperty('guardConfigurations'); - $classDeclaredAttributesProperty = $reflection->getProperty('classDeclaredAttributes'); + $classAttributeOverridesProperty = $reflection->getProperty('classAttributeOverrides'); $classPropertyDeclarersProperty = $reflection->getProperty('classPropertyDeclarers'); $guardConfigurations = $guardConfigurationsProperty->getValue(); - $classDeclaredAttributes = $classDeclaredAttributesProperty->getValue(); + $classAttributeOverrides = $classAttributeOverridesProperty->getValue(); $classPropertyDeclarers = $classPropertyDeclarersProperty->getValue(); $guardConfigurations[ModelWithUnguardedAttribute::class] = [ 'guarded' => ['cached'], 'propertyDefault' => ['*'], ]; - $classDeclaredAttributes[ChildModelWithTableAttribute::class . '@' . Table::class] = false; + $cacheKey = ChildModelWithTableAttribute::class . '@' . Table::class . '@table'; + $classAttributeOverrides[$cacheKey] = false; $guardConfigurationsProperty->setValue(null, $guardConfigurations); - $classDeclaredAttributesProperty->setValue(null, $classDeclaredAttributes); + $classAttributeOverridesProperty->setValue(null, $classAttributeOverrides); $this->assertSame(['cached'], (new ModelWithUnguardedAttribute)->getGuarded()); $this->assertSame('parent_prop', (new ChildModelWithTableAttribute)->getTable()); - $classDeclaredAttributes[ChildModelWithTableAttribute::class . '@' . Table::class] = true; + unset($classAttributeOverrides[$cacheKey]); $classPropertyDeclarers[ChildModelWithTableAttribute::class . '@table'] = ChildModelWithTableAttribute::class; - $classDeclaredAttributesProperty->setValue(null, $classDeclaredAttributes); + $classAttributeOverridesProperty->setValue(null, $classAttributeOverrides); $classPropertyDeclarersProperty->setValue(null, $classPropertyDeclarers); $this->assertSame('parent_prop', (new ChildModelWithTableAttribute)->getTable()); @@ -598,7 +662,7 @@ public function testGuardAndTableMetadataCachesAreUsedAndCleared(): void Model::clearBootedModels(); $this->assertSame([], $guardConfigurationsProperty->getValue()); - $this->assertSame([], $classDeclaredAttributesProperty->getValue()); + $this->assertSame([], $classAttributeOverridesProperty->getValue()); $this->assertSame([], $classPropertyDeclarersProperty->getValue()); $this->assertSame([], (new ModelWithUnguardedAttribute)->getGuarded()); $this->assertSame('child_attr', (new ChildModelWithTableAttribute)->getTable()); @@ -961,6 +1025,15 @@ class ChildModelWithTableAttribute extends ParentModelWithTableProperty { } +class GrandchildModelWithTableAttribute extends ChildModelWithTableAttribute +{ +} + +#[Table(timestamps: false)] +class ChildModelWithNamelessTableAttribute extends ParentModelWithTableProperty +{ +} + #[Table(name: 'parent_attr')] class ParentModelWithTableAttribute extends Model { @@ -1066,6 +1139,10 @@ class ChildModelWithConnectionAttribute extends ParentModelWithConnectionPropert { } +class GrandchildModelWithConnectionAttribute extends ChildModelWithConnectionAttribute +{ +} + #[Connection('secondary')] class ParentModelWithConnectionAttribute extends Model { @@ -1080,6 +1157,37 @@ class ChildModelWithConnectionProperty extends ParentModelWithConnectionAttribut protected UnitEnum|string|null $connection = 'default'; } +trait SetsRuntimeModelLocation +{ + /** + * Configure the model's table and connection during initialization. + */ + protected function initializeSetsRuntimeModelLocation(): void + { + $this->setTable('runtime_table'); + $this->setConnection('runtime_connection'); + } +} + +#[Table('attribute_table')] +#[Connection('secondary')] +class ModelWithLocationAttributes extends Model +{ +} + +class ModelWithLocationProperties extends Model +{ + protected ?string $table = 'parent_table'; + + protected UnitEnum|string|null $connection = 'default'; +} + +#[Table('attribute_table')] +#[Connection('secondary')] +class ModelWithLocationAttributeOverrides extends ModelWithLocationProperties +{ +} + #[Table(timestamps: false)] class ModelWithTimestampsFalseAttribute extends Model { diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index f9394dfc4e..0edcfde7b3 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -4364,7 +4364,7 @@ public function testFlushStateRestoresStaticState(): void $reflection->setStaticPropertyValue('relationResolvers', [ModelStub::class => ['foo' => fn () => null]]); $reflection->setStaticPropertyValue('guardableColumns', [ModelStub::class => ['id']]); $reflection->setStaticPropertyValue('guardConfigurations', [ModelStub::class => ['guarded' => [], 'propertyDefault' => ['*']]]); - $reflection->setStaticPropertyValue('classDeclaredAttributes', [ModelStub::class . '@attribute' => true]); + $reflection->setStaticPropertyValue('classAttributeOverrides', [ModelStub::class . '@attribute@property' => true]); $reflection->setStaticPropertyValue('classPropertyDeclarers', [ModelStub::class . '@table' => ModelStub::class]); $reflection->setStaticPropertyValue('mutatorCache', [ModelStub::class => ['foo']]); $reflection->setStaticPropertyValue('attributeMutatorCache', [ModelStub::class => ['foo' => true]]); @@ -4399,7 +4399,7 @@ public function testFlushStateRestoresStaticState(): void $this->assertSame([], $reflection->getStaticPropertyValue('relationResolvers')); $this->assertSame([], $reflection->getStaticPropertyValue('guardableColumns')); $this->assertSame([], $reflection->getStaticPropertyValue('guardConfigurations')); - $this->assertSame([], $reflection->getStaticPropertyValue('classDeclaredAttributes')); + $this->assertSame([], $reflection->getStaticPropertyValue('classAttributeOverrides')); $this->assertSame([], $reflection->getStaticPropertyValue('classPropertyDeclarers')); $this->assertTrue(Model::$snakeAttributes); $this->assertSame([], $reflection->getStaticPropertyValue('mutatorCache')); From 04bf4a7b3e9f8118c282f671629613ea7864c4fa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:56:15 +0000 Subject: [PATCH 19/22] Keep one-of-many join aliases separate from schema names Compare generated relationship aliases with the unqualified related table name. This prevents schema-qualified closure fallbacks from becoming invalid aliases and keeps named relationships from colliding with their related table. Preserve explicit relationship aliases and relationship-name inference. Extend existing named and closure relationship tests to schema-qualified models, including lazy loading, eager loading and existence queries. SQLite fixtures select explicit columns to retain its native schema-qualified wildcard limitation. Validated with the complete one-of-many test file, full source/type/skeleton analysis and formatting. --- .../Relations/Concerns/CanBeOneOfMany.php | 8 +++- .../DatabaseEloquentHasOneOfManyTest.php | 43 +++++++++++++++---- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php index ce9f0f410b..726ea926e4 100644 --- a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php +++ b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -164,8 +164,12 @@ public function oldestOfMany(string|array|null $column = 'id', ?string $relation */ protected function getDefaultOneOfManyJoinAlias(string $relation): string { - return $relation == $this->query->getModel()->getTable() - ? $relation . '_of_many' + // Schema qualification is not part of an alias, but the table name still + // needs a distinct join alias when the relation is named after that table. + $table = last(explode('.', $this->query->getModel()->getTable())); + + return last(explode('.', $relation)) === $table + ? $table . '_of_many' : $relation; } diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php index 0ee24b3426..288ea89edc 100755 --- a/tests/Database/DatabaseEloquentHasOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -14,6 +14,7 @@ use Hypervel\Database\Schema\Builder; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\TestWith; class DatabaseEloquentHasOneOfManyTest extends TestCase { @@ -97,21 +98,35 @@ public function testItGuessesRelationName(): void $this->assertSame('latest_login', $user->latest_login()->getRelationName()); } - public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName(): void + #[TestWith([Login::class])] + #[TestWith([SchemaLogin::class])] + public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName(string $related): void { $model = TestModel::make(); - $this->assertSame('logins_of_many', $model->logins()->getRelationName()); + $relation = $model->logins($related); + + $this->assertSame('logins_of_many', $relation->getRelationName()); + $this->assertStringContainsString('as "logins_of_many"', $relation->toSql()); } - public function testItUsesTheTableNameWhenOfManyIsCalledInsideAClosure(): void + #[TestWith([Login::class])] + #[TestWith([SchemaLogin::class])] + public function testItUsesTheTableNameWhenOfManyIsCalledInsideAClosure(string $related): void { - User::resolveRelationUsing('first_login_from_closure', function (User $user): HasOne { - return $user->hasOne(Login::class, 'user_id')->ofMany('id', 'min'); + User::resolveRelationUsing('first_login_from_closure', function (User $user) use ($related): HasOne { + $relation = $user->hasOne($related, 'user_id'); + + if ($related === SchemaLogin::class) { + // SQLite requires explicit columns instead of schema.table.*. + $relation->select(['main.logins.id', 'main.logins.user_id']); + } + + return $relation->ofMany('id', 'min'); }); $user = User::create(); - $first = $user->logins()->create(); - $user->logins()->create(); + $first = $user->first_login_from_closure()->create(); + $user->first_login_from_closure()->create(); $this->assertTrue($first->is($user->first_login_from_closure)); $this->assertTrue($first->is(User::with('first_login_from_closure')->first()->first_login_from_closure)); @@ -733,9 +748,14 @@ public function latest_updated_latest_created_state() class TestModel extends Eloquent { - public function logins() + /** + * Get the latest login. + * + * @param class-string $related + */ + public function logins(string $related = Login::class): HasOne { - return $this->hasOne(Login::class)->ofMany(); + return $this->hasOne($related)->ofMany(); } } @@ -748,6 +768,11 @@ class Login extends Eloquent public bool $timestamps = false; } +class SchemaLogin extends Login +{ + protected ?string $table = 'main.logins'; +} + class LoginWithSoftDeletes extends Eloquent { use SoftDeletes; From 5852326b8879b21323b346dd9e08e65542626f70 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:56:15 +0000 Subject: [PATCH 20/22] Rescue failures while preparing unique broadcasts Run unique-lock acquisition and skipped-event listeners inside the existing ShouldRescue boundary. Keep acquisition before owned-lock cleanup so a skipped broadcast cannot release another dispatch lock, while ordinary events and cancellation still propagate their failures. Cover rescued and ordinary lock/listener failures and cancellation carried by a listener exception. Clarify that unique-job and unique-broadcast skip notifications require an active listener. Validated with the complete broadcast manager tests, full source/type/skeleton analysis and formatting. --- src/broadcasting/src/BroadcastManager.php | 25 +++++------ src/docs/broadcasting.md | 2 +- src/docs/queues.md | 2 +- .../Broadcasting/BroadcastManagerTest.php | 41 +++++++++++++++++++ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 1c72395e41..780e1e164c 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -237,17 +237,6 @@ public function queue(mixed $event): void ? new UniqueBroadcastEvent($event instanceof UnitEnum ? $event : clone $event) : new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event); - if ($event instanceof ShouldBeUnique && $this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) { - /** @var EventDispatcher $events */ - $events = $this->app->make('events'); - - if ($events->hasListeners(UniqueJobSkipped::class)) { - $events->dispatch(new UniqueJobSkipped($broadcastEvent)); - } - - return; - } - $push = $synchronousMercure ? fn () => $this->dispatchSynchronousBroadcast($broadcastEvent) : fn () => $this->app->make(Queue::class) @@ -260,7 +249,19 @@ public function queue(mixed $event): void if ($event instanceof ShouldBeUnique) { $dispatch = $push; - $push = static function () use ($broadcastEvent, $dispatch): mixed { + $push = function () use ($broadcastEvent, $dispatch): mixed { + // Acquisition and skip listeners can fail too, so keep them inside the rescue boundary. + if ($this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) { + /** @var EventDispatcher $events */ + $events = $this->app->make('events'); + + if ($events->hasListeners(UniqueJobSkipped::class)) { + $events->dispatch(new UniqueJobSkipped($broadcastEvent)); + } + + return null; + } + try { return $dispatch(); } finally { diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index e40bf78505..6569165a42 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -961,7 +961,7 @@ If you would like all of your broadcast events to use the same queue without cus Broadcast events implementing `Hypervel\Contracts\Broadcasting\ShouldBeUnique` also support the `Hypervel\Queue\Attributes\UniqueFor` attribute to configure the lock's lifetime in seconds, just like [unique jobs](/docs/{{version}}/queues#unique-jobs). -When a unique broadcast is skipped because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event. Its `job` property contains the `UniqueBroadcastEvent` wrapper, whose `event` property contains your broadcast event. +When a unique broadcast is skipped because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event if an active listener is registered. Its `job` property contains the `UniqueBroadcastEvent` wrapper, whose `event` property contains your broadcast event. If you would like to broadcast your event using the `sync` queue instead of the default queue driver, you can implement the `ShouldBroadcastNow` interface instead of `ShouldBroadcast`: diff --git a/src/docs/queues.md b/src/docs/queues.md index 2ec3571318..fa07e2a9fe 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -493,7 +493,7 @@ class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique In the example above, the `UpdateSearchIndex` job is unique. So, the job will not be dispatched if another instance of the job is already on the queue and has not finished processing. -When a unique job dispatched through its static `dispatch` method is not queued because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event. The event's `job` property contains the job that was skipped. +When a unique job dispatched through its static `dispatch` method is not queued because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event if an active listener is registered. The event's `job` property contains the job that was skipped. In certain cases, you may want to define a specific "key" that makes the job unique or you may want to specify a timeout beyond which the job no longer stays unique. To accomplish this, you may use the `UniqueFor` attribute and define a `uniqueId` method on your job class: diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 1576b72589..9af9655418 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -349,6 +349,47 @@ public function testUniqueEventsEmitUniqueJobSkippedEventWhenLockNotAcquired(): $this->assertInstanceOf(TestEventUnique::class, $skipped[0]->event); } + #[TestWith(['lock', true], 'rescued lock failure')] + #[TestWith(['lock', false], 'ordinary lock failure')] + #[TestWith(['listener', true], 'rescued skip-listener failure')] + #[TestWith(['listener', false], 'ordinary skip-listener failure')] + #[TestWith(['listener', true, true], 'skip listener propagates chained cancellation')] + public function testUniqueBroadcastPreparationHonorsRescue(string $phase, bool $rescued, bool $cancellation = false): void + { + Queue::fake(); + + $failure = new RuntimeException('Broadcast preparation failed.', previous: $cancellation ? new CanceledException : null); + $event = $rescued ? new TestEventUniqueRescue : new TestEventUnique; + $handler = m::mock(ExceptionHandler::class); + $this->app->instance(ExceptionHandler::class, $handler); + + if ($rescued && ! $cancellation) { + $handler->expects('report')->with($failure, []); + } else { + $handler->shouldNotReceive('report'); + } + + if ($phase === 'lock') { + $store = m::mock(ArrayStore::class)->makePartial(); + $store->expects('lock')->andThrow($failure); + $this->app->instance(Cache::class, new CacheRepository($store)); + } else { + Broadcast::queue($event); + Event::listen(UniqueJobSkipped::class, static fn () => throw $failure); + } + + $caught = null; + + try { + Broadcast::queue($event); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($rescued && ! $cancellation ? null : $failure, $caught); + Queue::assertPushed(UniqueBroadcastEvent::class, $phase === 'lock' ? 0 : 1); + } + public function testUniqueForAttributeDoesNotMakeEventsUniqueWithoutTheContract(): void { Queue::fake(); From b9158fc0a3f3f648a5278b8bdcfa6e5b2295e161 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:56:15 +0000 Subject: [PATCH 21/22] Handle unavailable JPEG XL encoders in the image fixture A registered ImageMagick format does not guarantee its encoding delegate is available. Skip fixture generation when Imagick cannot produce JPEG XL output, retaining unconditional cleanup and all package assertions once a valid fixture exists. Complete method documentation for the PDO session-configurator fixture and request-transform providers and overrides. Validated the affected image, PDO and request-transform test files and formatting. The cached PHP 8.4 CI image exercises the existing unavailable-format skip; it does not exercise the new encoder-failure catch. --- tests/Database/DatabasePdoConnectionTest.php | 9 +++++++++ .../Http/Middleware/TransformsRequestTest.php | 12 ++++++++++++ tests/Image/ImageTest.php | 8 ++++++++ 3 files changed, 29 insertions(+) diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index 906b8527a7..608771296d 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -1499,15 +1499,24 @@ public function testFailedSessionConfigurationReleasesTheOldHandleDuringReplacem { $failure = new RuntimeException('Session configuration failed.'); PdoConnection::configureSessionUsing(new class($failure) implements SessionConfigurator { + /** + * Create a configurator that fails once. + */ public function __construct(private ?RuntimeException $failure) { } + /** + * Get the desired session state. + */ public function state(PdoConnection $connection): ?string { return 'configured'; } + /** + * Apply the session state or throw the configured failure once. + */ public function apply(PDO $pdo, string $state, PdoConnection $connection): void { if ($this->failure !== null) { diff --git a/tests/Foundation/Http/Middleware/TransformsRequestTest.php b/tests/Foundation/Http/Middleware/TransformsRequestTest.php index 25ead89875..53183afa99 100644 --- a/tests/Foundation/Http/Middleware/TransformsRequestTest.php +++ b/tests/Foundation/Http/Middleware/TransformsRequestTest.php @@ -33,6 +33,9 @@ public function testUnchangedNestedArraysAreNotCopied(string $middlewareClass): $this->assertSame($original, $request->json()->all()); } + /** + * Provide middleware that transforms request input. + */ public static function middlewareProvider(): iterable { yield [TransformsRequest::class]; @@ -59,6 +62,9 @@ public function testRejectsAmplifiedJsonKeys(string $middlewareClass, mixed $val } } + /** + * Provide middleware and values that amplify nested input keys. + */ public static function amplifiedInputProvider(): iterable { foreach ([TransformsRequest::class, TrimStrings::class, ConvertEmptyStringsToNull::class] as $middleware) { @@ -73,6 +79,9 @@ public function testKeyBudgetIncludesNestedPathsAndPreservesDottedKeys(): void $middleware = new class extends TransformsRequest { protected int $maxKeyBytes = 10; + /** + * Return the full input path for the value. + */ protected function transform(string $key, mixed $value): mixed { return $key; @@ -146,6 +155,9 @@ public function testYieldingTransformsKeepEachRequestsKeyBudget(): void $middleware = new class extends TransformsRequest { protected int $maxKeyBytes = 10; + /** + * Yield during transformation to interleave requests. + */ protected function transform(string $key, mixed $value): mixed { usleep(5000); diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index b337d96aab..df72ebabe2 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -34,6 +34,7 @@ use Hypervel\Image\Transformations\Sharpen; use Hypervel\Tests\TestCase; use Imagick; +use ImagickException; use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -365,11 +366,18 @@ public function testExtensionReturnsJxlForJxl(): void $imagick->newImage(10, 10, 'red'); $imagick->setImageFormat('jxl'); $contents = $imagick->getImageBlob(); + } catch (ImagickException) { + // A registered format may still lack its encoding delegate. + $contents = ''; } finally { $imagick->clear(); $imagick->destroy(); } + if ($contents === '') { + $this->markTestSkipped('The Imagick extension cannot encode jxl images.'); + } + $mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents); if (! in_array($mimeType, ['image/jxl', 'image/x-jxl'], true)) { From f55831102928fd0837032b835b32714293fcd5b3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:27:46 +0000 Subject: [PATCH 22/22] Include the resource in the OAuth callback session fixture The real authorization redirect stores the validated resource identifier, but the callback-route fixture omitted it. Token exchange therefore read a missing session field in the successful callback cases. Add the resource to the shared fixture and assert that the authorization-code request sends it. Preserve the production requirement rather than adding a fallback for malformed test state. Validated the complete callback-route test file locally and in the CI images, including reproduction of the missing-field failures before the correction. Formatting and diff checks pass. --- tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php b/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php index 8ddae65449..d41dcb342b 100644 --- a/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php +++ b/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php @@ -55,6 +55,7 @@ private function fakeOAuthSession(): array 'token_endpoint' => 'https://auth.test/token', 'redirect_uri' => 'https://app.test/callback', 'return_to' => null, + 'resource' => 'https://mcp.test/mcp', ]]; } @@ -102,7 +103,8 @@ public function testExchangesTheCodeAndInvokesTheHandlerWithTheClientName(): voi Http::assertSent(fn ($request): bool => $request->url() === 'https://auth.test/token' && ($request['grant_type'] ?? null) === 'authorization_code' - && ($request['code'] ?? null) === 'auth-code'); + && ($request['code'] ?? null) === 'auth-code' + && ($request['resource'] ?? null) === 'https://mcp.test/mcp'); } public function testRegistersAConnectRouteThatRedirectsToTheAuthorizationServer(): void