From 8ffa0ec458d2a73f53abf20a7dfa0458b9851081 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:22:58 +0000 Subject: [PATCH 01/41] Complete vector casting and MariaDB similarity queries Port the current AsVector caster and grammar-owned vector distance support for PostgreSQL and MariaDB. Preserve stateless caster reuse across long-lived workers, native Expression handling, existing vector bindings, and the current Stringable embedding hook. Fix inherited alias defects by quoting column aliases as single identifiers and resolving expression-backed default vector aliases. Aggregate queries normalize relation constraints without eager-loading parent expansion, so a dotted alias produces exactly one aggregate. The owner approved the narrow change for custom builders overriding only parseWithRelations; eager loading and the underlying normalization extension point remain unchanged. Disable object caching for the vector caster so assigned Arrayable values read back as float arrays. Reject unsupported vector grammars before any embedding generation. Port all applicable current upstream tests, add focused regressions for these defects, and adapt the Laravel documentation for AsVector and MariaDB, including PostgreSQL-only extension setup. Upstream framework source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Upstream documentation: 89e91b5cff48e1b9b1a7921300653eb1ceb7bfcb https://github.com/laravel/framework/pull/58337 https://github.com/laravel/framework/pull/61250 https://github.com/laravel/framework/pull/61337 Query Builder substitutions from https://github.com/laravel/framework/pull/60852 (the remaining files of that PR are tracked separately). Validation: changed test files, database unit suite through ParaTest, real MariaDB 11.8 vector integration, formatting, full source and type-fixture PHPStan. The unsupported-string regression fails before the guard and passes afterward; complete SQL assertions cover alias and aggregate fixes. --- src/database/src/Eloquent/Casts/AsVector.php | 97 ++++++++++ .../Concerns/QueriesRelationships.php | 5 +- src/database/src/Query/Builder.php | 51 +++-- src/database/src/Query/Grammars/Grammar.php | 18 ++ .../src/Query/Grammars/MariaDbGrammar.php | 17 ++ .../src/Query/Grammars/PostgresGrammar.php | 17 ++ src/docs/eloquent-mutators.md | 25 +++ src/docs/migrations.md | 2 +- src/docs/queries.md | 2 +- src/docs/search.md | 14 +- .../DatabaseEloquentAsVectorCastTest.php | 174 ++++++++++++++++++ .../Database/DatabaseEloquentBuilderTest.php | 24 +++ tests/Database/DatabaseQueryBuilderTest.php | 142 ++++++++++++++ .../Database/MariaDb/EloquentVectorTest.php | 82 +++++++++ 14 files changed, 642 insertions(+), 28 deletions(-) create mode 100644 src/database/src/Eloquent/Casts/AsVector.php create mode 100644 tests/Database/DatabaseEloquentAsVectorCastTest.php create mode 100644 tests/Integration/Database/MariaDb/EloquentVectorTest.php diff --git a/src/database/src/Eloquent/Casts/AsVector.php b/src/database/src/Eloquent/Casts/AsVector.php new file mode 100644 index 0000000000..6f9f5ad970 --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsVector.php @@ -0,0 +1,97 @@ +, array|Arrayable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes { + // Eloquent otherwise caches an assigned Arrayable and returns it instead of a float array. + public bool $withoutObjectCaching = true; + + /** + * Transform the attribute from the underlying model values. + * + * @return null|array + * + * @throws JsonException + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?array + { + if ($value === null) { + return null; + } + + $grammar = $model->getConnection()->getQueryGrammar(); + + // Decode a MariaDB expression assigned to the model before it is persisted... + if ($value instanceof ExpressionContract) { + $value = Str::between($value->getValue($grammar), "('", "')"); + + return array_map(floatval(...), json_decode($value, true, flags: JSON_THROW_ON_ERROR)); + } + + // MariaDB vector columns return little-endian float32 bytes... + if ($grammar instanceof MariaDbGrammar) { + return array_values(unpack('g*', $value)); + } + + // PostgreSQL (pgvector) returns JSON text... + return array_map(floatval(...), json_decode($value, true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * Transform the attribute to its underlying model values. + * + * @return array + * + * @throws InvalidArgumentException + * @throws JsonException + */ + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + if ($value === null) { + return [$key => null]; + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + if (! is_array($value)) { + throw new InvalidArgumentException( + sprintf('The [%s] attribute must be an array of floats or an Arrayable instance.', $key) + ); + } + + $vector = json_encode(array_values(array_map(floatval(...), $value)), JSON_THROW_ON_ERROR); + + // MariaDB requires vectors to be converted from JSON text server-side... + return [ + $key => $model->getConnection()->getQueryGrammar() instanceof MariaDbGrammar + ? new Expression("vec_fromtext('{$vector}')") + : $vector, + ]; + } + }; + } +} diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 5bb3ca9eda..0752d96e2b 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -802,7 +802,8 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum $relations = is_array($relations) ? $relations : [$relations]; - foreach ($this->parseWithRelations($relations) as $name => $constraints) { + // Aggregate aliases are not relationship paths and need no eager-load parent expansion. + foreach ($this->prepareNestedWithRelationships($relations) as $name => $constraints) { // First we will determine if the name has been aliased using an "as" clause on the name // and if it has we will extract the actual relationship name and the desired name of // the resulting column. This allows multiple aggregates on the same relationships. @@ -878,7 +879,7 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum if ($function === 'exists') { $this->selectRaw( - sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrap($alias)), + sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrapIdentifier($alias)), $query->getBindings() )->withCasts([$alias => 'bool']); // @phpstan-ignore method.notFound (selectRaw returns Eloquent\Builder $this, not Query\Builder) } else { diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 34224c8b75..7df28e62d7 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -21,7 +21,6 @@ use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Relations\Relation; -use Hypervel\Database\PostgresConnection; use Hypervel\Database\Query\Grammars\Grammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Pagination\Cursor; @@ -30,11 +29,12 @@ use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; -use Hypervel\Support\Str; use Hypervel\Support\StrCache; +use Hypervel\Support\Stringable; use Hypervel\Support\Traits\ForwardsCalls; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; +use JsonException; use LogicException; use RuntimeException; use SortDirection; @@ -293,7 +293,7 @@ public function selectSub(Closure|self|EloquentBuilder|Relation|string $query, s [$query, $bindings] = $this->createSub($query); return $this->selectRaw( - '(' . $query . ') as ' . $this->grammar->wrap($as), + '(' . $query . ') as ' . $this->grammar->wrapIdentifier($as), $bindings ); } @@ -304,7 +304,7 @@ public function selectSub(Closure|self|EloquentBuilder|Relation|string $query, s public function selectExpression(ExpressionContract $expression, string $as): static { return $this->selectRaw( - '(' . $expression->getValue($this->grammar) . ') as ' . $this->grammar->wrap($as) + '(' . $expression->getValue($this->grammar) . ') as ' . $this->grammar->wrapIdentifier($as) ); } @@ -469,14 +469,16 @@ public function getDefaultSelectColumn(): string /** * Add a vector-similarity selection to the query. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function selectVectorDistance(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, ?string $as = null): static { $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } $this->addBinding( @@ -489,10 +491,13 @@ public function selectVectorDistance(ExpressionContract|string $column, Collecti 'select', ); - $as = $this->getGrammar()->wrap($as ?? $column . '_distance'); + // An alias is a single identifier, even when derived from a qualified column or expression. + $as = $this->getGrammar()->wrapIdentifier( + $as ?? last(explode('.', (string) $this->getGrammar()->getValue($column))) . '_distance' + ); return $this->addSelect( - new Expression("({$this->getGrammar()->wrap($column)} <=> ?) as {$as}") + new Expression("{$this->getGrammar()->compileVectorDistanceExpression($column)} as {$as}") ); } @@ -1043,13 +1048,17 @@ public function orWhereColumn(ExpressionContract|string|array $first, Expression /** * Add a vector similarity clause to the query, filtering by minimum similarity and ordering by similarity. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector * @param float $minSimilarity A value between 0.0 and 1.0, where 1.0 is identical. + * + * @throws JsonException */ public function whereVectorSimilarTo(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $minSimilarity = 0.6, bool $order = true): static { + $this->ensureConnectionSupportsVectors(); + if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } $this->whereVectorDistanceLessThan($column, $vector, 1 - $minSimilarity); @@ -1064,18 +1073,20 @@ public function whereVectorSimilarTo(ExpressionContract|string $column, Collecti /** * Add a vector distance "where" clause to the query. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function whereVectorDistanceLessThan(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $maxDistance, string $boolean = 'and'): static { $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } return $this->whereRaw( - "({$this->getGrammar()->wrap($column)} <=> ?) <= ?", + "{$this->getGrammar()->compileVectorDistanceExpression($column)} <= ?", [ json_encode( $vector instanceof Arrayable @@ -1092,7 +1103,9 @@ public function whereVectorDistanceLessThan(ExpressionContract|string $column, C /** * Add a vector distance "or where" clause to the query. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function orWhereVectorDistanceLessThan(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $maxDistance): static { @@ -2533,13 +2546,15 @@ public function oldest(Closure|self|EloquentBuilder|Relation|ExpressionContract| * Add a vector-distance "order by" clause to the query. * * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function orderByVectorDistance(ExpressionContract|string $column, Collection|Arrayable|array|string $vector): static { $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } $this->addBinding( @@ -2553,7 +2568,7 @@ public function orderByVectorDistance(ExpressionContract|string $column, Collect ); $this->{$this->unions ? 'unionOrders' : 'orders'}[] = [ - 'column' => new Expression("({$this->getGrammar()->wrap($column)} <=> ?)"), + 'column' => new Expression($this->getGrammar()->compileVectorDistanceExpression($column)), 'direction' => 'asc', ]; @@ -4197,8 +4212,8 @@ public function getConnection(): ConnectionInterface */ protected function ensureConnectionSupportsVectors(): void { - if (! $this->connection instanceof PostgresConnection) { - throw new RuntimeException('Vector distance queries are only supported by Postgres.'); + if (! $this->getGrammar()->supportsVectorDistance()) { + throw new RuntimeException('Vector distance queries are only supported by Postgres and MariaDB.'); } } diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 94776b4e14..74fafcb89d 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -697,6 +697,24 @@ public function whereExpression(Builder $query, array $where): string return $where['column']->getValue($this); } + /** + * Compile a vector distance expression for the given column. + * + * @throws RuntimeException + */ + public function compileVectorDistanceExpression(Expression|string $column): string + { + throw new RuntimeException('This database engine does not support vector distance queries.'); + } + + /** + * Determine if the grammar supports vector distance queries. + */ + public function supportsVectorDistance(): bool + { + return false; + } + /** * Compile the "group by" portions of the query. */ diff --git a/src/database/src/Query/Grammars/MariaDbGrammar.php b/src/database/src/Query/Grammars/MariaDbGrammar.php index 193ad00f65..cc9bc4b98d 100755 --- a/src/database/src/Query/Grammars/MariaDbGrammar.php +++ b/src/database/src/Query/Grammars/MariaDbGrammar.php @@ -4,6 +4,7 @@ namespace Hypervel\Database\Query\Grammars; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\JoinLateralClause; use Override; @@ -46,6 +47,22 @@ public function compileThreadCount(): string return 'select variable_value as `Value` from information_schema.global_status where variable_name = \'THREADS_CONNECTED\''; } + /** + * Compile a vector distance expression for the given column. + */ + public function compileVectorDistanceExpression(Expression|string $column): string + { + return "vec_distance_cosine({$this->wrap($column)}, vec_fromtext(?))"; + } + + /** + * Determine if the grammar supports vector distance queries. + */ + public function supportsVectorDistance(): bool + { + return true; + } + /** * Determine whether to use a legacy group limit clause for MySQL < 8.0. */ diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php index 255a2c83ae..fbda4d351f 100755 --- a/src/database/src/Query/Grammars/PostgresGrammar.php +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -4,6 +4,7 @@ namespace Hypervel\Database\Query\Grammars; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\JoinLateralClause; use Hypervel\Support\Arr; @@ -197,6 +198,22 @@ protected function validFullTextLanguages(): array ]; } + /** + * Compile a vector distance expression for the given column. + */ + public function compileVectorDistanceExpression(Expression|string $column): string + { + return "({$this->wrap($column)} <=> ?)"; + } + + /** + * Determine if the grammar supports vector distance queries. + */ + public function supportsVectorDistance(): bool + { + return true; + } + /** * Compile the "select *" portion of the query. */ diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index d49afced78..d02fc4a485 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -6,6 +6,7 @@ - [Defining a Mutator](#defining-a-mutator) - [Attribute Casting](#attribute-casting) - [Array and JSON Casting](#array-and-json-casting) + - [Vector Casting](#vector-casting) - [Binary Casting](#binary-casting) - [Date Casting](#date-casting) - [Enum Casting](#enum-casting) @@ -228,6 +229,7 @@ The `casts` method should return an array where the key is the name of the attri - `AsHtmlString::class` - `AsStringable::class` - `AsUri::class` +- `AsVector::class` - `boolean` - `collection` - `date` @@ -580,6 +582,29 @@ class Option implements Arrayable, JsonSerializable } ``` + +### Vector Casting + +You may use the `Hypervel\Database\Eloquent\Casts\AsVector` cast class to cast a database vector column to and from a PHP array: + +```php +use Hypervel\Database\Eloquent\Casts\AsVector; + +/** + * Get the attributes that should be cast. + * + * @return array + */ +protected function casts(): array +{ + return [ + 'embedding' => AsVector::class, + ]; +} +``` + +When setting the attribute, the cast accepts a PHP array or an `Arrayable` instance, such as a Hypervel collection. When retrieving the attribute, the cast returns an array of floats. + ### Binary Casting diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 1a5fa7f6ff..aa4cdcd08e 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -1554,7 +1554,7 @@ Hypervel's schema builder blueprint class provides methods for creating each typ -When using PostgreSQL, chaining `index` onto a `vector` column definition will create a vector index instead of a regular index. +Chaining `index` onto a `vector` column definition will create a vector index instead of a regular index. #### Online Index Creation diff --git a/src/docs/queries.md b/src/docs/queries.md index ac61025c9e..e70c570dce 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -1302,7 +1302,7 @@ $users = DB::table('users') ### Vector Similarity Clauses > [!NOTE] -> Vector similarity clauses are currently only supported on PostgreSQL connections using the `pgvector` extension. For information on defining vector columns and indexes, consult the [migration documentation](/docs/{{version}}/migrations#available-column-types). +> Vector similarity clauses are currently supported on PostgreSQL connections using the `pgvector` extension and MariaDB 11.7 or later. For information on defining vector columns and indexes, consult the [migration documentation](/docs/{{version}}/migrations#available-column-types). The `whereVectorSimilarTo` method filters results by cosine similarity to a given vector and orders the results by relevance. The `minSimilarity` threshold should be a value between `0.0` and `1.0`, where `1.0` is identical: diff --git a/src/docs/search.md b/src/docs/search.md index e7df411ba3..bc174ad86a 100644 --- a/src/docs/search.md +++ b/src/docs/search.md @@ -30,7 +30,7 @@ When you need keyword relevance ranking — where the database scores and sorts #### Semantic / Vector Search -For semantic search that matches results by *meaning* rather than exact keywords, the `whereVectorSimilarTo` query builder method uses vector embeddings stored in PostgreSQL with the `pgvector` extension. For example, a search for "best wineries in Napa Valley" can surface an article titled "Top Vineyards to Visit" — even though the words don't overlap. Vector search requires PostgreSQL with the `pgvector` extension and pre-computed vector embeddings. +For semantic search that matches results by *meaning* rather than exact keywords, the `whereVectorSimilarTo` query builder method uses vector embeddings stored in PostgreSQL with the `pgvector` extension or MariaDB. For example, a search for "best wineries in Napa Valley" can surface an article titled "Top Vineyards to Visit" — even though the words don't overlap. Vector search requires PostgreSQL with the `pgvector` extension or MariaDB 11.7 or later, as well as pre-computed vector embeddings. #### Hypervel Scout Search @@ -100,7 +100,7 @@ Full-text search relies on matching keywords — the words in the query must app The basic workflow for vector search is: generate an embedding (a numeric array) for each piece of content and store it alongside your data, then at search time, generate an embedding for the user's query and find the stored embeddings that are closest to it in vector space. Hypervel does not generate embeddings for you; provide pre-computed vectors from your own embedding pipeline or provider. > [!NOTE] -> Vector search requires a PostgreSQL database with the `pgvector` extension. +> Vector search is supported by PostgreSQL with the `pgvector` extension and MariaDB 11.7 or later. ### Storing and Indexing Vectors @@ -108,7 +108,7 @@ The basic workflow for vector search is: generate an embedding (a numeric array) To store vector embeddings, define a `vector` column in your migration, specifying the number of dimensions in your vectors. You should also call `index` on the column to create an HNSW (Hierarchical Navigable Small World) index, which dramatically speeds up similarity searches on large datasets: ```php -Schema::ensureVectorExtensionExists(); +Schema::ensureVectorExtensionExists(); // PostgreSQL only. Schema::create('documents', function (Blueprint $table) { $table->id(); @@ -119,15 +119,17 @@ Schema::create('documents', function (Blueprint $table) { }); ``` -The `Schema::ensureVectorExtensionExists` method ensures the `pgvector` extension is enabled on your PostgreSQL database before creating the table. +The `Schema::ensureVectorExtensionExists` method ensures the `pgvector` extension is enabled on your PostgreSQL database before creating the table. Omit this call when using MariaDB. -On your Eloquent model, cast the vector column to an `array` so that Hypervel automatically handles the conversion between PHP arrays and the database's vector format: +On your Eloquent model, use the `AsVector` cast so that Hypervel automatically handles the conversion between PHP arrays and the database's vector format: ```php +use Hypervel\Database\Eloquent\Casts\AsVector; + protected function casts(): array { return [ - 'embedding' => 'array', + 'embedding' => AsVector::class, ]; } ``` diff --git a/tests/Database/DatabaseEloquentAsVectorCastTest.php b/tests/Database/DatabaseEloquentAsVectorCastTest.php new file mode 100644 index 0000000000..1cbd4b537c --- /dev/null +++ b/tests/Database/DatabaseEloquentAsVectorCastTest.php @@ -0,0 +1,174 @@ +useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => pack('g*', 0.5, -1.25, 3)]); + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testGetDecodesBinaryVectorBeginningWithOpeningBracketByte(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => pack('g*', 1.0000108480453491)]); + + $this->assertSame([1.0000108480453491], $model->embedding); + } + + public function testGetDecodesTextVector(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => '[0.5,-1.25,3]']); + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testGetReturnsNullForNullValue(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => null]); + + $this->assertNull($model->embedding); + } + + public function testSetOnMariaDbWrapsVectorInVecFromText(): void + { + $grammar = $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3.75]; + + $attribute = $model->getAttributes()['embedding']; + + $this->assertInstanceOf(Expression::class, $attribute); + $this->assertSame("vec_fromtext('[0.5,-1.25,3.75]')", $attribute->getValue($grammar)); + } + + public function testSetOnPostgresStoresJson(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3.75]; + + $this->assertSame('[0.5,-1.25,3.75]', $model->getAttributes()['embedding']); + } + + public function testSetAcceptsArrayable(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = new Collection([0.5, -1.25, 3.75]); + + $this->assertSame('[0.5,-1.25,3.75]', $model->getAttributes()['embedding']); + $this->assertSame([0.5, -1.25, 3.75], $model->embedding); + } + + public function testSetAcceptsArrayableOnMariaDb(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = new Collection([0.5, -1.25, 3]); + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testSetStoresNullAsNull(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = null; + + $this->assertNull($model->getAttributes()['embedding']); + } + + public function testSetRejectsNonArrayValues(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The [embedding] attribute must be an array of floats or an Arrayable instance.'); + + $model = new AsVectorTestModel; + $model->embedding = 'not a vector'; + } + + public function testVectorCanBeReadBackBeforeSavingOnMariaDb(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3]; + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testVectorCanBeReadBackBeforeSavingOnPostgres(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3]; + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + /** + * Use the given query grammar for model connections. + * + * @param class-string $grammar + */ + protected function useGrammar(string $grammar): Grammar + { + $connection = m::mock(Connection::class); + $grammar = new $grammar($connection); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->andReturn($connection); + + Model::setConnectionResolver($resolver); + + return $grammar; + } +} + +class AsVectorTestModel extends Model +{ + protected array $guarded = []; + + protected array $casts = [ + 'embedding' => AsVector::class, + ]; +} diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index ae715f1111..ab85160738 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -1762,6 +1762,18 @@ public function testWithCountAndRename() $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar" from "model_parent_stubs"', $builder->toSql()); } + public function testWithCountWithConstrainedDottedAlias(): void + { + $model = new ModelParentStub; + + $builder = $model->withCount(['foo as a.b' => function ($query): void { + $query->where('active', true); + }]); + + $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where ("model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") and ("active" = ?)) as "a.b" from "model_parent_stubs"', $builder->toSql()); + $this->assertSame([true], $builder->getBindings()); + } + public function testWithCountMultipleAndPartialRename() { $model = new ModelParentStub; @@ -1909,6 +1921,18 @@ public function testWithExistsAndRename() $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar" from "model_parent_stubs"', $builder->toSql()); } + public function testWithExistsWithLiteralAliases(): void + { + foreach (['a.b', 'data->x'] as $alias) { + $model = new ModelParentStub; + + $builder = $model->withExists('foo as ' . $alias); + + $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "' . $alias . '" from "model_parent_stubs"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + } + public function testWithExistsMultipleAndPartialRename() { $model = new ModelParentStub; diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 54ba5e3193..002b671352 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -6185,6 +6185,25 @@ public function testSelectExpression() $this->assertSame('select (1 + 1) as "expr" from "one"', $builder->toSql()); } + public function testSelectionAliasesAreSingleIdentifiers(): void + { + foreach (['a.b', 'x as y', 'data->x'] as $alias) { + $builder = $this->getPostgresBuilder('prefix_'); + $builder->from('one')->selectSub(function ($query): void { + $query->select('value')->from('two')->where('id', 1); + }, $alias); + + $this->assertSame('select (select "value" from "prefix_two" where "id" = ?) as "' . $alias . '" from "prefix_one"', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getPostgresBuilder('prefix_'); + $builder->from('one')->selectExpression(new Raw('1 + 1'), $alias); + + $this->assertSame('select (1 + 1) as "' . $alias . '" from "prefix_one"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + } + public function testSelectWithAliasedExpression() { $builder = $this->getBuilder(); @@ -7828,6 +7847,129 @@ public function testCloneWithoutBindings() $this->assertEquals([], $clone->getBindings()); } + public function testWhereVectorSimilarToOnPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('documents')->whereVectorSimilarTo('embedding', [1, 2, 3], minSimilarity: 0.4)->limit(10); + + $this->assertSame( + 'select * from "documents" where ("embedding" <=> ?) <= ? order by ("embedding" <=> ?) asc limit 10', + $builder->toSql() + ); + $this->assertSame(['[1,2,3]', 0.6, '[1,2,3]'], $builder->getBindings()); + } + + public function testWhereVectorSimilarToOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->select('*')->from('documents')->whereVectorSimilarTo('embedding', [1, 2, 3], minSimilarity: 0.4)->limit(10); + + $this->assertSame( + 'select * from `documents` where vec_distance_cosine(`embedding`, vec_fromtext(?)) <= ? order by vec_distance_cosine(`embedding`, vec_fromtext(?)) asc limit 10', + $builder->toSql() + ); + $this->assertSame(['[1,2,3]', 0.6, '[1,2,3]'], $builder->getBindings()); + } + + public function testWhereVectorSimilarToThrowsOnUnsupportedGrammar(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Vector distance queries are only supported by Postgres and MariaDB.'); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('documents')->whereVectorSimilarTo('embedding', [1, 2, 3]); + } + + public function testWhereVectorSimilarToRejectsUnsupportedGrammarBeforeGeneratingEmbeddings(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Vector distance queries are only supported by Postgres and MariaDB.'); + + $builder = $this->getMySqlBuilder(); + $builder->from('documents')->whereVectorSimilarTo('embedding', 'best wineries in Napa Valley'); + } + + public function testWhereVectorDistanceLessThanOnPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('documents')->whereVectorDistanceLessThan('embedding', [1, 2, 3], 0.5); + + $this->assertSame('select * from "documents" where ("embedding" <=> ?) <= ?', $builder->toSql()); + $this->assertSame(['[1,2,3]', 0.5], $builder->getBindings()); + } + + public function testWhereVectorDistanceLessThanOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->select('*')->from('documents')->whereVectorDistanceLessThan('embedding', [1, 2, 3], 0.5); + + $this->assertSame('select * from `documents` where vec_distance_cosine(`embedding`, vec_fromtext(?)) <= ?', $builder->toSql()); + $this->assertSame(['[1,2,3]', 0.5], $builder->getBindings()); + } + + public function testOrderByVectorDistanceOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->select('*')->from('documents')->orderByVectorDistance('embedding', [1, 2, 3]); + + $this->assertSame('select * from `documents` order by vec_distance_cosine(`embedding`, vec_fromtext(?)) asc', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + + public function testSelectVectorDistanceOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->from('documents')->selectVectorDistance('embedding', [1, 2, 3]); + + $this->assertSame('select vec_distance_cosine(`embedding`, vec_fromtext(?)) as `embedding_distance` from `documents`', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + + public function testSelectVectorDistanceWithQualifiedColumnsAndExpressions(): void + { + foreach ([ + ['documents.embedding', '"documents"."embedding"', '`documents`.`embedding`'], + [new Raw('embedding'), 'embedding', 'embedding'], + [new Raw('documents.embedding'), 'documents.embedding', 'documents.embedding'], + ] as [$column, $postgresColumn, $mariaDbColumn]) { + $builder = $this->getPostgresBuilder(); + $builder->from('documents')->selectVectorDistance($column, [1, 2, 3]); + + $this->assertSame('select (' . $postgresColumn . ' <=> ?) as "embedding_distance" from "documents"', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + + $builder = $this->getMariaDbBuilder(); + $builder->from('documents')->selectVectorDistance($column, [1, 2, 3]); + + $this->assertSame('select vec_distance_cosine(' . $mariaDbColumn . ', vec_fromtext(?)) as `embedding_distance` from `documents`', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + } + + public function testSelectVectorDistanceWithCastExpression(): void + { + $builder = $this->getPostgresBuilder(); + $builder->from('documents')->selectVectorDistance(new Raw('CAST(documents.embedding AS vector)'), [1, 2, 3]); + + $this->assertSame('select (CAST(documents.embedding AS vector) <=> ?) as "embedding AS vector)_distance" from "documents"', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + + public function testSelectVectorDistanceWithExplicitAlias(): void + { + $builder = $this->getPostgresBuilder('prefix_'); + $builder->from('documents')->selectVectorDistance('embedding', [1, 2, 3], 'a.b'); + + $this->assertSame('select ("embedding" <=> ?) as "a.b" from "prefix_documents"', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + + $builder = $this->getMariaDbBuilder('prefix_'); + $builder->from('documents')->selectVectorDistance('embedding', [1, 2, 3], 'a.b'); + + $this->assertSame('select vec_distance_cosine(`embedding`, vec_fromtext(?)) as `a.b` from `prefix_documents`', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + public function testToRawSql() { $connection = $this->getConnection(); diff --git a/tests/Integration/Database/MariaDb/EloquentVectorTest.php b/tests/Integration/Database/MariaDb/EloquentVectorTest.php new file mode 100644 index 0000000000..0b294a0d43 --- /dev/null +++ b/tests/Integration/Database/MariaDb/EloquentVectorTest.php @@ -0,0 +1,82 @@ +=11.7.0')] +class EloquentVectorTest extends MariaDbTestCase +{ + /** + * Create the vector storage schema. + */ + protected function afterRefreshingDatabase(): void + { + Schema::create('documents', function (Blueprint $table): void { + $table->increments('id'); + $table->vector('embedding', 3); + $table->vectorIndex('embedding'); + }); + } + + /** + * Remove the vector storage schema. + */ + protected function destroyDatabaseMigrations(): void + { + Schema::dropIfExists('documents'); + } + + public function testVectorsCanBeStoredAndRetrieved(): void + { + $document = VectorDocument::create(['embedding' => [0.5, -1.25, 3]]); + + $this->assertSame([0.5, -1.25, 3.0], $document->embedding); + $this->assertSame([0.5, -1.25, 3.0], $document->fresh()->embedding); + } + + public function testVectorsCanBeUpdated(): void + { + $document = VectorDocument::create(['embedding' => [0.5, -1.25, 3]]); + + $document->update(['embedding' => [1, 2, 3]]); + + $this->assertSame([1.0, 2.0, 3.0], $document->fresh()->embedding); + } + + public function testVectorsCanBeQueriedByDistance(): void + { + $exact = VectorDocument::create(['embedding' => [1, 0, 0]]); + $close = VectorDocument::create(['embedding' => [0.9, 0.1, 0]]); + VectorDocument::create(['embedding' => [0, 1, 0]]); + + $results = VectorDocument::query() + ->select('id') + ->selectVectorDistance('embedding', [1, 0, 0]) + ->whereVectorSimilarTo('embedding', [1, 0, 0], minSimilarity: 0.5) + ->get(); + + $this->assertSame([$exact->id, $close->id], $results->pluck('id')->all()); + $this->assertEqualsWithDelta(0.0, (float) $results[0]->embedding_distance, 0.0001); + $this->assertGreaterThan(0.0, (float) $results[1]->embedding_distance); + } +} + +class VectorDocument extends Model +{ + protected ?string $table = 'documents'; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected array $casts = [ + 'embedding' => AsVector::class, + ]; +} From 86efebcedfab72ecdb83de2c0b9980dfbb6a2623 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:05:03 +0000 Subject: [PATCH 02/41] Use direct Stringable construction internally Complete Laravel framework PR #60852 using source revision 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Replace the remaining internal helper calls with direct construction and use the negative punctuation predicate. The public string helpers and their behavior remain unchanged. Preserve nullable application URLs, narrow terminal widths, generated view class casing, console cancellation handling and native Stringable interfaces. Query Builder substitutions were completed with the vector port; JSON:API and view compiler behavior already match, and Laravel Cloud integration remains unsupported. Validated the affected console, generator, mail and support suites with ParaTest, full source and type-fixture PHPStan, formatting, and generated facade consistency. Upstream: https://github.com/laravel/framework/pull/60852 --- src/console/src/QuestionHelper.php | 2 +- .../View/Components/Mutators/EnsurePunctuation.php | 2 +- src/foundation/src/Console/AboutCommand.php | 2 +- src/foundation/src/Console/DevListCommand.php | 3 ++- src/foundation/src/Console/ModelMakeCommand.php | 3 ++- src/foundation/src/Console/ViewMakeCommand.php | 14 +++++++------- src/foundation/src/Exceptions/Handler.php | 4 ++-- src/mail/src/Mailables/Headers.php | 4 ++-- src/mail/src/Transport/LogTransport.php | 7 +++++-- src/support/src/ServiceProvider.php | 5 +++-- src/support/src/Str.php | 4 ++-- src/support/src/Traits/InteractsWithData.php | 3 +-- src/support/src/Uri.php | 2 +- 13 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/console/src/QuestionHelper.php b/src/console/src/QuestionHelper.php index 0356ae938a..70158ec337 100644 --- a/src/console/src/QuestionHelper.php +++ b/src/console/src/QuestionHelper.php @@ -69,7 +69,7 @@ protected function writePrompt(OutputInterface $output, Question $question): voi */ protected function ensureEndsWithPunctuation(string $string): string { - if (! (new Stringable($string))->endsWith(['?', ':', '!', '.'])) { + if ((new Stringable($string))->doesntEndWith(['?', ':', '!', '.'])) { return "{$string}:"; } diff --git a/src/console/src/View/Components/Mutators/EnsurePunctuation.php b/src/console/src/View/Components/Mutators/EnsurePunctuation.php index 14820cd225..d8c16f1911 100644 --- a/src/console/src/View/Components/Mutators/EnsurePunctuation.php +++ b/src/console/src/View/Components/Mutators/EnsurePunctuation.php @@ -13,7 +13,7 @@ class EnsurePunctuation */ public function __invoke(string $string): string { - if (! (new Stringable($string))->endsWith(['.', '?', '!', ':'])) { + if ((new Stringable($string))->doesntEndWith(['.', '?', '!', ':'])) { return "{$string}."; } diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 4eec398fb3..1adaf3afe9 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -140,7 +140,7 @@ protected function gatherApplicationInformation(): void 'Composer Version' => $this->composer->getVersion() ?? '-', 'Environment' => $this->hypervel->environment(), 'Debug Mode' => static::format(config()->boolean('app.debug'), console: $formatEnabledStatus), - 'URL' => Str::of(config('app.url'))->replace(['http://', 'https://'], ''), + 'URL' => (new Stringable(config('app.url')))->replace(['http://', 'https://'], ''), 'Maintenance Mode' => static::format($this->hypervel->isDownForMaintenance(), console: $formatEnabledStatus), 'Timezone' => config()->string('app.timezone'), 'Locale' => config()->string('app.locale'), diff --git a/src/foundation/src/Console/DevListCommand.php b/src/foundation/src/Console/DevListCommand.php index f3bf3ecfbd..b2bccf9497 100644 --- a/src/foundation/src/Console/DevListCommand.php +++ b/src/foundation/src/Console/DevListCommand.php @@ -8,6 +8,7 @@ use Hypervel\Foundation\DevCommand; use Hypervel\Foundation\DevCommands; use Hypervel\Prompts\Prompt; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -75,7 +76,7 @@ public function handle(): int ); $source = $availableSourceWidth >= 2 - ? str($source)->limit($availableSourceWidth - 1, '…')->toString() + ? (new Stringable($source))->limit($availableSourceWidth - 1, '…')->value() : ''; } diff --git a/src/foundation/src/Console/ModelMakeCommand.php b/src/foundation/src/Console/ModelMakeCommand.php index 3f6e1393b5..805dbc669f 100644 --- a/src/foundation/src/Console/ModelMakeCommand.php +++ b/src/foundation/src/Console/ModelMakeCommand.php @@ -8,6 +8,7 @@ use Hypervel\Console\GeneratorCommand; use Hypervel\Support\Collection; use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -236,7 +237,7 @@ protected function buildFactoryReplacements(): array $replacements = []; if ($this->option('factory') || $this->option('all')) { - $modelPath = Str::of($this->argument('name'))->studly()->replace('/', '\\')->toString(); + $modelPath = (new Stringable($this->argument('name')))->studly()->replace('/', '\\')->toString(); $factoryNamespace = '\Database\Factories\\' . $modelPath . 'Factory'; diff --git a/src/foundation/src/Console/ViewMakeCommand.php b/src/foundation/src/Console/ViewMakeCommand.php index 53fdc2b99b..22f9ced6ee 100644 --- a/src/foundation/src/Console/ViewMakeCommand.php +++ b/src/foundation/src/Console/ViewMakeCommand.php @@ -95,7 +95,7 @@ protected function resolveStubPath(string $stub): string protected function getTestPath(): string { return base_path( - Str::of($this->testClassFullyQualifiedName()) + (new Stringable($this->testClassFullyQualifiedName())) ->replace('\\', '/') ->replaceFirst('Tests/Feature', 'tests/Feature') ->append('Test.php') @@ -132,7 +132,7 @@ protected function handleTestCreation(string $path): bool */ protected function testNamespace(): string { - return Str::of($this->testClassFullyQualifiedName()) + return (new Stringable($this->testClassFullyQualifiedName())) ->beforeLast('\\') ->value(); } @@ -142,7 +142,7 @@ protected function testNamespace(): string */ protected function testClassName(): string { - return Str::of($this->testClassFullyQualifiedName()) + return (new Stringable($this->testClassFullyQualifiedName())) ->afterLast('\\') ->append('Test') ->value(); @@ -153,15 +153,15 @@ protected function testClassName(): string */ protected function testClassFullyQualifiedName(): string { - $name = Str::of(Str::lower($this->getNameInput()))->replace('.' . $this->option('extension'), ''); + $name = (new Stringable(Str::lower($this->getNameInput())))->replace('.' . $this->option('extension'), ''); - $namespacedName = Str::of( + $namespacedName = (new Stringable( (new Stringable($name)) ->replace('/', ' ') ->explode(' ') ->map(fn ($part) => (new Stringable($part))->ucfirst()) ->implode('\\') - ) + )) ->replace(['-', '_'], ' ') ->explode(' ') ->map(fn ($part) => (new Stringable($part))->ucfirst()) @@ -187,7 +187,7 @@ protected function getTestStub(): string */ protected function testViewName(): string { - return Str::of($this->getNameInput()) + return (new Stringable($this->getNameInput())) ->replace('/', '.') ->lower() ->value(); diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index af0a06a4ba..4f00b4ac2f 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -41,7 +41,7 @@ use Hypervel\Support\Facades\Auth; use Hypervel\Support\Lottery; use Hypervel\Support\Reflector; -use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Hypervel\Support\Traits\ReflectsClosures; use Hypervel\Support\ViewErrorBag; use Hypervel\Validation\ValidationException; @@ -1160,7 +1160,7 @@ public function renderForConsole(OutputInterface $output, Throwable $e): void } if ($e instanceof CommandNotFoundException) { - $message = Str::of($e->getMessage())->explode('.')->first(); + $message = (new Stringable($e->getMessage()))->explode('.')->first(); if (! empty($alternatives = $e->getAlternatives())) { $message .= '. Did you mean one of these?'; diff --git a/src/mail/src/Mailables/Headers.php b/src/mail/src/Mailables/Headers.php index 6f4c524b70..a9b811919a 100644 --- a/src/mail/src/Mailables/Headers.php +++ b/src/mail/src/Mailables/Headers.php @@ -5,7 +5,7 @@ namespace Hypervel\Mail\Mailables; use Hypervel\Support\Collection; -use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Hypervel\Support\Traits\Conditionable; class Headers @@ -62,7 +62,7 @@ public function text(array $text): static public function referencesString(): string { return (new Collection($this->references)) - ->map(fn ($messageId) => Str::of($messageId)->start('<')->finish('>')->value()) + ->map(fn ($messageId) => (new Stringable($messageId))->start('<')->finish('>')->value()) ->implode(' '); } } diff --git a/src/mail/src/Transport/LogTransport.php b/src/mail/src/Transport/LogTransport.php index accde79a06..f8b6d74f21 100644 --- a/src/mail/src/Transport/LogTransport.php +++ b/src/mail/src/Transport/LogTransport.php @@ -4,7 +4,7 @@ namespace Hypervel\Mail\Transport; -use Hypervel\Support\Str; +use Hypervel\Support\Stringable as SupportStringable; use Psr\Log\LoggerInterface; use Stringable; use Symfony\Component\Mailer\Envelope; @@ -22,9 +22,12 @@ public function __construct( ) { } + /** + * Send the given message. + */ public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage { - $string = Str::of($message->toString()); + $string = new SupportStringable($message->toString()); if ($string->contains('Content-Type: multipart/')) { $boundary = $string diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index 895abf61a9..9872490f61 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -553,12 +553,13 @@ protected function reloads(string $reload, ?string $key = null): void */ protected function getProviderKey(?string $key = null): string { - $key ??= (string) Str::of(get_class($this)) + $key ??= (new Stringable(get_class($this))) ->classBasename() ->before('ServiceProvider') ->kebab() ->lower() - ->trim(); + ->trim() + ->value(); if (empty($key)) { $key = class_basename(get_class($this)); diff --git a/src/support/src/Str.php b/src/support/src/Str.php index f907508db2..7aca7afc42 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -397,14 +397,14 @@ public static function excerpt(string|int|float|bool|BaseStringable|null $text, $start = ltrim($matches[1]); - $start = Str::of(mb_substr($start, max(mb_strlen($start, 'UTF-8') - $radius, 0), $radius, 'UTF-8'))->ltrim()->unless( + $start = (new Stringable(mb_substr($start, max(mb_strlen($start, 'UTF-8') - $radius, 0), $radius, 'UTF-8')))->ltrim()->unless( fn ($startWithRadius) => $startWithRadius->exactly($start), fn ($startWithRadius) => $startWithRadius->prepend($omission), ); $end = rtrim($matches[3]); - $end = Str::of(mb_substr($end, 0, $radius, 'UTF-8'))->rtrim()->unless( + $end = (new Stringable(mb_substr($end, 0, $radius, 'UTF-8')))->rtrim()->unless( fn ($endWithRadius) => $endWithRadius->exactly($end), fn ($endWithRadius) => $endWithRadius->append($omission), ); diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 16bf85150f..2d5629fc30 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -11,7 +11,6 @@ use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; use Hypervel\Support\Number; -use Hypervel\Support\Str; use Hypervel\Support\Stringable; use stdClass; use Stringable as BaseStringable; @@ -249,7 +248,7 @@ public function str(string $key, mixed $default = null): Stringable */ public function string(string $key, mixed $default = null): Stringable { - return Str::of($this->data($key, $default)); + return new Stringable($this->data($key, $default)); } /** diff --git a/src/support/src/Uri.php b/src/support/src/Uri.php index 1c0ec4d93f..0906e3a46d 100644 --- a/src/support/src/Uri.php +++ b/src/support/src/Uri.php @@ -342,7 +342,7 @@ public function redirect(int $status = 302, array $headers = []): RedirectRespon */ public function toStringable(): Stringable { - return Str::of($this->value()); + return new Stringable($this->value()); } /** From 9d3294b853eea9156eea547c7493c138a92c0dd9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:05:21 +0000 Subject: [PATCH 03/41] Preserve health-page CSS when Blade theme directives exist Complete the Tailwind health-view update from Laravel framework PR #58344 with the literal directive escape from PR #60340, using source revision 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Tailwind v4 was already present, but an application-defined theme directive could consume the CSS rule during Blade compilation. Use the normal Blade escape so the browser receives the literal CSS at-rule. Add a real health-route regression with compiled-view caching disabled for that test, preventing an older artifact from hiding the collision. The regression fails on the original template and passes with the escape; all existing health responses remain covered. Validated the health test file, affected ParaTest suites, full PHPStan source and type checks, and formatting. Upstream: https://github.com/laravel/framework/pull/58344 Upstream fix: https://github.com/laravel/framework/pull/60340 --- src/foundation/src/resources/health-up.blade.php | 2 +- .../Providers/RouteServiceProviderHealthTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/foundation/src/resources/health-up.blade.php b/src/foundation/src/resources/health-up.blade.php index 10843b1128..964ffb6d54 100644 --- a/src/foundation/src/resources/health-up.blade.php +++ b/src/foundation/src/resources/health-up.blade.php @@ -14,7 +14,7 @@ diff --git a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php index bc8020690f..3bb7752919 100644 --- a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php +++ b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php @@ -8,6 +8,7 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\Events\DiagnosingHealth; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\Event; use Hypervel\Support\Str; use Hypervel\Testbench\Attributes\WithConfig; @@ -45,6 +46,16 @@ public function testItCanLoadHealthPage(): void ->assertSee('Application up'); } + #[WithConfig('view.cache', false)] + public function testItPreservesTheCssThemeWhenACustomDirectiveIsRegistered(): void + { + Blade::directive('theme', static fn (): string => '/* Application theme directive */'); + + $this->get('/up') + ->assertOk() + ->assertSee('@theme {', false); + } + public function testItRendersTheCurrentRequestDuration(): void { CarbonImmutable::setTestNow('2026-08-06 12:00:00 UTC'); From 7e9b60350abc66ea066cd2b06ea5fc5bf6e3d44b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:40:07 +0000 Subject: [PATCH 04/41] Fix decimal number summaries and complete Number typing and coverage Keep small fractions at their original scale and omit the minus sign when rounding a negative magnitude to zero. Compare against zero at the same precision and locale so formatted zeros such as 0.00 and 0,00 agree. Preserve callback return types through withLocale and withCurrency while retaining Hypervel's coroutine-local overrides and exact finally cleanup. Correct formatting failure return types and document the list-of-tuples result from pairs. Preserve explicit integer exponent normalization. Complete the upstream regression cases for decimal formatting, rounding across unit boundaries, negative file sizes, nonfinite values, and pair steps. Keep the existing Hypervel tests and consistently type the touched test class. No new state, parser, cache, or public API is introduced. Ported from Laravel framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58358 https://github.com/laravel/framework/pull/58408 https://github.com/laravel/framework/pull/58409 https://github.com/laravel/framework/pull/59692 https://github.com/laravel/framework/pull/60147 https://github.com/laravel/framework/pull/60263 https://github.com/laravel/framework/pull/60322 https://github.com/laravel/framework/pull/60324 https://github.com/laravel/framework/pull/60617 https://github.com/laravel/framework/pull/60625 https://github.com/laravel/framework/pull/60736 https://github.com/laravel/framework/pull/60768 Validated the decimal regressions before and after the source corrections, both Number test classes through ParaTest, formatting, full PHPStan and type fixtures. Final review corrections passed the affected test class and targeted formatting check. --- src/support/src/Number.php | 32 ++++++-- tests/Support/SupportNumberTest.php | 117 ++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/support/src/Number.php b/src/support/src/Number.php index e0cec1f034..0896c38400 100644 --- a/src/support/src/Number.php +++ b/src/support/src/Number.php @@ -95,7 +95,7 @@ public static function parseFloat(string $string, ?string $locale = null): float /** * Spell out the given number in the given locale. */ - public static function spell(float|int $number, ?string $locale = null, ?int $after = null, ?int $until = null): string + public static function spell(float|int $number, ?string $locale = null, ?int $after = null, ?int $until = null): false|string { static::ensureIntlExtensionIsInstalled(); @@ -115,7 +115,7 @@ public static function spell(float|int $number, ?string $locale = null, ?int $af /** * Spell out the given number in the given locale in ordinal form. */ - public static function spellOrdinal(float|int $number, ?string $locale = null): string + public static function spellOrdinal(float|int $number, ?string $locale = null): false|string { static::ensureIntlExtensionIsInstalled(); @@ -129,7 +129,7 @@ public static function spellOrdinal(float|int $number, ?string $locale = null): /** * Convert the given number to ordinal form. */ - public static function ordinal(float|int $number, ?string $locale = null): string + public static function ordinal(float|int $number, ?string $locale = null): false|string { static::ensureIntlExtensionIsInstalled(); @@ -195,7 +195,7 @@ public static function fileSize(float|int $bytes, int $precision = 0, ?int $maxP /** * Convert the number to its human-readable equivalent. */ - public static function abbreviate(float|int $number, int $precision = 0, ?int $maxPrecision = null): bool|string + public static function abbreviate(float|int $number, int $precision = 0, ?int $maxPrecision = null): false|string { return static::forHumans($number, $precision, $maxPrecision, abbreviate: true); } @@ -245,14 +245,20 @@ protected static function summarize(float|int $number, int $precision = 0, ?int case (float) $number === 0.0: return $precision > 0 ? static::format(0, $precision, $maxPrecision) : '0'; case $number < 0: - return sprintf('-%s', static::summarize(abs($number), $precision, $maxPrecision, $units)); + $summary = static::summarize(abs($number), $precision, $maxPrecision, $units); + + // Compare with zero at the same precision and locale so a magnitude + // that rounds to zero does not retain its minus sign. + return $summary === static::summarize(0, $precision, $maxPrecision, $units) + ? $summary + : sprintf('-%s', $summary); case $number >= 1e15: return sprintf('%s' . end($units), static::summarize($number / 1e15, $precision, $maxPrecision, $units)); } $numberExponent = (int) floor(log10($number)); - $displayExponent = $numberExponent - ($numberExponent % 3); - $number /= pow(10, $displayExponent); + $displayExponent = max(0, $numberExponent - ($numberExponent % 3)); + $number /= 10 ** $displayExponent; $formatted = static::format($number, $precision, $maxPrecision); @@ -275,6 +281,8 @@ public static function clamp(float|int $number, float|int $min, float|int $max): /** * Split the given number into pairs of min/max values. + * + * @return list */ public static function pairs(float|int $to, float|int $by, float|int $start = 0, float|int $offset = 1): array { @@ -313,6 +321,11 @@ public static function trim(float|int $number): float|int /** * Execute the given callback using the given locale. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public static function withLocale(string $locale, callable $callback): mixed { @@ -333,6 +346,11 @@ public static function withLocale(string $locale, callable $callback): mixed /** * Execute the given callback using the given currency. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public static function withCurrency(string $currency, callable $callback): mixed { diff --git a/tests/Support/SupportNumberTest.php b/tests/Support/SupportNumberTest.php index f9eda84440..6e45cedb29 100644 --- a/tests/Support/SupportNumberTest.php +++ b/tests/Support/SupportNumberTest.php @@ -6,22 +6,23 @@ use Hypervel\Support\Number; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\RequiresPhpExtension; class SupportNumberTest extends TestCase { - public function testDefaultLocale() + public function testDefaultLocale(): void { $this->assertSame('en', Number::defaultLocale()); } - public function testDefaultCurrency() + public function testDefaultCurrency(): void { $this->assertSame('USD', Number::defaultCurrency()); } #[RequiresPhpExtension('intl')] - public function testFormat() + public function testFormat(): void { $this->assertSame('0', Number::format(0)); $this->assertSame('0', Number::format(0.0)); @@ -53,7 +54,7 @@ public function testFormat() } #[RequiresPhpExtension('intl')] - public function testFormatWithDifferentLocale() + public function testFormatWithDifferentLocale(): void { $this->assertSame('123,456,789', Number::format(123456789, locale: 'en')); $this->assertSame('123.456.789', Number::format(123456789, locale: 'de')); @@ -63,7 +64,7 @@ public function testFormatWithDifferentLocale() } #[RequiresPhpExtension('intl')] - public function testFormatWithAppLocale() + public function testFormatWithAppLocale(): void { $this->assertSame('123,456,789', Number::format(123456789)); @@ -75,20 +76,20 @@ public function testFormatWithAppLocale() } #[RequiresPhpExtension('intl')] - public function testSpellout() + public function testSpellout(): void { $this->assertSame('ten', Number::spell(10)); $this->assertSame('one point two', Number::spell(1.2)); } #[RequiresPhpExtension('intl')] - public function testSpelloutWithLocale() + public function testSpelloutWithLocale(): void { $this->assertSame('trois', Number::spell(3, 'fr')); } #[RequiresPhpExtension('intl')] - public function testSpelloutWithThreshold() + public function testSpelloutWithThreshold(): void { $this->assertSame('9', Number::spell(9, after: 10)); $this->assertSame('10', Number::spell(10, after: 10)); @@ -103,7 +104,7 @@ public function testSpelloutWithThreshold() } #[RequiresPhpExtension('intl')] - public function testOrdinal() + public function testOrdinal(): void { $this->assertSame('1st', Number::ordinal(1)); $this->assertSame('2nd', Number::ordinal(2)); @@ -111,7 +112,7 @@ public function testOrdinal() } #[RequiresPhpExtension('intl')] - public function testSpellOrdinal() + public function testSpellOrdinal(): void { $this->assertSame('first', Number::spellOrdinal(1)); $this->assertSame('second', Number::spellOrdinal(2)); @@ -119,7 +120,7 @@ public function testSpellOrdinal() } #[RequiresPhpExtension('intl')] - public function testToPercent() + public function testToPercent(): void { $this->assertSame('0%', Number::percentage(0, precision: 0)); $this->assertSame('0%', Number::percentage(0)); @@ -142,7 +143,7 @@ public function testToPercent() } #[RequiresPhpExtension('intl')] - public function testToCurrency() + public function testToCurrency(): void { $this->assertSame('$0.00', Number::currency(0)); $this->assertSame('$1.00', Number::currency(1)); @@ -162,7 +163,7 @@ public function testToCurrency() } #[RequiresPhpExtension('intl')] - public function testToCurrencyWithDifferentLocale() + public function testToCurrencyWithDifferentLocale(): void { $this->assertSame('1,00 €', Number::currency(1, 'EUR', 'de')); $this->assertSame('1,00 $', Number::currency(1, 'USD', 'de')); @@ -174,7 +175,7 @@ public function testToCurrencyWithDifferentLocale() } #[RequiresPhpExtension('intl')] - public function testBytesToHuman() + public function testBytesToHuman(): void { $this->assertSame('0 B', Number::fileSize(0)); $this->assertSame('0.00 B', Number::fileSize(0, precision: 2)); @@ -191,9 +192,19 @@ public function testBytesToHuman() $this->assertSame('1 ZB', Number::fileSize(1024 ** 7)); $this->assertSame('1 YB', Number::fileSize(1024 ** 8)); $this->assertSame('1,024 YB', Number::fileSize(1024 ** 9)); + + $this->assertSame('-1 B', Number::fileSize(-1)); + $this->assertSame('-2 KB', Number::fileSize(-2048)); + $this->assertSame('-2.00 KB', Number::fileSize(-2048, precision: 2)); + $this->assertSame('-1.23 KB', Number::fileSize(-1264, precision: 2)); + $this->assertSame('-5 GB', Number::fileSize(-1024 * 1024 * 1024 * 5)); + + $this->assertSame('∞ B', Number::fileSize(INF)); + $this->assertSame('-∞ B', Number::fileSize(-INF)); + $this->assertSame('NaN B', Number::fileSize(NAN)); } - public function testClamp() + public function testClamp(): void { $this->assertSame(2, Number::clamp(1, 2, 3)); $this->assertSame(3, Number::clamp(5, 2, 3)); @@ -203,7 +214,7 @@ public function testClamp() } #[RequiresPhpExtension('intl')] - public function testToHuman() + public function testToHuman(): void { $this->assertSame('1', Number::forHumans(1)); $this->assertSame('1.00', Number::forHumans(1, precision: 2)); @@ -257,10 +268,32 @@ public function testToHuman() $this->assertSame('-1.1 trillion', Number::forHumans(-1100000000000, maxPrecision: 1)); $this->assertSame('-1 quadrillion', Number::forHumans(-1000000000000000)); $this->assertSame('-1 thousand quadrillion', Number::forHumans(-1000000000000000000)); + + // A negative magnitude that rounds down to zero must not keep the sign. + $this->assertSame('0', Number::forHumans(-0.4)); + $this->assertSame('0', Number::forHumans(-0.05)); + $this->assertSame('0', Number::forHumans(-0.4999)); + $this->assertSame('-0.40', Number::forHumans(-0.4, precision: 2)); + + // Fractions with magnitude below 0.01 must not be scaled up by a negative display exponent. + $this->assertSame('0', Number::forHumans(0.005)); + $this->assertSame('0', Number::forHumans(0.001)); + $this->assertSame('0', Number::forHumans(0.009)); + $this->assertSame('0', Number::forHumans(-0.005)); + $this->assertSame('0.005', Number::forHumans(0.005, precision: 3)); + $this->assertSame('-0.005', Number::forHumans(-0.005, precision: 3)); + + $this->assertSame('999 thousand', Number::forHumans(999499)); + $this->assertSame('1 million', Number::forHumans(999500)); + $this->assertSame('1 million', Number::forHumans(999999)); + + $this->assertSame('∞', Number::forHumans(INF)); + $this->assertSame('-∞', Number::forHumans(-INF)); + $this->assertSame('NaN', Number::forHumans(NAN)); } #[RequiresPhpExtension('intl')] - public function testSummarize() + public function testSummarize(): void { $this->assertSame('1', Number::abbreviate(1)); $this->assertSame('1.00', Number::abbreviate(1, precision: 2)); @@ -314,9 +347,32 @@ public function testSummarize() $this->assertSame('-1.1T', Number::abbreviate(-1100000000000, maxPrecision: 1)); $this->assertSame('-1Q', Number::abbreviate(-1000000000000000)); $this->assertSame('-1KQ', Number::abbreviate(-1000000000000000000)); + + // A negative magnitude that rounds down to zero must not keep the sign. + $this->assertSame('0', Number::abbreviate(-0.4)); + $this->assertSame('0', Number::abbreviate(-0.05)); + + // Fractions with magnitude below 0.01 must not be scaled up by a negative display exponent. + $this->assertSame('0', Number::abbreviate(0.005)); + $this->assertSame('0', Number::abbreviate(0.001)); + $this->assertSame('0', Number::abbreviate(-0.005)); + $this->assertSame('0.005', Number::abbreviate(0.005, precision: 3)); + + $this->assertSame('999K', Number::abbreviate(999499)); + $this->assertSame('1M', Number::abbreviate(999500)); + $this->assertSame('1M', Number::abbreviate(999999)); + $this->assertSame('1B', Number::abbreviate(999500000)); + $this->assertSame('1B', Number::abbreviate(999999999)); + + Number::withLocale('de', fn () => $this->assertSame('1M', Number::abbreviate(999500))); + Number::withLocale('fr', fn () => $this->assertSame('1M', Number::abbreviate(999500))); + + $this->assertSame('∞', Number::abbreviate(INF)); + $this->assertSame('-∞', Number::abbreviate(-INF)); + $this->assertSame('NaN', Number::abbreviate(NAN)); } - public function testPairs() + public function testPairs(): void { $this->assertSame([[0, 10], [10, 20], [20, 25]], Number::pairs(25, 10, 0, 0)); $this->assertSame([[0, 9], [10, 19], [20, 25]], Number::pairs(25, 10, 0, 1)); @@ -332,7 +388,19 @@ public function testPairs() $this->assertSame([[0.5, 2.5], [3.0, 5.0], [5.5, 7.5], [8.0, 10.0]], Number::pairs(10, 2.5, 0.5, 0.5)); } - public function testTrim() + public function testPairsThrowsWhenByIsZero(): void + { + $this->expectException(InvalidArgumentException::class); + + Number::pairs(100, 0); + } + + public function testPairsWithNegativeByWorksLikePositive(): void + { + $this->assertSame(Number::pairs(100, 10), Number::pairs(100, -10)); + } + + public function testTrim(): void { $this->assertSame(12, Number::trim(12)); $this->assertSame(120, Number::trim(120)); @@ -341,10 +409,13 @@ public function testTrim() $this->assertSame(12.3, Number::trim(12.30)); $this->assertSame(12.3456789, Number::trim(12.3456789)); $this->assertSame(12.3456789, Number::trim(12.34567890000)); + $this->assertSame(INF, Number::trim(INF)); + $this->assertSame(-INF, Number::trim(-INF)); + $this->assertNan(Number::trim(NAN)); } #[RequiresPhpExtension('intl')] - public function testParse() + public function testParse(): void { $this->assertSame(1234.0, Number::parse('1,234')); $this->assertSame(1234.5, Number::parse('1,234.5')); @@ -356,7 +427,7 @@ public function testParse() } #[RequiresPhpExtension('intl')] - public function testParseInt() + public function testParseInt(): void { $this->assertSame(1234, Number::parseInt('1,234')); $this->assertSame(1234, Number::parseInt('1,234.5')); @@ -367,7 +438,7 @@ public function testParseInt() } #[RequiresPhpExtension('intl')] - public function testParseFloat() + public function testParseFloat(): void { $this->assertSame(1234.0, Number::parseFloat('1,234')); $this->assertSame(1234.5, Number::parseFloat('1,234.5')); @@ -378,7 +449,7 @@ public function testParseFloat() $this->assertSame(1234.56, Number::parseFloat('1 234,56', locale: 'fr')); } - public function testFlushStateClearsMacros() + public function testFlushStateClearsMacros(): void { Number::macro('foo', fn () => 'bar'); $this->assertTrue(Number::hasMacro('foo')); From a87a6613a4ddcfb501937d0de36f2c16e98169ff Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:40:20 +0000 Subject: [PATCH 05/41] Constrain model identifier collection class annotations Describe useCollectionClass's nullable argument as an Eloquent collection class, matching the stored property and the serialization/restoration path. Import Collection for both annotations without changing the native signature, serialized property order, or morph-map behavior. Completes the remaining applicable change from the separately inspected post-cutoff Laravel typing PR. Its other four changes are already covered by Hypervel's native signatures and current annotations. https://github.com/laravel/framework/pull/61457 Upstream merge: baedec2039f6fcbbb6578532c12f9aaebc5a6151 Validated with full PHPStan including the committed type fixtures, the repository formatter, and review of the queue serialization callers. --- src/contracts/src/Database/ModelIdentifier.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/contracts/src/Database/ModelIdentifier.php b/src/contracts/src/Database/ModelIdentifier.php index e07b4ead0c..27130296ec 100644 --- a/src/contracts/src/Database/ModelIdentifier.php +++ b/src/contracts/src/Database/ModelIdentifier.php @@ -4,6 +4,7 @@ namespace Hypervel\Contracts\Database; +use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Relations\Relation; /** @@ -51,7 +52,7 @@ class ModelIdentifier /** * The class name of the model collection. * - * @var null|class-string<\Hypervel\Database\Eloquent\Collection> + * @var null|class-string */ public ?string $collectionClass = null; @@ -78,7 +79,7 @@ public function __construct(?string $class, mixed $id, array $relations, ?string /** * Specify the collection class that should be used when serializing / restoring collections. * - * @param null|class-string $collectionClass + * @param null|class-string $collectionClass */ public function useCollectionClass(?string $collectionClass): static { From dc7a53a6559d7e345e1490afa3c1bd74d846f5eb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:34:04 +0000 Subject: [PATCH 06/41] Preserve raw queue payloads and index failed-job counts Port Laravel #60073's string connection/queue columns and compound connection/queue/failed_at index into the generator, Testbench schema and both local migration fixtures. The index's useful leftmost prefix serves connection-scoped counts; this does not add an index dedicated to pruning or queue-only lookups. Keep Laravel's bundled migration checks for apps that retain their original migration layout and explain their purpose. Correct the generated jobs and failed_jobs schemas to retain raw payloads as text. JSON storage rejects malformed jobs that must remain available for failure investigation and normalizes ordinary payloads. Failed-job storage uses string identifiers because the provider accepts non-UUID identifiers; native PostgreSQL UUID columns rejected supported input. The schema fix adds no runtime encoding, validation, compatibility branches or state. Port the complete upstream generator test and exercise both real generated migrations through database queue push/pop and failed-provider storage. Two payload cases cover malformed JSON, generated IDs, supplied string IDs and exact payload preservation. Proved each strict-column failure before its correction on PostgreSQL; final coverage passes on SQLite, PostgreSQL, MySQL and MariaDB. Affected tests, Testbench package-mode tests, formatting and full source/type analysis pass. Upstream: https://github.com/laravel/framework/pull/60073 Source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 --- src/queue/src/Console/BatchesTableCommand.php | 1 + src/queue/src/Console/FailedTableCommand.php | 1 + src/queue/src/Console/stubs/failed_jobs.stub | 12 ++-- src/queue/src/Console/stubs/jobs.stub | 3 +- ...007_testbench_create_failed_jobs_table.php | 6 +- ..._02_21_000000_create_failed_jobs_table.php | 10 ++-- .../Queue/QueuePayloadStorageTest.php | 58 +++++++++++++++++++ .../QueueFailedTableCommandTest.php | 27 +++++++++ ..._11_20_000000_create_failed_jobs_table.php | 8 ++- 9 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 tests/Integration/Database/Queue/QueuePayloadStorageTest.php create mode 100644 tests/Integration/Generators/QueueFailedTableCommandTest.php diff --git a/src/queue/src/Console/BatchesTableCommand.php b/src/queue/src/Console/BatchesTableCommand.php index 5ce5bc8bc9..5a6808f88e 100644 --- a/src/queue/src/Console/BatchesTableCommand.php +++ b/src/queue/src/Console/BatchesTableCommand.php @@ -56,6 +56,7 @@ protected function migrationExists(string $table): bool foreach ([ join_paths($this->hypervel->databasePath('migrations'), '*_*_*_*_create_' . $table . '_table.php'), + // Laravel applications may keep job_batches in the bundled jobs migration. join_paths($this->hypervel->databasePath('migrations'), '0001_01_01_000002_create_jobs_table.php'), ] as $path) { if ($this->matchingMigrationFiles($path) !== []) { diff --git a/src/queue/src/Console/FailedTableCommand.php b/src/queue/src/Console/FailedTableCommand.php index 0eb4bb8771..8a033277e5 100644 --- a/src/queue/src/Console/FailedTableCommand.php +++ b/src/queue/src/Console/FailedTableCommand.php @@ -56,6 +56,7 @@ protected function migrationExists(string $table): bool foreach ([ join_paths($this->hypervel->databasePath('migrations'), '*_*_*_*_create_' . $table . '_table.php'), + // Laravel applications may keep failed_jobs in the bundled jobs migration. join_paths($this->hypervel->databasePath('migrations'), '0001_01_01_000002_create_jobs_table.php'), ] as $path) { if ($this->matchingMigrationFiles($path) !== []) { diff --git a/src/queue/src/Console/stubs/failed_jobs.stub b/src/queue/src/Console/stubs/failed_jobs.stub index f8c2b8ce30..87a9a2a77a 100644 --- a/src/queue/src/Console/stubs/failed_jobs.stub +++ b/src/queue/src/Console/stubs/failed_jobs.stub @@ -15,12 +15,16 @@ return new class extends Migration { Schema::create('{{table}}', function (Blueprint $table) { $table->id(); - $table->uuid()->unique(); - $table->text('connection'); - $table->text('queue'); - $table->jsonb('payload'); + // Payloads may supply identifiers that are not UUIDs. + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + // Failed jobs include malformed payloads; keep their original bytes. + $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } diff --git a/src/queue/src/Console/stubs/jobs.stub b/src/queue/src/Console/stubs/jobs.stub index 718b75b406..5eb65cdcd0 100644 --- a/src/queue/src/Console/stubs/jobs.stub +++ b/src/queue/src/Console/stubs/jobs.stub @@ -16,7 +16,8 @@ return new class extends Migration Schema::create('{{table}}', function (Blueprint $table) { $table->id(); $table->string('queue')->index(); - $table->jsonb('payload'); + // Preserve raw payloads without JSON validation or normalization. + $table->longText('payload'); $table->unsignedSmallInteger('attempts'); $table->unsignedInteger('reserved_at')->nullable(); $table->unsignedInteger('available_at'); diff --git a/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php b/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php index 2051f9d4cf..ce5b3d5d1b 100644 --- a/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php +++ b/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php @@ -15,11 +15,13 @@ public function up(): void Schema::create('failed_jobs', function (Blueprint $table) { $table->id(); $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); + $table->string('connection'); + $table->string('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } diff --git a/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php b/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php index 3ef5b59275..238c6149be 100644 --- a/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php +++ b/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php @@ -15,22 +15,24 @@ /** * Run the migrations. */ - public function up() + public function up(): void { Schema::create('failed_jobs', function (Blueprint $table) { $table->id(); - $table->text('connection'); - $table->text('queue'); + $table->string('connection'); + $table->string('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } /** * Reverse the migrations. */ - public function down() + public function down(): void { Schema::dropIfExists('failed_jobs'); } diff --git a/tests/Integration/Database/Queue/QueuePayloadStorageTest.php b/tests/Integration/Database/Queue/QueuePayloadStorageTest.php new file mode 100644 index 0000000000..0385d6b70d --- /dev/null +++ b/tests/Integration/Database/Queue/QueuePayloadStorageTest.php @@ -0,0 +1,58 @@ +artisan('make:queue-table')->assertExitCode(0); + $this->artisan('make:queue-failed-table')->assertExitCode(0); + $this->artisan('migrate')->assertExitCode(0); + + $queue = Queue::connection('database'); + $provider = new DatabaseUuidFailedJobProvider($this->app->make('db'), null, 'failed_jobs'); + + foreach ([ + [null, '{invalid'], + // PostgreSQL enforces native UUIDs; MySQL accepts this ID even in char(36). + ['uuid-1', '{ "uuid": "uuid-1", "job": "ExampleJob", "data": {"b":2,"a":1} }'], + ] as [$identifier, $payload]) { + $queue->pushRaw($payload); + + $job = $queue->pop(); + + $this->assertNotNull($job); + $this->assertSame($payload, $job->getRawBody()); + + $failedId = $provider->log('database', $job->getQueue(), $job->getRawBody(), new RuntimeException); + + if ($identifier === null) { + $this->assertTrue(Str::isUuid($failedId)); + } else { + $this->assertSame($identifier, $failedId); + } + + $failedJob = $provider->find($failedId); + + $this->assertNotNull($failedJob); + $this->assertSame($payload, $failedJob->payload); + + $job->delete(); + } + } +} diff --git a/tests/Integration/Generators/QueueFailedTableCommandTest.php b/tests/Integration/Generators/QueueFailedTableCommandTest.php new file mode 100644 index 0000000000..21184cb4e4 --- /dev/null +++ b/tests/Integration/Generators/QueueFailedTableCommandTest.php @@ -0,0 +1,27 @@ +artisan(FailedTableCommand::class)->assertExitCode(0); + + $this->assertMigrationFileContains([ + 'use Hypervel\Database\Migrations\Migration;', + 'return new class extends Migration', + "Schema::create('failed_jobs', function (Blueprint \$table) {", + "\$table->string('uuid')->unique();", + "\$table->string('connection');", + "\$table->string('queue');", + "\$table->longText('payload');", + "\$table->index(['connection', 'queue', 'failed_at']);", + "Schema::dropIfExists('failed_jobs');", + ], 'create_failed_jobs_table.php'); + } +} diff --git a/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php b/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php index 941e3f6376..9ba40a7994 100644 --- a/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php +++ b/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php @@ -14,12 +14,14 @@ public function up(): void { Schema::create('failed_jobs', function (Blueprint $table) { $table->id(); - $table->uuid('uuid')->nullable(); - $table->text('connection'); - $table->text('queue'); + $table->string('uuid')->nullable(); + $table->string('connection'); + $table->string('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } From bbef3fc69320af7f9d0ee62d889f4c05d90ecb6e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:34:04 +0000 Subject: [PATCH 07/41] Remove unused host port publications from container CI jobs Adapt Laravel #58357's service-port correction to Hypervel's container jobs. The database, Redis, Valkey, Reverb, Meilisearch and Typesense clients run inside their job containers and reach services by DNS name and internal port. Publishing those ports on the host serves no consumer. Remove eleven publication blocks across four workflows. Keep service images, health checks, internal port settings, matrices and test commands unchanged. Redis Cluster nodes and Reverb test servers run inside the job container and retain their existing loopback configuration. No dynamic host-port plumbing is required and no Hypervel port collision is claimed. Parsed all four YAML files and compared their structures with the base: only the eleven service publication fields differ, and every affected job declares a container. Live GitHub orchestration runs at the PR checkpoint. Upstream: https://github.com/laravel/framework/pull/58357 Source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 --- .github/workflows/databases.yml | 12 ------------ .github/workflows/redis.yml | 4 ---- .github/workflows/reverb.yml | 2 -- .github/workflows/scout.yml | 4 ---- 4 files changed, 22 deletions(-) diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index b2c13ce4d4..13d7aa9411 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -23,8 +23,6 @@ jobs: env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "mysqladmin ping -h localhost" --health-interval 10s @@ -79,8 +77,6 @@ jobs: env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "mysqladmin ping -h localhost" --health-interval 10s @@ -135,8 +131,6 @@ jobs: env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "healthcheck.sh --connect --innodb_initialized" --health-interval 10s @@ -191,8 +185,6 @@ jobs: env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "healthcheck.sh --connect --innodb_initialized" --health-interval 10s @@ -247,8 +239,6 @@ jobs: env: POSTGRES_PASSWORD: password POSTGRES_DB: testing - ports: - - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s @@ -303,8 +293,6 @@ jobs: env: POSTGRES_PASSWORD: password POSTGRES_DB: testing - ports: - - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 3f0f19060f..e43058063f 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -20,8 +20,6 @@ jobs: services: redis: image: redis:8 - ports: - - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s @@ -253,8 +251,6 @@ jobs: services: valkey: image: valkey/valkey:9 - ports: - - 6379:6379 options: >- --health-cmd "valkey-cli ping" --health-interval 10s diff --git a/.github/workflows/reverb.yml b/.github/workflows/reverb.yml index d551e7a336..ba8bb53440 100644 --- a/.github/workflows/reverb.yml +++ b/.github/workflows/reverb.yml @@ -26,8 +26,6 @@ jobs: services: redis: image: redis:8 - ports: - - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml index 392ed9debf..484cffb09f 100644 --- a/.github/workflows/scout.yml +++ b/.github/workflows/scout.yml @@ -23,8 +23,6 @@ jobs: env: MEILI_MASTER_KEY: secret MEILI_NO_ANALYTICS: true - ports: - - 7700:7700 options: >- --health-cmd "curl -f http://localhost:7700/health" --health-interval 10s @@ -78,8 +76,6 @@ jobs: env: TYPESENSE_API_KEY: secret TYPESENSE_DATA_DIR: /tmp - ports: - - 8108:8108 container: image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 From 7ec1be3036ab6c404401bf384c02bc0b68fe0201 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:36:13 +0000 Subject: [PATCH 08/41] Preserve non-error HTTP responses when retrying asynchronously Complete the PendingRequest callback annotations from Laravel PR #58386 and correct an adjacent upstream retry defect. Async requests previously retried redirect responses without an exception and returned null when retry throwing was enabled. Require an exception before retrying and preserve the response when there is none. Compute the request exception once inside the existing failure handler and reuse it for the retry policy, delay and final return. Preserve cancellation handling, request-method capture, public APIs and protected helper signatures. Correct the documented delay callback type and missing ConnectionException import. Add synchronous and asynchronous regression cases with both retry throw settings. Both async cases fail before the correction. HTTP package tests, source and type-fixture analysis, formatting and generated-facade validation pass. Upstream: https://github.com/laravel/framework/pull/58386 Porting source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. --- src/docs/http-client.md | 7 ++-- src/http/src/Client/PendingRequest.php | 19 +++++----- tests/Http/HttpClientTest.php | 48 ++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 8991740c94..74a45bc0ec 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -355,9 +355,9 @@ $response = Http::retry(3, 100)->post(/* ... */); If you would like to manually calculate the number of milliseconds to sleep between attempts, you may pass a closure as the second argument to the `retry` method: ```php -use Exception; +use Throwable; -$response = Http::retry(3, function (int $attempt, Exception $exception) { +$response = Http::retry(3, function (int $attempt, Throwable $exception) { return $attempt * 100; })->post(/* ... */); ``` @@ -368,9 +368,10 @@ For convenience, you may also provide an array as the first argument to the `ret $response = Http::retry([100, 200])->post(/* ... */); ``` -If needed, you may pass a third argument to the `retry` method. The third argument should be a callable that determines if the retries should actually be attempted. For example, you may wish to only retry the request if the initial request encounters an `ConnectionException`: +If needed, you may pass a third argument to the `retry` method. The third argument should be a callable that determines if the retries should actually be attempted. For example, you may wish to only retry the request if the initial request encounters a `ConnectionException`: ```php +use Hypervel\Http\Client\ConnectionException; use Hypervel\Http\Client\PendingRequest; use Throwable; diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 2381a60140..177b6bb669 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -136,6 +136,8 @@ class PendingRequest implements Transient /** * The number of milliseconds to wait between retries. + * + * @var (Closure(int, Throwable): int)|int */ protected Closure|int $retryDelay = 100; @@ -147,7 +149,7 @@ class PendingRequest implements Transient /** * The callback that will determine if the request should be retried. * - * @var null|callable + * @var null|(callable(null|Throwable, static, null|string): bool) */ protected $retryWhenCallback; @@ -580,7 +582,7 @@ public function connectTimeout(float|int $seconds): static /** * Specify the number of times the request should be attempted. * - * @param (Closure(int, mixed): int)|int $sleepMilliseconds + * @param (Closure(int, Throwable): int)|int $sleepMilliseconds * @param null|(callable(null|Throwable, static, null|string): bool) $when */ public function retry( @@ -591,8 +593,8 @@ public function retry( ): static { $this->tries = $times; $this->retryDelay = $sleepMilliseconds; - $this->retryThrow = $throw; $this->retryWhenCallback = $when; + $this->retryThrow = $throw; return $this; } @@ -1154,9 +1156,11 @@ protected function handlePromiseResponse( } try { + $exception = $response instanceof Response ? $response->toException() : $response; + $shouldRetry = $this->retryWhenCallback ? call_user_func( $this->retryWhenCallback, - $response instanceof Response ? $response->toException() : $response, + $exception, $this, $this->request?->toPsrRequest()->getMethod() ) : true; @@ -1166,9 +1170,8 @@ protected function handlePromiseResponse( return $exception; } - $exception = $response instanceof Response ? $response->toException() : $response; - - if ($attempt < $this->getMaximumAttempts() && $shouldRetry) { + // Non-error responses have no exception to retry, just as on the synchronous path. + if ($exception !== null && $attempt < $this->getMaximumAttempts() && $shouldRetry) { $options['delay'] = $this->retryDelayInMilliseconds($attempt, $exception); return $this->makePromise($method, $url, $options, $attempt + 1); @@ -1187,7 +1190,7 @@ protected function handlePromiseResponse( } if ($this->getMaximumAttempts() > 1 && $this->retryThrow) { - return $response instanceof Response ? $response->toException() : $response; + return $exception ?? $response; } return $response; diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 5b81fb82af..113b497920 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -3585,6 +3585,54 @@ public function testAsyncRequestRetriesWithIntegerTries(): void $this->factory->assertSentCount(2); } + #[DataProvider('redirectRetryModes')] + public function testRetryPreservesRedirectResponses(bool $async, bool $throw): void + { + $exceptions = []; + $delays = 0; + + $this->factory->fake([ + '*' => $this->factory->response('Redirect body', 302), + ]); + + $response = $this->factory->async($async) + ->withoutRedirecting() + ->retry(3, function () use (&$delays): int { + ++$delays; + + return 0; + }, function (?Throwable $exception) use (&$exceptions): bool { + $exceptions[] = $exception; + + return true; + }, $throw) + ->get('http://foo.com/get'); + + if ($async) { + $response = $response->wait(); + } + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(302, $response->status()); + $this->assertSame('Redirect body', $response->body()); + $this->factory->assertSentCount(1); + $this->assertSame([null], $exceptions); + $this->assertSame(0, $delays); + } + + /** + * Provide request execution and retry exception modes. + */ + public static function redirectRetryModes(): array + { + return [ + 'sync, throw' => [false, true], + 'sync, no throw' => [false, false], + 'async, throw' => [true, true], + 'async, no throw' => [true, false], + ]; + } + #[DataProvider('requestRewritingModes')] public function testAsyncRetryCallbackReceivesHttpMethod(bool $rewriteMethod): void { From 4ba0cfacba2e96e73539cfaf3ac7598620fced15 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:36:38 +0000 Subject: [PATCH 09/41] Complete upstream fractional translation selector coverage Merge the missing decimal condition fallback and fractional pluralization assertions from the current Laravel translation tests. Keep the stronger Hypervel assertions that exercise fractional modulo conversion and distinguish non-integral plural forms; the upstream additions supplement them. The existing numeric condition parser and local modulo casts already provide the intended source behavior. Add the provider title required by local conventions without changing translation runtime behavior. The edited test file, translation package tests, formatting and full static analysis pass. Upstream: https://github.com/laravel/framework/pull/58367 https://github.com/laravel/framework/pull/59174 https://github.com/laravel/framework/pull/59268 Porting source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. --- tests/Translation/TranslationMessageSelectorTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Translation/TranslationMessageSelectorTest.php b/tests/Translation/TranslationMessageSelectorTest.php index 750b4ee9d7..4466fccd73 100644 --- a/tests/Translation/TranslationMessageSelectorTest.php +++ b/tests/Translation/TranslationMessageSelectorTest.php @@ -20,6 +20,8 @@ public function testChoose(string $expected, string $id, float|int $number): voi } /** + * Provide translation choices. + * * @return array */ public static function chooseTestData(): array @@ -39,6 +41,7 @@ public static function chooseTestData(): array ['first', '{9}first|{10}second', 1], ['', '{0}|{1}second', 0], ['', '{0}first|{1}', 1], + ['second', '{1.3}first|{2.3}second', .3], ['first', '{1.3}first|{2.3}second', 1.3], ['second', '{1.3}first|{2.3}second', 2.3], ['first', '{1.}first|{2.}second', 1], @@ -113,6 +116,7 @@ public function testChooseWithFloatDoesNotTriggerDeprecation(): void }, E_DEPRECATED); try { + $this->assertSame('many', $selector->choose('{0} zero|{1} one|[2,*] many', 2.75, 'pl')); $this->assertSame('few', $selector->choose('one|few|many', 2.75, 'pl')); } finally { restore_error_handler(); @@ -123,6 +127,7 @@ public function testChoosePluralizesFloats(): void { $selector = new MessageSelector; + $this->assertSame('plural', $selector->choose('singular|plural', 0.5, 'en')); $this->assertSame('plural', $selector->choose('singular|plural', 1.5, 'en')); } } From a842eff978cb3f9f1a4c9197853390a2efb493e3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:36:38 +0000 Subject: [PATCH 10/41] Document conditional CORS middleware bypass Complete the user-facing documentation for the existing HandleCors::skipWhen API from Laravel PR #58361. Show registration in AppServiceProvider::boot, explain that a matching callback also bypasses the dynamic configuration resolver, and note that callbacks run before path matching. Place the section before dynamic configuration and link it from the contents and resolver discussion. The pinned upstream documentation has no equivalent usage example. Existing HTTP middleware tests pass; no runtime change or duplicate tests are needed. Upstream: https://github.com/laravel/framework/pull/58361 Framework source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Documentation reference: 89e91b5cff48e1b9b1a7921300653eb1ceb7bfcb. --- src/docs/routing.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/docs/routing.md b/src/docs/routing.md index 623a6d56ee..ba87ef5aac 100644 --- a/src/docs/routing.md +++ b/src/docs/routing.md @@ -32,6 +32,7 @@ - [Form Method Spoofing](#form-method-spoofing) - [Accessing the Current Route](#accessing-the-current-route) - [Cross-Origin Resource Sharing (CORS)](#cors) + - [Skipping CORS Handling](#skipping-cors-handling) - [Dynamic CORS Configuration](#dynamic-cors-configuration) - [Route Caching](#route-caching) @@ -1126,6 +1127,26 @@ This command will place a `cors.php` configuration file within your application' > [!NOTE] > For more information on CORS and CORS headers, please consult the [MDN web documentation on CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#The_HTTP_response_headers). + +### Skipping CORS Handling + +You may skip CORS handling for selected requests by calling the `HandleCors` middleware's `skipWhen` method from the `boot` method of your application's `App\Providers\AppServiceProvider` class: + +```php +use Hypervel\Http\Middleware\HandleCors; +use Hypervel\Http\Request; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + HandleCors::skipWhen(static fn (Request $request): bool => $request->is('webhooks/*')); +} +``` + +If any registered callback returns `true`, the middleware skips CORS handling for that request, including the dynamic configuration resolver described below. Keep these callbacks inexpensive, since they are checked before CORS path matching. + ### Dynamic CORS Configuration @@ -1151,7 +1172,7 @@ public function boot(): void } ``` -The closure receives the current HTTP request instance and should return the full CORS options array, including `paths`. Except for requests explicitly excluded with `skipWhen`, it runs before path matching because its returned paths determine whether CORS applies. Keep the resolver inexpensive since it runs for every request that reaches the middleware. +The closure receives the current HTTP request instance and should return the full CORS options array, including `paths`. Except for requests explicitly excluded with [`skipWhen`](#skipping-cors-handling), it runs before path matching because its returned paths determine whether CORS applies. Keep the resolver inexpensive since it runs for every request that reaches the middleware. If you only need to override a few options, you may merge your changes with the values defined in your `cors.php` configuration file: From 8468c7b0c27da1102bfcc7d4a67f0c8af7eab7fc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:36:38 +0000 Subject: [PATCH 11/41] Explain deferred queue callback ownership beside the lifecycle listener Record why deferred queue attempts may drain DeferredCallbackCollection. DeferredQueue schedules jobs through native coroutine-exit callbacks, so this collection does not contain the job being completed and cannot recursively run it. Laravel PR #58373 excludes deferred queue events because its scheduling uses the same collection. Copying that exclusion would suppress valid Hypervel callback execution. Preserve the existing behavior and document its owning boundary. DeferredCallbacksTest passes, including deferred and background connections; formatting and full static analysis pass. Upstream: https://github.com/laravel/framework/pull/58373 Compared with laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. --- src/foundation/src/Providers/FoundationServiceProvider.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index 24fb01a789..edc4ce1f72 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -256,6 +256,8 @@ protected function registerDeferHandler(): void $this->app->scoped(DeferredCallbackCollection::class); $events = $this->app->make('events'); + // Deferred jobs use coroutine-exit callbacks, not this collection, so their + // JobAttempted events can drain it without recursively running the same job. $events->listen(function (JobAttempted $event) { if ($event->connectionName === 'sync' || ! BaseContainer::getInstance()->resolvedScoped(DeferredCallbackCollection::class) From e5e08420697e6d49dbb69164390eee25d69e0763 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:21:26 +0000 Subject: [PATCH 12/41] Verify parallel compiled-view cleanup through its real lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing teardown regression from Laravel PRs #58390 and #58440 beyond callback registration. Run process setup, test-case setup and process teardown, then verify that the nonempty process directory is removed while the shared root and sibling file survive. Use the standard process-isolated temporary directory and real filesystem binding. Retain facade application cleanup and rely on the central subscriber for duplicate framework-static resets. Preserve Hypervel’s stateless, idempotent compiled-view path handling. Validation: focused TestViews tests, related parallel-testing suites, formatting, and full source/type analysis pass. Upstream: https://github.com/laravel/framework/pull/58390 https://github.com/laravel/framework/pull/58440 Source reference: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. --- tests/Testing/Concerns/TestViewsTest.php | 49 ++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/Testing/Concerns/TestViewsTest.php b/tests/Testing/Concerns/TestViewsTest.php index 28b7e72c7c..7ee71316d2 100644 --- a/tests/Testing/Concerns/TestViewsTest.php +++ b/tests/Testing/Concerns/TestViewsTest.php @@ -8,7 +8,6 @@ use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Facade; -use Hypervel\Support\Facades\ParallelTesting as ParallelTestingFacade; use Hypervel\Testing\Concerns\TestViews; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; @@ -21,12 +20,24 @@ class TestViewsTest extends TestCase { private mixed $originalParallelTesting; + private string $tempDir; + + private Filesystem $filesystem; + + /** + * Create the isolated compiled-view directory and container bindings. + */ protected function setUp(): void { $this->originalParallelTesting = $_SERVER['HYPERVEL_PARALLEL_TESTING'] ?? null; parent::setUp(); + $this->filesystem = new Filesystem; + $this->tempDir = ParallelTesting::tempDir('TestViewsTest'); + $this->filesystem->deleteDirectory($this->tempDir); + $this->filesystem->ensureDirectoryExists($this->tempDir); + Container::setInstance($container = new Container); Facade::setFacadeApplication($container); @@ -38,14 +49,18 @@ protected function setUp(): void ])); $container->singleton(ParallelTesting::class, fn ($app) => new ParallelTesting($app)); + $container->instance('files', $this->filesystem); $_SERVER['HYPERVEL_PARALLEL_TESTING'] = 1; } + /** + * Remove the isolated compiled-view directory and restore the environment. + */ protected function tearDown(): void { - Container::setInstance(null); - ParallelTestingFacade::clearResolvedInstance(); + $this->filesystem->deleteDirectory($this->tempDir); + Facade::setFacadeApplication(null); if ($this->originalParallelTesting === null) { @@ -133,6 +148,9 @@ public function testSwitchToCompiledViewPathUpdatesCompilerCachePath(): void public function testTearDownProcessDeletesCompiledViewDirectory(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '7'); + Container::getInstance()->make('config')->set('view.compiled', $this->tempDir); + + $this->filesystem->put($this->tempDir . '/shared.php', 'shared view'); $instance = $this->makeTestViewsInstance(); @@ -143,8 +161,24 @@ public function testTearDownProcessDeletesCompiledViewDirectory(): void $tearDownCallbacks = (new ReflectionProperty($parallelTesting, 'tearDownProcessCallbacks'))->getValue($parallelTesting); $this->assertCount(1, $tearDownCallbacks); + + $parallelTesting->callSetUpProcessCallbacks(); + + $this->assertDirectoryExists($this->tempDir . '/test_7'); + + $this->filesystem->put($this->tempDir . '/test_7/compiled.php', 'compiled view'); + + $parallelTesting->callSetUpTestCaseCallbacks($this); + $parallelTesting->callTearDownProcessCallbacks(); + + $this->assertDirectoryDoesNotExist($this->tempDir . '/test_7'); + $this->assertDirectoryExists($this->tempDir); + $this->assertFileExists($this->tempDir . '/shared.php'); } + /** + * Get the compiled view path for the current process. + */ protected function getCompiledViewPath(): ?string { $instance = $this->makeTestViewsInstance(); @@ -154,6 +188,9 @@ protected function getCompiledViewPath(): ?string return $method->invoke($instance); } + /** + * Switch to the given compiled view path. + */ protected function switchToCompiledViewPath(string $path): void { $instance = $this->makeTestViewsInstance(); @@ -162,6 +199,9 @@ protected function switchToCompiledViewPath(string $path): void $method->invoke($instance, $path); } + /** + * Create a test views instance using the current container. + */ protected function makeTestViewsInstance(): object { return new class { @@ -169,6 +209,9 @@ protected function makeTestViewsInstance(): object public Container $app; + /** + * Create a new test views instance. + */ public function __construct() { $this->app = Container::getInstance(); From 32792941913581219b5c53ba4d7ba500756cfa58 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:21:35 +0000 Subject: [PATCH 13/41] Port collection merge union type assertions Port all four mixed-value merge assertions from Laravel PR #58405 into the existing combined eager and lazy collection fixture. Preserve integer/string literals and verify both merge directions. Collection and Enumerable already expose the correct TMergeValue union, so no runtime or contract changes are needed. Validation: the focused type fixture and full source/type analysis pass; formatting is clean. Upstream: https://github.com/laravel/framework/pull/58405 Source reference: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. --- types/Collections/Collection.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 3b7ed47f9d..0d6fa29d81 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -57,6 +57,11 @@ assertType('Hypervel\Support\LazyCollection', $lazy->random(2, true)); assertType('Hypervel\Support\LazyCollection', LazyCollection::make($lazySource)); +assertType('Hypervel\Support\Collection', $collection::make([1])->merge(['string'])); +assertType('Hypervel\Support\Collection', $collection::make(['string'])->merge([1])); +assertType('Hypervel\Support\LazyCollection', $lazy::make([1])->merge(['string'])); +assertType('Hypervel\Support\LazyCollection', $lazy::make(['string'])->merge([1])); + /** * Check shared enumerable return and callback types. * From f7645d6f5c4c9f06a99fa5ede8b1596a391e7ce8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:21:50 +0000 Subject: [PATCH 14/41] Document queue startup, pop and exception-release events Complete the public documentation accompanying Laravel PRs #55941, #58399, #58413 and #58414. Describe startup and stopping registration from provider boot, the configured queue selection before a pop, the retrieved job after a successful pop, and exception-release metadata including backoff seconds. Runtime implementations and upstream assertions are already present. Keep the documentation concise and pair related hooks without duplicating listener examples. Verify every described field and dispatch condition against current source; no queue behavior or APIs change. Upstream: https://github.com/laravel/framework/pull/55941 https://github.com/laravel/framework/pull/58399 https://github.com/laravel/framework/pull/58413 https://github.com/laravel/framework/pull/58414 Source reference: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2; docs at 89e91b5cff48e1b9b1a7921300653eb1ceb7bfcb. --- src/docs/queues.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index 3da121d80a..0618eb377c 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -3853,6 +3853,8 @@ Hypervel dispatches a `JobQueueing` event immediately before a job is sent to it The `JobPayloadFinalizing` event runs immediately before `JobQueueing` and may replace its encoded `payload`. It also provides the connection, queue, job, and normalized delay. Use this event for last-mile payload changes that must reach the queue backend. Listening to both events deliberately runs both listeners for each asynchronous job. +When a worker releases a job back onto the queue after an exception, the `JobReleasedAfterException` event provides the `connectionName`, `job`, `backoff` delay in seconds, and the original `exception`. + Using the `looping` method on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks that execute before the worker attempts to fetch a job from a queue. For example, you might register a closure to rollback any transactions that were left open by a previously failed job: ```php @@ -3866,6 +3868,10 @@ Queue::looping(function () { }); ``` +The `JobPopping` event is dispatched before a worker attempts to retrieve a job. Its `connectionName` and `queue` properties identify the configured connection and queue selection, which may be a comma-separated list. After a job is retrieved, the `JobPopped` event provides the `connectionName` and `job`. + +Long-running queue workers dispatch a `WorkerStarting` event when they start. Its `connectionName`, `queue`, and `workerOptions` properties describe the worker. You may register a listener using `Queue::starting` in the `boot` method of a service provider. + Hypervel also dispatches a `Hypervel\Queue\Events\WorkerIdle` event when a queue worker is unable to retrieve a job from the queue: ```php @@ -3881,4 +3887,4 @@ Event::listen(function (WorkerIdle $event) { When an interrupting signal is delivered to running jobs, Hypervel dispatches a `Hypervel\Queue\Events\JobInterrupted` event once for each job that was notified. Its `connectionName`, `job`, and `signal` properties identify the interrupted work. -Queue workers also dispatch a `WorkerStopping` event before they stop. Its `connectionName` and `queue` properties identify the worker, while `terminatesImmediately` is `true` when the process will be terminated as soon as the listeners return. In that case, listeners should not start cleanup that must finish after the listener returns. +Queue workers also dispatch a `WorkerStopping` event before they stop. You may register a listener using `Queue::stopping` in the `boot` method of a service provider. Its `connectionName` and `queue` properties identify the worker, while `terminatesImmediately` is `true` when the process will be terminated as soon as the listeners return. In that case, listeners should not start cleanup that must finish after the listener returns. From 74a0928719afb9ed2b1cdb332834299f7c6de4ae Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:19:46 +0000 Subject: [PATCH 15/41] Complete HTTP response JSON flag types Port the remaining int-mask annotations and the original Response type fixture from Laravel framework PR #58379. Carry the same accepted flag set through Hypervel's existing decode helper so the public and internal boundaries agree without changing native signatures or runtime behavior. Preserve custom decoder precedence, flag-aware caching, falsy payload caching and centralized static cleanup. The upstream runtime assertions are already present; add only the missing upstream PHPStan fixture. Upstream: https://github.com/laravel/framework/pull/58379 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Validation: complete source and type analysis, affected HTTP/Bus/response consumer tests, formatting and independent code review pass. --- src/http/src/Client/Response.php | 12 ++++++++++++ types/Http/Client/Response.php | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 types/Http/Client/Response.php diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index f074789afb..826eebc49f 100644 --- a/src/http/src/Client/Response.php +++ b/src/http/src/Client/Response.php @@ -41,6 +41,8 @@ class Response implements ArrayAccess, Stringable /** * The flags that were used when decoding the JSON response. + * + * @var int-mask */ protected int $decodingFlags = 0; @@ -91,6 +93,8 @@ public function body(): string /** * Get the JSON decoded body of the response as an array or scalar value. + * + * @param null|int-mask $flags */ public function json(?string $key = null, mixed $default = null, ?int $flags = null): mixed { @@ -123,6 +127,8 @@ public function json(?string $key = null, mixed $default = null, ?int $flags = n * * This method will return an array of objects. Scalar JSON values remain * their decoded scalar type. + * + * @param null|int-mask $flags */ public function object(?int $flags = null): mixed { @@ -152,6 +158,8 @@ public function decodeUsing(?Closure $callback): static /** * Decode the given response body. + * + * @param int-mask $flags */ protected function decode(string $body, bool $asObject = false, int $flags = 0): mixed { @@ -164,6 +172,8 @@ protected function decode(string $body, bool $asObject = false, int $flags = 0): /** * Get the JSON decoded body of the response as a collection. + * + * @param null|int-mask $flags */ public function collect(?string $key = null, ?int $flags = null): Collection { @@ -172,6 +182,8 @@ public function collect(?string $key = null, ?int $flags = null): Collection /** * Get the JSON decoded body of the response as a fluent object. + * + * @param null|int-mask $flags */ public function fluent(?string $key = null, ?int $flags = null): Fluent { diff --git a/types/Http/Client/Response.php b/types/Http/Client/Response.php new file mode 100644 index 0000000000..c78890d576 --- /dev/null +++ b/types/Http/Client/Response.php @@ -0,0 +1,12 @@ + Date: Thu, 10 Sep 2026 19:19:59 +0000 Subject: [PATCH 16/41] Document HTTP response decoding flags and defaults Complete the public documentation for Laravel framework PR #58379's JSON decoding flags. Show the flags arguments on object, collect and fluent, and explain per-call overrides and configuring the shared default during worker startup. Describe custom decoder precedence because decodeUsing replaces JSON decoding and makes flags inapplicable. Correct the object return type in the method list to cover scalar JSON and custom decoder results. Upstream: https://github.com/laravel/framework/pull/58379 Compared with laravel/docs at 89e91b5cff48e1b9b1a7921300653eb1ceb7bfcb; its current page does not explain these defaults or custom decoding. Validation: checked examples and prose against response methods and existing decoding regressions; independent review completed. --- src/docs/http-client.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 74a45bc0ec..2b638ea746 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -49,9 +49,10 @@ The `get` method returns an instance of `Hypervel\Http\Client\Response`, which p ```php $response->body() : string; $response->json($key = null, $default = null, $flags = null) : mixed; -$response->object() : array|object|null; -$response->collect($key = null) : Hypervel\Support\Collection; -$response->fluent($key = null) : Hypervel\Support\Fluent; +$response->object($flags = null) : mixed; +$response->collect($key = null, $flags = null) : Hypervel\Support\Collection; +$response->fluent($key = null, $flags = null) : Hypervel\Support\Fluent; +$response->decodeUsing($callback) : Hypervel\Http\Client\Response; $response->resource() : resource; $response->status() : int; $response->successful() : bool; @@ -74,12 +75,30 @@ The `Hypervel\Http\Client\Response` object also implements the PHP `ArrayAccess` return Http::get('http://example.com/users/1')['name']; ``` -The optional third argument accepted by the `json` method is passed to `json_decode` as its decoding flags: +The `json`, `object`, `collect`, and `fluent` methods accept an optional `flags` argument, which is passed to `json_decode`: ```php $value = $response->json('value', flags: JSON_BIGINT_AS_STRING); ``` +When `flags` is omitted or `null`, these methods use `Response::$defaultJsonDecodingFlags`. You may configure this default in a service provider's `boot` method: + +```php +use Hypervel\Http\Client\Response; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + Response::$defaultJsonDecodingFlags = JSON_BIGINT_AS_STRING; +} +``` + +The default is shared by all requests handled by the worker, so configure it only during startup. Pass `flags: 0` to use no flags for an individual call. + +If you set a custom decoder using `decodeUsing`, that callback replaces JSON decoding and the flags do not apply. The callback receives the response body and a boolean indicating whether `object` was called. + In addition to the response methods listed above, the following methods may be used to determine if the response has a specific status code: ```php From 184d7a487cda56dbd3d4c47a2befa52aa16bf1fa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:20:12 +0000 Subject: [PATCH 17/41] Preserve explicit handlers when deferred dispatch is disabled Forward the supplied handler to dispatchSync when dispatchAfterResponse runs inline. Previously withoutDispatchingAfterResponses caused that argument to be discarded, invoking the command's own handler or failing for commands that rely exclusively on the explicit handler. Found while reconciling Laravel framework PR #58428. The contract and fake already include and forward the handler; the concrete dispatcher has the same omission in pinned upstream. Correct it at that single boundary, without changing deferred execution, queue handling or worker state. Add one regression using the existing immediate-command fixture. Verify that the explicit handler receives the identical command and that the command's own handle method does not run. The test fails before the fix and passes after it. Related upstream: https://github.com/laravel/framework/pull/58428 Compared against laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: BusDispatcherTest, affected HTTP/Bus/response consumer tests, complete source/type analysis, formatting and independent review pass. --- src/bus/src/Dispatcher.php | 2 +- tests/Bus/BusDispatcherTest.php | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index 64dbcef26b..add4b27666 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -263,7 +263,7 @@ protected function pushCommandToQueue(Queue $queue, mixed $command): mixed public function dispatchAfterResponse(mixed $command, mixed $handler = null): void { if (! $this->allowsDispatchingAfterResponses) { - $this->dispatchSync($command); + $this->dispatchSync($command, $handler); return; } diff --git a/tests/Bus/BusDispatcherTest.php b/tests/Bus/BusDispatcherTest.php index 89a1ecaa6a..a60605300d 100644 --- a/tests/Bus/BusDispatcherTest.php +++ b/tests/Bus/BusDispatcherTest.php @@ -207,6 +207,22 @@ public function testDispatcherCanDispatchStandAloneHandler() $this->assertInstanceOf(StandAloneCommand::class, $response); } + public function testDisabledDispatchAfterResponseUsesExplicitHandler(): void + { + $dispatcher = new Dispatcher(new Container); + $dispatcher->withoutDispatchingAfterResponses(); + + $command = new BusDispatcherImmediateCommand; + $handledCommand = null; + + $dispatcher->dispatchAfterResponse($command, static function (BusDispatcherImmediateCommand $receivedCommand) use (&$handledCommand): void { + $handledCommand = $receivedCommand; + }); + + $this->assertSame($command, $handledCommand); + $this->assertFalse($command->handled); + } + public function testOnConnectionOnJobWhenDispatching() { Container::setInstance($container = new Container); From d9ac0303eb7d4046048a3b375cda7701ccc490fd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:51:13 +0000 Subject: [PATCH 18/41] Reject malformed CSRF tokens through the existing validator Complete the direct middleware coverage from Laravel framework PR #58400, using the current 13.x tests at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Hypervel's nullable-string token getter threw TypeError for array or numeric request input before tokensMatch could reject it. Return the raw value as mixed so the existing string predicate owns validation and the normal token mismatch response is preserved. No coercion or duplicate guard is added. Port all nine origin/token cases and add one regression for an array token. Use Hypervel's test base, session API, native types and centralized cleanup. Record the existing omission of deprecated CSRF middleware aliases and apply the imported exception and strict comparison conventions in the touched code. The regression fails with the former getter signature. Affected middleware, configuration, broadcasting and Sanctum tests pass, as do full formatting and both PHPStan checks. Peer review also verified reverse-order static cleanup. https://github.com/laravel/framework/pull/58400 --- .../Http/Middleware/PreventRequestForgery.php | 13 +- .../Middleware/PreventRequestForgeryTest.php | 175 ++++++++++++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 tests/Http/Middleware/PreventRequestForgeryTest.php diff --git a/src/foundation/src/Http/Middleware/PreventRequestForgery.php b/src/foundation/src/Http/Middleware/PreventRequestForgery.php index 978df5a3ea..0d7094da67 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestForgery.php +++ b/src/foundation/src/Http/Middleware/PreventRequestForgery.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response; +// REMOVED: Deprecated VerifyCsrfToken and ValidateCsrfToken aliases; use this middleware directly. class PreventRequestForgery { use ExcludesPaths; @@ -66,8 +67,8 @@ public function __construct( /** * Handle an incoming request. * - * @throws \Hypervel\Session\TokenMismatchException - * @throws \Hypervel\Http\Exceptions\OriginMismatchException + * @throws TokenMismatchException + * @throws OriginMismatchException */ public function handle(Request $request, Closure $next): Response { @@ -93,7 +94,7 @@ public function handle(Request $request, Closure $next): Response */ protected function isReading(Request $request): bool { - return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']); + return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS'], true); } /** @@ -107,7 +108,7 @@ protected function runningUnitTests(): bool /** * Determine if the request has a valid origin based on the Sec-Fetch-Site header. * - * @throws \Hypervel\Http\Exceptions\OriginMismatchException + * @throws OriginMismatchException */ protected function hasValidOrigin(Request $request): bool { @@ -142,8 +143,10 @@ protected function tokensMatch(Request $request): bool /** * Get the CSRF token from the request. + * + * Preserve raw input so tokensMatch can reject non-string tokens. */ - protected function getTokenFromRequest(Request $request): ?string + protected function getTokenFromRequest(Request $request): mixed { $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN'); diff --git a/tests/Http/Middleware/PreventRequestForgeryTest.php b/tests/Http/Middleware/PreventRequestForgeryTest.php new file mode 100644 index 0000000000..c272049d52 --- /dev/null +++ b/tests/Http/Middleware/PreventRequestForgeryTest.php @@ -0,0 +1,175 @@ +createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-origin']); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + public function testSameSiteHeaderRejectedByDefault(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-site']); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testSameSiteHeaderPassesWhenAllowed(): void + { + PreventRequestForgery::allowSameSite(); + + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-site']); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + public function testCrossSiteWithValidTokenPasses(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'cross-site'], 'test-token'); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + public function testCrossSiteWithoutTokenFails(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'cross-site']); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testMissingHeaderWithoutTokenFails(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testArrayTokenIsRejected(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(); + // Malformed input must reach token validation, not fail at the getter's return type. + $request->request->set('_token', ['test-token']); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testOriginOnlyModeRejectsCrossSite(): void + { + PreventRequestForgery::useOriginOnly(); + + $middleware = $this->createMiddleware(); + // Even with a valid token, origin-only mode rejects cross-site + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'cross-site'], 'test-token'); + + $this->expectException(OriginMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testOriginOnlyModeRejectsMissingHeader(): void + { + PreventRequestForgery::useOriginOnly(); + + $middleware = $this->createMiddleware(); + $request = $this->createRequest([], 'test-token'); + + $this->expectException(OriginMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testOriginOnlyModePassesSameOrigin(): void + { + PreventRequestForgery::useOriginOnly(); + + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-origin']); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + /** + * Create a request with the given headers and token. + */ + protected function createRequest(array $server = [], ?string $token = null): Request + { + $request = Request::create( + 'http://example.com/test', + 'POST', + $token ? ['_token' => $token] : [], + [], + [], + $server + ); + + $session = m::mock(Session::class); + $session->shouldReceive('token')->andReturn('test-token'); + $request->setHypervelSession($session); + + return $request; + } + + /** + * Create the middleware for origin and token verification. + */ + protected function createMiddleware(): PreventRequestForgeryTestStub + { + return new PreventRequestForgeryTestStub( + m::mock(Application::class), + m::mock(Encrypter::class) + ); + } +} + +class PreventRequestForgeryTestStub extends PreventRequestForgery +{ + protected bool $addHttpCookie = false; + + /** + * Determine if the application is running unit tests. + */ + protected function runningUnitTests(): bool + { + return false; + } +} From b5166be857ea4f7668a885b2d84f50c9eb6e09bb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:51:25 +0000 Subject: [PATCH 19/41] Complete request-forgery exclusion coverage Port the five exclusion tests and fixture from the current Laravel 13.x implementation of PR #58400 at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Preserve path, URL, slash and wildcard assertions, using native types and Hypervel Testbench. Keep these service-free tests under tests/Http. Correct the upstream global-exclusion test: installing the same path as a local exclusion made its assertion pass without global registration. Supply an empty local list so the assertion depends on the global exclusion itself. Framework test cleanup owns resetting that static configuration. The test file and the affected middleware, configuration, broadcasting and Sanctum suite pass. Formatting and full source/type analysis also pass. https://github.com/laravel/framework/pull/58400 --- .../PreventRequestForgeryExceptStub.php | 29 +++++++ .../PreventRequestForgeryExceptTest.php | 82 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/Http/Fixtures/PreventRequestForgeryExceptStub.php create mode 100644 tests/Http/Middleware/PreventRequestForgeryExceptTest.php diff --git a/tests/Http/Fixtures/PreventRequestForgeryExceptStub.php b/tests/Http/Fixtures/PreventRequestForgeryExceptStub.php new file mode 100644 index 0000000000..6e94bc1390 --- /dev/null +++ b/tests/Http/Fixtures/PreventRequestForgeryExceptStub.php @@ -0,0 +1,29 @@ +inExceptArray($request); + } + + /** + * Set the locally excluded paths. + */ + public function setExcept(array $except): static + { + $this->except = $except; + + return $this; + } +} diff --git a/tests/Http/Middleware/PreventRequestForgeryExceptTest.php b/tests/Http/Middleware/PreventRequestForgeryExceptTest.php new file mode 100644 index 0000000000..9cfeedc070 --- /dev/null +++ b/tests/Http/Middleware/PreventRequestForgeryExceptTest.php @@ -0,0 +1,82 @@ +stub = new PreventRequestForgeryExceptStub(app(), new Encrypter(Encrypter::generateKey('AES-128-CBC'))); + $this->request = Request::create('http://example.com/foo/bar', 'POST'); + } + + public function testItCanExceptPaths(): void + { + $this->assertMatchingExcept(['/foo/bar']); + $this->assertMatchingExcept(['foo/bar']); + $this->assertNonMatchingExcept(['/bar/foo']); + } + + public function testPathsCanBeGloballyIgnored(): void + { + $this->request = Request::create('http://example.com/globally/ignored', 'POST'); + $this->assertMatchingExcept([]); + } + + public function testItCanExceptWildcardPaths(): void + { + $this->assertMatchingExcept(['/foo/*']); + $this->assertNonMatchingExcept(['/bar*']); + } + + public function testItCanExceptFullUrlPaths(): void + { + $this->assertMatchingExcept(['http://example.com/foo/bar']); + $this->assertMatchingExcept(['http://example.com/foo/bar/']); + + $this->assertNonMatchingExcept(['https://example.com/foo/bar/']); + $this->assertNonMatchingExcept(['http://foobar.com/']); + } + + public function testItCanExceptFullUrlWildcardPaths(): void + { + $this->assertMatchingExcept(['http://example.com/*']); + $this->assertMatchingExcept(['*example.com*']); + + $this->request = Request::create('https://example.com', 'POST'); + $this->assertMatchingExcept(['*example.com']); + } + + /** + * Assert whether the request matches the given exclusions. + */ + private function assertMatchingExcept(array $except, bool $bool = true): void + { + $this->assertSame($bool, $this->stub->setExcept($except)->checkInExceptArray($this->request)); + } + + /** + * Assert that the request does not match the given exclusions. + */ + private function assertNonMatchingExcept(array $except): void + { + $this->assertMatchingExcept($except, false); + } +} From 8b71c516ec88bc1a3d2fd55c2f7f74568fa09b6c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:51:39 +0000 Subject: [PATCH 20/41] Document the supported CSRF middleware replacements Complete the existing omission record for Laravel's deprecated VerifyCsrfToken and ValidateCsrfToken aliases introduced by PR #58400. Hypervel already omits those classes and the validateCsrfTokens method. Direct application and package ports to PreventRequestForgery and the preventRequestForgery configuration method. Mention the native array type needed when overriding the exclusions property, and link to the canonical CSRF documentation instead of duplicating its feature examples. Checked the replacement names and property type against current source, and the guide anchor and link against the existing documentation. https://github.com/laravel/framework/pull/58400 --- src/docs/porting-from-laravel.md | 6 ++++++ src/foundation/README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 2a03232831..1751d0133c 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -22,6 +22,7 @@ - [Configuration](#configuration) - [Other API Differences](#other-api-differences) - [HTTP Client and Concurrency](#http-client-and-concurrency) + - [CSRF Protection](#csrf-protection) - [Scout](#scout) - [JSON Schema](#json-schema) - [Validation](#validation) @@ -474,6 +475,11 @@ For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` p Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver). + +### CSRF Protection + +Replace references to Laravel's deprecated `VerifyCsrfToken` and `ValidateCsrfToken` middleware with `Hypervel\Foundation\Http\Middleware\PreventRequestForgery`. If your application extends either class, extend `PreventRequestForgery` instead and declare any overridden exclusions as `protected array $except`. Replace `validateCsrfTokens()` configuration calls with `preventRequestForgery()`. See the [CSRF protection documentation](/docs/{{version}}/csrf). + ### Scout diff --git a/src/foundation/README.md b/src/foundation/README.md index a237adc201..34f11728c4 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -14,7 +14,7 @@ Laravel's real-time facades are intentionally not supported. Define explicit fac The application locale setters do not change the `app.locale` or `app.fallback_locale` configuration values. `App::setLocale()` applies only to the current request, while `App::setFallbackLocale()` is intended for application boot and changes the fallback shared by the worker. -Laravel's deprecated `Middleware::validateCsrfTokens()` alias is intentionally not ported. Configure request-forgery protection with `preventRequestForgery()`. +Laravel's deprecated `VerifyCsrfToken` and `ValidateCsrfToken` middleware aliases and `Middleware::validateCsrfTokens()` method are intentionally not ported. Use `PreventRequestForgery` and configure request-forgery protection with `preventRequestForgery()`. The default `dev` server process runs `php artisan watch` so the Watcher package can own and restart the long-running Swoole server. Official Hypervel skeletons and starter kits include `hypervel/watcher` as a development dependency. From 7e6a6dc284f71285c682810f57745eee975a5211 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:42:25 +0000 Subject: [PATCH 21/41] Fix raw expression selection and preserve authorization attributes Complete Laravel framework PRs #58436, #58469 and #58753 against source revision 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Remove automatic aliases inferred from array keys, which can wrap already-aliased raw SQL into an invalid expression. Preserve the explicit selectExpression API and accept its current string form. Keep Hypervel single-identifier alias quoting and source-aware default columns. Adapt withCan to explicitly alias expression results while preserving model columns and caller selections; bound policy subqueries keep their existing path. Preserve source-alias regression assertions using supported keyed subqueries, and cover raw SQL preservation, model hydration, explicit selections and bindings. Verified the affected query and grammar tests, Auth suite and SQLite database integration suite, full source and type-fixture analysis, and formatting. No extra database queries or shared state are introduced. Upstream: https://github.com/laravel/framework/pull/58436 Upstream partial revert: https://github.com/laravel/framework/pull/58469 Upstream string support: https://github.com/laravel/framework/pull/58753 --- src/auth/src/AuthServiceProvider.php | 17 ++++-- src/database/src/Query/Builder.php | 18 ++----- tests/Auth/AuthEloquentBuilderCanTest.php | 18 +++++++ tests/Database/DatabaseQueryBuilderTest.php | 54 +++++++++---------- .../Database/AuthQueryAwarePolicyTest.php | 1 + .../Integration/Database/QueryBuilderTest.php | 4 +- .../Sqlite/DatabaseSchemaBuilderTest.php | 2 +- 7 files changed, 65 insertions(+), 49 deletions(-) diff --git a/src/auth/src/AuthServiceProvider.php b/src/auth/src/AuthServiceProvider.php index 0f7bc5e6fb..586d252741 100755 --- a/src/auth/src/AuthServiceProvider.php +++ b/src/auth/src/AuthServiceProvider.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Auth\Access\Gate as GateContract; use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; @@ -135,9 +136,19 @@ private function registerQueryBuilderMacros(GateContract $gate): void $casts = []; foreach ($resolvedAbilities as [$ability, $alias]) { - $this->addSelect([ - $alias => $queryGate->select($ability, $this), - ]); + $selection = $queryGate->select($ability, $this); + + if ($selection instanceof ExpressionContract) { + // Raw authorization selections must retain the model columns, like subqueries do. + if ($this->getQuery()->columns === null) { + $this->select($this->getQuery()->getDefaultSelectColumn()); + } + + $this->selectExpression($selection, $alias); + } else { + $this->addSelect([$alias => $selection]); + } + $casts[$alias] = 'bool'; } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 7df28e62d7..1654d61794 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -269,9 +269,7 @@ public function select(mixed $columns = ['*']): static $columns = is_array($columns) ? $columns : func_get_args(); foreach ($columns as $as => $column) { - if (is_string($as) && $column instanceof ExpressionContract) { - $this->selectExpression($column, $as); - } elseif (is_string($as) && $this->isQueryable($column)) { + if (is_string($as) && $this->isQueryable($column)) { $this->selectSub($column, $as); } else { $this->columns[] = $column; @@ -299,12 +297,12 @@ public function selectSub(Closure|self|EloquentBuilder|Relation|string $query, s } /** - * Add an expression to the select clause. + * Add a select expression to the query. */ - public function selectExpression(ExpressionContract $expression, string $as): static + public function selectExpression(ExpressionContract|string $expression, string $as): static { return $this->selectRaw( - '(' . $expression->getValue($this->grammar) . ') as ' . $this->grammar->wrapIdentifier($as) + '(' . $this->grammar->getValue($expression) . ') as ' . $this->grammar->wrapIdentifier($as) ); } @@ -432,13 +430,7 @@ public function addSelect(mixed $column): static $columns = is_array($column) ? $column : func_get_args(); foreach ($columns as $as => $column) { - if (is_string($as) && $column instanceof ExpressionContract) { - if (is_null($this->columns)) { - $this->select($this->getDefaultSelectColumn()); - } - - $this->selectExpression($column, $as); - } elseif (is_string($as) && $this->isQueryable($column)) { + if (is_string($as) && $this->isQueryable($column)) { if (is_null($this->columns)) { $this->select($this->getDefaultSelectColumn()); } diff --git a/tests/Auth/AuthEloquentBuilderCanTest.php b/tests/Auth/AuthEloquentBuilderCanTest.php index 628860c0da..e3d9245a30 100644 --- a/tests/Auth/AuthEloquentBuilderCanTest.php +++ b/tests/Auth/AuthEloquentBuilderCanTest.php @@ -149,6 +149,24 @@ public function testWithCanAddsOneOrMultipleStrictBooleanAttributesAndKeepsModel } } + public function testWithCanPreservesExplicitColumnsAndSelectionBindings(): void + { + $this->gate()->before(fn (): bool => true); + + $post = Post::query() + ->select('id') + ->selectRaw('? as title', ['Selected title']) + ->withCan('edit', $this->user(1)) + ->orderBy('id') + ->firstOrFail(); + + $this->assertSame([ + 'id' => 1, + 'title' => 'Selected title', + 'can_edit' => true, + ], $post->toArray()); + } + public function testWithCanGeneratesDashedCamelExplicitAndDottedAliases(): void { $user = $this->user(1); diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 002b671352..d2ed06e9ec 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -165,29 +165,29 @@ public function testBasicSelectWithPrefix() public function testDefaultSelectionUsesTheLogicalSourceAlias(): void { $builder = $this->getBuilder(prefix: 'prefix_'); - $builder->from('users', '0')->addSelect(['bonus' => new Raw(42)]); + $builder->from('users', '0')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); - $this->assertSame('select "prefix_0".*, (42) as "bonus" from "prefix_users" as "prefix_0"', $builder->toSql()); + $this->assertSame('select "prefix_0".*, (select 42) as "bonus" from "prefix_users" as "prefix_0"', $builder->toSql()); $builder = $this->getBuilder(prefix: 'prefix_'); - $builder->from('users AS people')->addSelect(['bonus' => new Raw(42)]); + $builder->from('users AS people')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); - $this->assertSame('select "prefix_people".*, (42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); + $this->assertSame('select "prefix_people".*, (select 42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); } public function testReplacingTheSourceResetsItsDefaultSelectionAlias(): void { $builder = $this->getBuilder(prefix: 'prefix_')->fromSub('select 1 as id', 'old'); - $plain = (clone $builder)->from('users')->addSelect(['bonus' => new Raw(42)]); - $this->assertSame('select "prefix_users".*, (42) as "bonus" from "prefix_users"', $plain->toSql()); + $plain = (clone $builder)->from('users')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); + $this->assertSame('select "prefix_users".*, (select 42) as "bonus" from "prefix_users"', $plain->toSql()); - $subquery = (clone $builder)->fromSub('select 2 as id', 'new')->addSelect(['bonus' => new Raw(42)]); - $this->assertSame('select "prefix_new".*, (42) as "bonus" from (select 2 as id) as "prefix_new"', $subquery->toSql()); + $subquery = (clone $builder)->fromSub('select 2 as id', 'new')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); + $this->assertSame('select "prefix_new".*, (select 42) as "bonus" from (select 2 as id) as "prefix_new"', $subquery->toSql()); $this->expectException(TypeError::class); - $builder->fromRaw('users')->addSelect(['bonus' => new Raw(42)]); + $builder->fromRaw('users')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); } public function testContractExpressionsAreAcceptedAsQuerySources(): void @@ -204,8 +204,8 @@ public function getValue(BaseGrammar $grammar): string $this->assertSame('select * from "prefix_users"', $builder->toSql()); $this->assertSame($expression, $builder->from); - $builder->from($expression, 'people')->addSelect(['bonus' => new Raw(42)]); - $this->assertSame('select "prefix_people".*, (42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); + $builder->from($expression, 'people')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); + $this->assertSame('select "prefix_people".*, (select 42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); $builder->from($expression); $this->assertSame($expression, $builder->from); @@ -6177,12 +6177,14 @@ public function testSubSelectResetBindings() $this->assertEquals([], $builder->getBindings()); } - public function testSelectExpression() + public function testSelectExpression(): void { $builder = $this->getBuilder(); - $builder->from('one')->selectExpression(new Raw('1 + 1'), 'expr'); + $builder->from('one') + ->selectExpression(new Raw('1 + 1'), 'expr') + ->selectExpression('2 + 2', 'expr2'); - $this->assertSame('select (1 + 1) as "expr" from "one"', $builder->toSql()); + $this->assertSame('select (1 + 1) as "expr", (2 + 2) as "expr2" from "one"', $builder->toSql()); } public function testSelectionAliasesAreSingleIdentifiers(): void @@ -6204,31 +6206,23 @@ public function testSelectionAliasesAreSingleIdentifiers(): void } } - public function testSelectWithAliasedExpression() + public function testSelectPreservesKeyedRawExpressions(): void { $builder = $this->getBuilder(); - $builder->from('users')->select(['is_admin' => new Raw('role = 1')]); + $builder->from('users')->select(['is_admin' => new Raw('role = 1 as is_admin')]); - $this->assertSame('select (role = 1) as "is_admin" from "users"', $builder->toSql()); + $this->assertSame('select role = 1 as is_admin from "users"', $builder->toSql()); } - public function testAddSelectWithAliasedExpression() + public function testAddSelectPreservesKeyedRawExpressions(): void { $builder = $this->getBuilder(); - $builder->from('users')->select('*')->addSelect(['is_admin' => new Raw('role = 1')]); + $builder->from('users')->addSelect(['is_admin' => new Raw('role = 1 as is_admin')]); - $this->assertSame('select *, (role = 1) as "is_admin" from "users"', $builder->toSql()); + $this->assertSame('select role = 1 as is_admin from "users"', $builder->toSql()); } - public function testAddSelectWithAliasedExpressionPreservesDefaultColumns() - { - $builder = $this->getBuilder(); - $builder->from('users')->addSelect(['is_admin' => new Raw('role = 1')]); - - $this->assertSame('select "users".*, (role = 1) as "is_admin" from "users"', $builder->toSql()); - } - - public function testSelect() + public function testSelect(): void { $builder = $this->getBuilder(); $builder->from('one')->select([ @@ -6238,7 +6232,7 @@ public function testSelect() 'five' => new Raw('1 + 1'), ]); - $this->assertSame('select "two", "threee" as "threeee", (select "col" from "tbl") as "four", (1 + 1) as "five" from "one"', $builder->toSql()); + $this->assertSame('select "two", "threee" as "threeee", (select "col" from "tbl") as "four", 1 + 1 from "one"', $builder->toSql()); } public function testUppercaseLeadingBooleansAreRemoved() diff --git a/tests/Integration/Database/AuthQueryAwarePolicyTest.php b/tests/Integration/Database/AuthQueryAwarePolicyTest.php index 39037bfbff..9d0758ed0a 100644 --- a/tests/Integration/Database/AuthQueryAwarePolicyTest.php +++ b/tests/Integration/Database/AuthQueryAwarePolicyTest.php @@ -147,6 +147,7 @@ public function testPolicyBeforeResultsHydrateAsTrueAndFalseLiterals(): void ->withCan(['policy-before-allowed', 'policy-before-denied'], $this->user()) ->findOrFail('owned'); + $this->assertSame('owned', $post->id); $this->assertTrue($post->can_policy_before_allowed); $this->assertFalse($post->can_policy_before_denied); } diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index d050ae222f..64c20a4c82 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -273,7 +273,7 @@ public function testAliasedSourcesPreserveTheirColumnsWhenAddingSelections(): vo $expected = DB::table('posts')->orderBy('id')->get()->all(); foreach ([DB::table('posts', 'source'), DB::table('posts AS source'), DB::table('posts', '0')] as $query) { - $rows = $query->addSelect(['bonus' => new Expression(42)])->orderBy('id')->get(); + $rows = $query->addSelect(['bonus' => DB::query()->selectRaw('42')])->orderBy('id')->get(); $this->assertCount(2, $rows); foreach ($rows as $index => $row) { @@ -286,7 +286,7 @@ public function testAliasedSourcesPreserveTheirColumnsWhenAddingSelections(): vo $this->assertEquals([ (object) ['id' => 1, 'bonus' => 42], (object) ['id' => 2, 'bonus' => 42], - ], (clone $query)->addSelect(['bonus' => new Expression(42)])->orderBy('id')->get()->all()); + ], (clone $query)->addSelect(['bonus' => DB::query()->selectRaw('42')])->orderBy('id')->get()->all()); $this->assertEquals([ (object) ['id' => 1, 'bonus' => 7], diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php index c0bb6cbf3a..a782e6c05c 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php @@ -171,7 +171,7 @@ public function testSchemaQualifiedPrefixedTablesPreserveQueryIdentifiers(): voi $this->assertSame([ ['id' => 1, 'bonus' => 42], ['id' => 2, 'bonus' => 42], - ], $connection->table('main.items', 'source')->addSelect(['bonus' => new Expression(42)]) + ], $connection->table('main.items', 'source')->addSelect(['bonus' => $connection->query()->selectRaw('42')]) ->orderBy('id')->get()->map(static fn (object $row): array => (array) $row)->all()); $query = $connection->table('main.items', 'source') From 591c9dd55a41049e9a049891b6f30ee78c068c5e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:42:38 +0000 Subject: [PATCH 22/41] Document explicit expression aliases and policy selections Document selectExpression beside selectRaw, including string and expression inputs, single-identifier alias quoting, and when to use bindings instead. This completes the public documentation for Laravel framework PRs #58436 and #58753 while following the selection behavior restored by #58469. Explain how callers composing Gate selections use selectExpression for raw expressions and keyed addSelect for query builders. Name the model-column requirement and point callers to withCan when they want that composition handled for them. Checked the examples and descriptions against the query builder and Gate contracts; the accompanying runtime correction passed Auth and SQLite integration suites, static analysis and formatting. Upstream: https://github.com/laravel/framework/pull/58436 Upstream: https://github.com/laravel/framework/pull/58469 Upstream: https://github.com/laravel/framework/pull/58753 --- src/docs/authorization.md | 2 ++ src/docs/queries.md | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/docs/authorization.md b/src/docs/authorization.md index f368fe5596..6235621270 100644 --- a/src/docs/authorization.md +++ b/src/docs/authorization.md @@ -765,6 +765,8 @@ $selection = Gate::select('edit', $query); Both methods use the same symmetric fallback behavior as the fluent builder methods. +`Gate::select` returns either a database expression or a query builder. For an expression, select any model columns you need before calling `$query->selectExpression($selection, 'can_edit')`; for a query builder, use `$query->addSelect(['can_edit' => $selection])`. The `withCan` method handles both forms for you. + #### Query-Aware Policy Behavior diff --git a/src/docs/queries.md b/src/docs/queries.md index e70c570dce..e661a7b964 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -417,6 +417,19 @@ $orders = DB::table('orders') ->get(); ``` + +#### `selectExpression` + +The `selectExpression` method adds a raw SQL expression with an alias. It accepts a string or an expression created by `DB::raw`, wraps the expression in parentheses, and quotes the alias as a single identifier: + +```php +$orders = DB::table('orders') + ->selectExpression('price * 1.0825', 'price_with_tax') + ->get(); +``` + +This method does not accept parameter bindings. Use `selectRaw` when you need bindings or want to insert the SQL expression as written. + #### `whereRaw / orWhereRaw` From 94eb720da3d587ae7836f31977ccaf671ed8a2f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:17:37 +0000 Subject: [PATCH 23/41] Complete notification failure event regression coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the repeated-send regression from Laravel #58452 and the channel-reported failure case from #55507 against the current 13.x source at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Exercise the real provider listener for channel-owned failures and verify that a reported failure without an exception leaves no attempt state behind. Preserve the existing nested, sequential and coroutine-isolation regressions, moving their service-free test class into tests/Notifications. Retain Hypervel’s per-invocation sender and provider-owned listener instead of caching Laravel’s sender. The existing implementation avoids accumulating listeners, freezing a previous locale, or suppressing a later failure after a channel returns normally. Validation: affected test files, the Notifications ParaTest suite, formatting and full source/type analysis pass. Upstream: https://github.com/laravel/framework/pull/58452 https://github.com/laravel/framework/pull/55507 --- .../NotificationChannelManagerTest.php | 39 +++++++++ .../NotificationFailedEventTest.php | 80 ++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) rename tests/{Integration => }/Notifications/NotificationFailedEventTest.php (72%) diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index 47506a8402..cc281551a2 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -170,6 +170,45 @@ public function testNotificationNotSentWhenFailed(): void $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); } + public function testNotificationFailedDispatchedOnlyOnceWhenMultipleFailed(): void + { + $container = $this->getContainer(); + $events = $container->make(Dispatcher::class); + $manager = new ChannelManager($container); + $manager->extend('test', function () { + return new class { + private int $count = 0; + + /** + * Fail after two successful sends. + */ + public function send(mixed $notifiable, Notification $notification): void + { + if ($this->count > 1) { + throw new Exception('Channel failed.'); + } + + ++$this->count; + } + }; + }); + + // The provider owns the listener; sending must not register additional listeners. + $events->shouldNotReceive('listen'); + $events->shouldReceive('until')->times(3)->with(m::type(NotificationSending::class))->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationFailed::class)); + $events->shouldReceive('dispatch')->twice()->with(m::type(NotificationDelivered::class)); + $events->shouldReceive('dispatch')->twice()->with(m::type(NotificationSent::class)); + + $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); + $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Channel failed.'); + + $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); + } + public function testNotificationCanBeQueued(): void { $container = $this->getContainer(); diff --git a/tests/Integration/Notifications/NotificationFailedEventTest.php b/tests/Notifications/NotificationFailedEventTest.php similarity index 72% rename from tests/Integration/Notifications/NotificationFailedEventTest.php rename to tests/Notifications/NotificationFailedEventTest.php index 974b708693..2d15374d74 100644 --- a/tests/Integration/Notifications/NotificationFailedEventTest.php +++ b/tests/Notifications/NotificationFailedEventTest.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Notifications; +namespace Hypervel\Tests\Notifications; use Closure; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Notifications\ChannelManager; use Hypervel\Notifications\Events\NotificationFailed; +use Hypervel\Notifications\Events\NotificationSent; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; use Hypervel\Testbench\TestCase; @@ -18,6 +19,30 @@ class NotificationFailedEventTest extends TestCase { + public function testNotificationFailedDispatchedOnlyOnceWhenFailed(): void + { + $events = $this->app->make(Dispatcher::class); + $manager = $this->app->make(ChannelManager::class); + $dispatched = $this->recordFailures($events); + $sent = 0; + + $events->listen(NotificationSent::class, function () use (&$sent): void { + ++$sent; + }); + $manager->extend('test', fn () => new NotificationFailedEventDispatchingChannel($events, 'test')); + + // Use the real provider listener to suppress the sender's duplicate failure event. + $exception = $this->captureRuntimeException(fn () => $manager->sendNow( + new NotificationFailedEventNotifiable, + new NotificationFailedEventNotification, + ['test'], + )); + + $this->assertSame('test failed.', $exception->getMessage()); + $this->assertSame(['test'], $dispatched()); + $this->assertSame(0, $sent); + } + public function testChannelOwnedFailureSurvivesNestedSuccess(): void { $events = $this->app->make(Dispatcher::class); @@ -86,6 +111,29 @@ 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); @@ -175,6 +223,9 @@ protected function captureRuntimeException(Closure $callback): RuntimeException class NotificationFailedEventDispatchingChannel { + /** + * Create a channel that reports and throws a failure. + */ public function __construct( private readonly Dispatcher $events, private readonly string $channel, @@ -182,6 +233,9 @@ public function __construct( ) { } + /** + * Report the failure before invoking the nested send and throwing. + */ public function send(mixed $notifiable, Notification $notification): never { $this->events->dispatch(new NotificationFailed($notifiable, $notification, $this->channel)); @@ -194,8 +248,29 @@ 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 { + /** + * Complete the delivery without an exception. + */ public function send(mixed $notifiable, Notification $notification): void { } @@ -203,6 +278,9 @@ public function send(mixed $notifiable, Notification $notification): void class NotificationFailedEventThrowingChannel { + /** + * Throw a delivery failure for the sender to report. + */ public function send(mixed $notifiable, Notification $notification): never { throw new RuntimeException('Channel failed.'); From b56155e3ad81eb6c5d63ce9ca09a2ac1ae5e38b4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:17:50 +0000 Subject: [PATCH 24/41] Preserve character boundaries and encodings in string helpers Complete Laravel #58457 and #60646 from 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Pass the requested encoding to the final mask slice, including both upstream Latin-1 assertions. Use the same internal encoding for afterLast, beforeLast and unwrap positions, lengths and slices. The previous mixed byte/character and internal/UTF-8 operations could split or corrupt correctly encoded SJIS input. Keep cheap suffix checks in chopEnd and unwrap, then verify character alignment before removing a character-counted suffix; a rejected chopEnd candidate continues to the next needle. Add one encoding-scoped regression covering extraction, wrapping, a backslash byte inside an SJIS character, real trailing backslashes and multiple suffix candidates. Restore process encoding in finally without yielding. Keep substrReplace and the byte-search APIs unchanged; this does not impose a new class-wide encoding policy. As with current Laravel, afterLast now follows mbstring substitution for invalid input bytes. Validation: SupportStrTest, the Support ParaTest suite, formatting and full source/type analysis pass. Upstream: https://github.com/laravel/framework/pull/58457 https://github.com/laravel/framework/pull/60646 --- src/support/src/Str.php | 27 ++++++++++++++------- tests/Support/SupportStrTest.php | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 7aca7afc42..d4cf973f3c 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -90,13 +90,13 @@ public static function afterLast(string $subject, string|int|float|bool|BaseStri return $subject; } - $position = strrpos($subject, $search); + $position = mb_strrpos($subject, $search); if ($position === false) { return $subject; } - return substr($subject, $position + strlen($search)); + return mb_substr($subject, $position + mb_strlen($search)); } /** @@ -148,7 +148,8 @@ public static function beforeLast(string $subject, string|int|float|bool|BaseStr return $subject; } - return static::substr($subject, 0, $pos); + // Character offsets use the internal encoding, which can differ from Str::substr's UTF-8 default. + return mb_substr($subject, 0, $pos); } /** @@ -230,7 +231,12 @@ public static function chopEnd(string $subject, string|array $needle): string { foreach ((array) $needle as $n) { if ($n !== '' && str_ends_with($subject, $n)) { - return mb_substr($subject, 0, -mb_strlen($n)); + $length = mb_strlen($n); + + // A byte suffix match can start inside a multibyte character. + if (mb_substr($subject, -$length) === $n) { + return mb_substr($subject, 0, -$length); + } } } @@ -440,11 +446,16 @@ public static function wrap(string $value, string $before, ?string $after = null public static function unwrap(string $value, string $before, ?string $after = null): string { if (static::startsWith($value, $before)) { - $value = static::substr($value, static::length($before)); + $value = mb_substr($value, mb_strlen($before)); } if (static::endsWith($value, $after ??= $before)) { - $value = static::substr($value, 0, -static::length($after)); + $length = mb_strlen($after); + + // Unlike a prefix, a byte suffix match can start inside a character. + if (mb_substr($value, -$length) === $after) { + $value = mb_substr($value, 0, -$length); + } } return $value; @@ -724,7 +735,7 @@ public static function inlineMarkdown(string $string, array $options = [], array } /** - * Masks a portion of a string with a repeated character. + * Mask a portion of a string with a repeated character. */ public static function mask(string $string, string|BaseStringable $character, int $index, ?int $length = null, string $encoding = 'UTF-8'): string { @@ -749,7 +760,7 @@ public static function mask(string $string, string|BaseStringable $character, in $start = mb_substr($string, 0, $startIndex, $encoding); $segmentLen = mb_strlen($segment, $encoding); - $end = mb_substr($string, $startIndex + $segmentLen); + $end = mb_substr($string, $startIndex + $segmentLen, null, $encoding); return $start . str_repeat(mb_substr($character, 0, 1, $encoding), $segmentLen) . $end; } diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index dcf720b815..4c23a347d7 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -472,6 +472,40 @@ public function testStrBeforeLast(): void $this->assertSame('yvette', Str::beforeLast("yvette\tyv0et0te", "\t")); } + public function testStringBoundariesRespectInternalEncoding(): void + { + $encoding = mb_internal_encoding(); + + try { + mb_internal_encoding('SJIS'); + + $subject = mb_convert_encoding('日本語と日本語', 'SJIS', 'UTF-8'); + $search = mb_convert_encoding('日本', 'SJIS', 'UTF-8'); + $wrapper = mb_convert_encoding('語', 'SJIS', 'UTF-8'); + $wrapped = mb_convert_encoding('語日本語', 'SJIS', 'UTF-8'); + + $this->assertSame(mb_convert_encoding('日本語と', 'SJIS', 'UTF-8'), Str::beforeLast($subject, $search)); + $this->assertSame($wrapper, Str::afterLast($subject, $search)); + $this->assertSame($search, Str::unwrap($wrapped, $wrapper)); + + // 表 ends with byte 0x5c in SJIS, but contains no backslash character. + $leadingSubject = mb_convert_encoding('表計算', 'SJIS', 'UTF-8'); + $trailingSubject = mb_convert_encoding('計算表', 'SJIS', 'UTF-8'); + + $this->assertSame($leadingSubject, Str::afterLast($leadingSubject, '\\')); + $this->assertSame($trailingSubject, Str::chopEnd($trailingSubject, '\\')); + $this->assertSame($trailingSubject, Str::unwrap($trailingSubject, '\\')); + $this->assertSame($trailingSubject, Str::chopEnd($trailingSubject . '\\', '\\')); + $this->assertSame($trailingSubject, Str::unwrap($trailingSubject . '\\', '\\')); + $this->assertSame( + mb_convert_encoding('計算', 'SJIS', 'UTF-8'), + Str::chopEnd($trailingSubject, ['\\', mb_convert_encoding('表', 'SJIS', 'UTF-8')]), + ); + } finally { + mb_internal_encoding($encoding); + } + } + public function testStrBetween(): void { $this->assertSame('abc', Str::between('abc', '', 'c')); @@ -1505,6 +1539,12 @@ public function testMask(): void $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1)); $this->assertSame('***************', Str::mask('maria@email.com', '*', -15)); $this->assertSame('***************', Str::mask('maria@email.com', '*', 0)); + + // the trailing portion of the string must respect a non-default encoding + $latin1 = mb_convert_encoding('José Pérez García', 'ISO-8859-1', 'UTF-8'); + $expected = mb_convert_encoding('José ***** García', 'ISO-8859-1', 'UTF-8'); + $this->assertSame($expected, Str::mask($latin1, '*', 5, 5, 'ISO-8859-1')); + $this->assertSame($expected, Str::mask($latin1, '*', -12, 5, 'ISO-8859-1')); } public function testMatch(): void From e5af2ab38a7d18046d3576e78f495c32f628ad82 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:18:00 +0000 Subject: [PATCH 25/41] Document fluent resource collection key preservation Complete the user documentation for Laravel #58443 by showing preserveKeys on the anonymous collection returned by UserResource::collection. The method and its upstream runtime regression were already present; this example makes the per-collection option discoverable beside the existing class-level guidance. Checked the example against AnonymousResourceCollection and the pinned Laravel 13.x source at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. No source behavior or new test machinery is needed. Upstream: https://github.com/laravel/framework/pull/58443 --- src/docs/eloquent-resources.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md index 4ab568f769..c83e0b9f7b 100644 --- a/src/docs/eloquent-resources.md +++ b/src/docs/eloquent-resources.md @@ -264,6 +264,12 @@ Route::get('/users', function () { }); ``` +You may also preserve keys for an individual collection by calling the `preserveKeys` method on the collection returned by `UserResource::collection`: + +```php +return UserResource::collection(User::all()->keyBy->id)->preserveKeys(); +``` + #### Customizing the Underlying Resource Class From 86d9ed8fdbcfd71e6330ab140202ea3408910ee5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:01:56 +0000 Subject: [PATCH 26/41] Preserve morph-map opt-in and integer model aliases Honor the morph-map setting when restoring model identifiers so a class-name map key cannot redirect an ordinary queued model while alias serialization is disabled. Store integer morph aliases as integers, matching Laravel payloads and allowing Hypervel workers to deserialize them. Keep the nullable-string getter contract through explicit scalar normalization under strict typing. Preserve public property order and constructor class-name input. Extend the owning tests for opt-in gating, exact integer payloads, restoration, unmapped alias conversion and null classes. Rely on the existing global test subscriber for static cleanup. Upstream: https://github.com/laravel/framework/pull/58939 Related feature: https://github.com/laravel/framework/pull/58482 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: owning serialization tests, Queue/Log/Scout suites, full source and type-fixture analysis, formatting and diff checks pass. Regression assertions fail against the previous implementation. --- .../src/Database/ModelIdentifier.php | 20 ++--- .../Database/ModelIdentifierTest.php | 87 ++++++++++++++----- 2 files changed, 76 insertions(+), 31 deletions(-) diff --git a/src/contracts/src/Database/ModelIdentifier.php b/src/contracts/src/Database/ModelIdentifier.php index 27130296ec..3cfcad86c5 100644 --- a/src/contracts/src/Database/ModelIdentifier.php +++ b/src/contracts/src/Database/ModelIdentifier.php @@ -8,15 +8,12 @@ use Hypervel\Database\Eloquent\Relations\Relation; /** - * NOTE: Do not use constructor property promotion here. + * Do not use constructor property promotion here. * * The order these properties are declared in is part of the serialized output, * and Laravel expects that exact order. If this class is switched to constructor * property promotion, PHP will change the property declaration order and the * serialized string will no longer match Laravel. - * - * Keep these properties explicitly declared in this exact order: - * class, id, relations, connection, collectionClass. */ class ModelIdentifier { @@ -26,9 +23,9 @@ class ModelIdentifier protected static bool $useMorphMap = false; /** - * The class name of the model, or its morph-map alias when enabled. + * The class name of the model, or its string or integer morph-map alias when enabled. */ - public ?string $class; + public int|string|null $class; /** * The unique identifier of the model. @@ -67,7 +64,7 @@ class ModelIdentifier public function __construct(?string $class, mixed $id, array $relations, ?string $connection = null) { if ($class !== null && static::$useMorphMap) { - $class = (string) Relation::getMorphAlias($class); + $class = Relation::getMorphAlias($class); } $this->class = $class; @@ -93,11 +90,14 @@ public function useCollectionClass(?string $collectionClass): static */ public function getClass(): ?string { - if ($this->class === null) { - return null; + $class = $this->class; + + if (static::$useMorphMap && $class !== null) { + $class = Relation::getMorphedModel($class) ?? $class; } - return Relation::getMorphedModel($this->class) ?? $this->class; + // Unmapped integer aliases still follow the nullable-string getter contract. + return $class === null ? null : (string) $class; } /** diff --git a/tests/Contracts/Database/ModelIdentifierTest.php b/tests/Contracts/Database/ModelIdentifierTest.php index 0e2964bee5..0c7cbb7121 100644 --- a/tests/Contracts/Database/ModelIdentifierTest.php +++ b/tests/Contracts/Database/ModelIdentifierTest.php @@ -7,32 +7,77 @@ use Hypervel\Contracts\Database\ModelIdentifier; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Tests\TestCase; +use stdClass; class ModelIdentifierTest extends TestCase { public function testFlushStateRestoresRawClassSerialization(): void { - try { - Relation::morphMap([ - 'model-identifier-user' => ModelIdentifierTestUser::class, - ]); - ModelIdentifier::useMorphMap(); - - $this->assertSame( - 'model-identifier-user', - (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class - ); - - ModelIdentifier::flushState(); - - $this->assertSame( - ModelIdentifierTestUser::class, - (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class - ); - } finally { - Relation::morphMap([], false); - ModelIdentifier::flushState(); - } + Relation::morphMap([ + 'model-identifier-user' => ModelIdentifierTestUser::class, + ]); + ModelIdentifier::useMorphMap(); + + $this->assertSame( + 'model-identifier-user', + (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class + ); + + ModelIdentifier::flushState(); + + $this->assertSame( + ModelIdentifierTestUser::class, + (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class + ); + } + + public function testClassNamesAreNotRemappedUnlessMorphMapsAreEnabled(): void + { + Relation::morphMap([ModelIdentifierTestUser::class => stdClass::class]); + + $identifier = new ModelIdentifier(ModelIdentifierTestUser::class, 1, []); + + $this->assertSame(ModelIdentifierTestUser::class, $identifier->getClass()); + + ModelIdentifier::useMorphMap(); + + $this->assertSame(stdClass::class, $identifier->getClass()); + } + + public function testIntegerMorphAliasesKeepTheirSerializedType(): void + { + Relation::morphMap([1 => ModelIdentifierTestUser::class]); + ModelIdentifier::useMorphMap(); + + $identifier = new ModelIdentifier(ModelIdentifierTestUser::class, 1, []); + $serialized = sprintf( + 'O:%d:"%s":5:{s:5:"class";i:1;s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";N;s:15:"collectionClass";N;}', + strlen(ModelIdentifier::class), + ModelIdentifier::class, + ); + + $this->assertSame($serialized, serialize($identifier)); + + $restored = unserialize($serialized); + + $this->assertInstanceOf(ModelIdentifier::class, $restored); + $this->assertSame(1, $restored->class); + $this->assertSame(ModelIdentifierTestUser::class, $restored->getClass()); + + Relation::morphMap([], false); + + $this->assertSame('1', $restored->getClass()); + } + + public function testNullClassIsPreserved(): void + { + $identifier = new ModelIdentifier(null, [], []); + + $this->assertNull($identifier->getClass()); + + ModelIdentifier::useMorphMap(); + + $this->assertNull($identifier->getClass()); } } From c6f8467501ac1aa2674555ed5f7e826fcf17c923 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:01:56 +0000 Subject: [PATCH 27/41] Honor inherited relation exclusion when serializing jobs Recognize WithoutRelations on parent job classes while retaining cached metadata lookup for long-lived workers. Cache concrete and inherited presence separately; inspect parent classes without constructing attributes or inheriting trait attributes. Existing callers retain concrete-only lookup by default, and the existing reset clears both modes. Correct the old inherited-attribute expectation and port the upstream parent-class integration case. Restore the original one-model morph-map collection regression, including its exact serialized bytes and identity assertions, while preserving the custom collection ordering test. Remove duplicate static teardown and complete the touched test files native typing and method titles. Upstream: https://github.com/laravel/framework/pull/59568 https://github.com/laravel/framework/pull/58939 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: all four owning test files and Queue/Log/Scout suites pass, along with full source/type analysis and formatting. The inherited fixture retains relations with the old trait and excludes them with this fix. Cache tests cover independent modes, negative caching, trait exclusion and reset behavior. --- src/queue/src/SerializesModels.php | 2 +- src/reflection/src/ClassMetadataCache.php | 25 +- .../Queue/ModelSerializationTest.php | 241 ++++++++++++++---- tests/Queue/SerializesModelsTest.php | 55 +++- tests/Support/ClassMetadataCacheTest.php | 28 +- 5 files changed, 287 insertions(+), 64 deletions(-) diff --git a/src/queue/src/SerializesModels.php b/src/queue/src/SerializesModels.php index 8421452569..bc3478d20e 100644 --- a/src/queue/src/SerializesModels.php +++ b/src/queue/src/SerializesModels.php @@ -22,7 +22,7 @@ public function __serialize(): array [$class, $properties, $classLevelWithoutRelations] = [ get_class($this), ClassMetadataCache::properties($this), - ClassMetadataCache::hasClassAttribute($this, WithoutRelations::class), + ClassMetadataCache::hasClassAttribute($this, WithoutRelations::class, ascend: true), ]; foreach ($properties as $property) { diff --git a/src/reflection/src/ClassMetadataCache.php b/src/reflection/src/ClassMetadataCache.php index 9725f7acac..68d12f215d 100644 --- a/src/reflection/src/ClassMetadataCache.php +++ b/src/reflection/src/ClassMetadataCache.php @@ -38,7 +38,7 @@ class ClassMetadataCache protected static array $attributes = []; /** - * @var array> + * @var array>> */ protected static array $classAttributePresence = []; @@ -133,27 +133,32 @@ public static function getAttribute(object|string $target, string $attributeClas } /** - * Determine if the given class has the given concrete class attribute. + * Determine if the given class has the given attribute, optionally checking its parents. * * @param class-string|object $target * @param class-string $attributeClass * * @throws ReflectionException */ - public static function hasClassAttribute(object|string $target, string $attributeClass): bool + public static function hasClassAttribute(object|string $target, string $attributeClass, bool $ascend = false): bool { $class = static::className($target); + $cacheKey = (int) $ascend; - if (! array_key_exists($class, static::$classAttributePresence)) { - static::$classAttributePresence[$class] = []; + if (isset(static::$classAttributePresence[$class][$attributeClass][$cacheKey])) { + return static::$classAttributePresence[$class][$attributeClass][$cacheKey]; } - if (array_key_exists($attributeClass, static::$classAttributePresence[$class])) { - return static::$classAttributePresence[$class][$attributeClass]; - } + $reflection = static::reflectClass($class); + + // Presence checks do not instantiate attributes or inherit attributes from traits. + do { + if ($reflection->getAttributes($attributeClass) !== []) { + return static::$classAttributePresence[$class][$attributeClass][$cacheKey] = true; + } + } while ($ascend && ($reflection = $reflection->getParentClass()) !== false); - return static::$classAttributePresence[$class][$attributeClass] - = static::reflectClass($class)->getAttributes($attributeClass) !== []; + return static::$classAttributePresence[$class][$attributeClass][$cacheKey] = false; } /** diff --git a/tests/Integration/Queue/ModelSerializationTest.php b/tests/Integration/Queue/ModelSerializationTest.php index 4c7d950fe2..783bccbcf6 100644 --- a/tests/Integration/Queue/ModelSerializationTest.php +++ b/tests/Integration/Queue/ModelSerializationTest.php @@ -24,12 +24,14 @@ use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use LogicException; -use Override; class ModelSerializationTest extends TestCase { use RefreshDatabase; + /** + * Define the additional database connection. + */ protected function defineEnvironment(ApplicationContract $app): void { $app->make('config')->set('database.connections.custom', [ @@ -39,56 +41,50 @@ protected function defineEnvironment(ApplicationContract $app): void ]); } + /** + * Set up the model serialization tables. + */ protected function setUp(): void { parent::setUp(); Model::preventLazyLoading(false); - Schema::create('users', function (Blueprint $table) { + Schema::create('users', function (Blueprint $table): void { $table->increments('id'); $table->string('email'); }); - Schema::connection('custom')->create('users', function (Blueprint $table) { + Schema::connection('custom')->create('users', function (Blueprint $table): void { $table->increments('id'); $table->string('email'); }); - Schema::create('orders', function (Blueprint $table) { + Schema::create('orders', function (Blueprint $table): void { $table->increments('id'); }); - Schema::create('lines', function (Blueprint $table) { + Schema::create('lines', function (Blueprint $table): void { $table->increments('id'); $table->unsignedInteger('order_id'); $table->unsignedInteger('product_id'); }); - Schema::create('products', function (Blueprint $table) { + Schema::create('products', function (Blueprint $table): void { $table->increments('id'); }); - Schema::create('roles', function (Blueprint $table) { + Schema::create('roles', function (Blueprint $table): void { $table->increments('id'); }); - Schema::create('role_user', function (Blueprint $table) { + Schema::create('role_user', function (Blueprint $table): void { $table->unsignedInteger('user_id'); $table->unsignedInteger('role_id'); }); } - #[Override] - protected function tearDown(): void - { - Relation::morphMap([], false); - ModelIdentifier::useMorphMap(false); - - parent::tearDown(); - } - - public function testItSerializeUserOnDefaultConnection() + public function testItSerializeUserOnDefaultConnection(): void { $defaultConnection = config('database.default'); @@ -117,7 +113,7 @@ public function testItSerializeUserOnDefaultConnection() $this->assertSame('taylor@laravel.com', $unSerialized->users[1]->email); } - public function testItSerializeUserOnDifferentConnection() + public function testItSerializeUserOnDifferentConnection(): void { $user = ModelSerializationTestUser::on('custom')->create([ 'email' => 'mohamed@laravel.com', @@ -144,7 +140,7 @@ public function testItSerializeUserOnDifferentConnection() $this->assertSame('taylor@laravel.com', $unSerialized->users[1]->email); } - public function testItFailsIfModelsOnMultiConnections() + public function testItFailsIfModelsOnMultiConnections(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage('Queueing collections with multiple model connections is not supported.'); @@ -164,9 +160,9 @@ public function testItFailsIfModelsOnMultiConnections() unserialize($serialized); } - public function testItReloadsRelationships() + public function testItReloadsRelationships(): void { - $order = tap(Order::create(), function (Order $order) { + $order = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -184,9 +180,9 @@ public function testItReloadsRelationships() $this->assertEquals($unSerialized->order->getRelations(), $order->getRelations()); } - public function testItReloadsRelationshipsOnlyOnce() + public function testItReloadsRelationshipsOnlyOnce(): void { - $order = tap(ModelSerializationTestCustomOrder::create(), function (ModelSerializationTestCustomOrder $order) { + $order = tap(ModelSerializationTestCustomOrder::create(), function (ModelSerializationTestCustomOrder $order): void { $order->wasRecentlyCreated = false; }); @@ -206,9 +202,9 @@ public function testItReloadsRelationshipsOnlyOnce() $this->assertEquals($unSerialized->order->getRelations(), $order->getRelations()); } - public function testItReloadsNestedRelationships() + public function testItReloadsNestedRelationships(): void { - $order = tap(Order::create(), function (Order $order) { + $order = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -226,13 +222,13 @@ public function testItReloadsNestedRelationships() $this->assertEquals($nestedUnSerialized->order->getRelations(), $order->getRelations()); } - public function testItReloadsRelationshipsForCollections() + public function testItReloadsRelationshipsForCollections(): void { - $order1 = tap(Order::create(), function (Order $order) { + $order1 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); - $order2 = tap(Order::create(), function (Order $order) { + $order2 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -256,13 +252,13 @@ public function testItReloadsRelationshipsForCollections() $this->assertTrue($unSerialized->orders[1]->relationLoaded('products')); } - public function testItReloadsNestedRelationshipsForCollections() + public function testItReloadsNestedRelationshipsForCollections(): void { - $order1 = tap(Order::create(), function (Order $order) { + $order1 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); - $order2 = tap(Order::create(), function (Order $order) { + $order2 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -288,7 +284,7 @@ public function testItReloadsNestedRelationshipsForCollections() $this->assertTrue($unSerialized->orders[1]->lines->first()->relationLoaded('product')); } - public function testItCanRunModelBootsAndTraitInitializations() + public function testItCanRunModelBootsAndTraitInitializations(): void { $model = new ModelBootTestWithTraitInitialization; @@ -318,11 +314,11 @@ public function testItCanRunModelBootsAndTraitInitializations() /** * Regression test for https://github.com/laravel/framework/issues/23068. */ - public function testItCanUnserializeNestedRelationshipsWithoutPivot() + public function testItCanUnserializeNestedRelationshipsWithoutPivot(): void { $user = tap(User::create([ 'email' => 'taylor@laravel.com', - ]), function (User $user) { + ]), function (User $user): void { $user->wasRecentlyCreated = false; }); @@ -332,7 +328,7 @@ public function testItCanUnserializeNestedRelationshipsWithoutPivot() RoleUser::create(['user_id' => $user->id, 'role_id' => $role1->id]); RoleUser::create(['user_id' => $user->id, 'role_id' => $role2->id]); - $user->roles->each(function ($role) { + $user->roles->each(function (Role $role): void { $role->pivot->load('user', 'role'); }); @@ -340,7 +336,7 @@ public function testItCanUnserializeNestedRelationshipsWithoutPivot() unserialize($serialized); } - public function testItSerializesAnEmptyCollection() + public function testItSerializesAnEmptyCollection(): void { $serialized = serialize(new CollectionSerializationTestClass( new Collection([]) @@ -349,7 +345,7 @@ public function testItSerializesAnEmptyCollection() unserialize($serialized); } - public function testItSerializesACollectionInCorrectOrder() + public function testItSerializesACollectionInCorrectOrder(): void { ModelSerializationTestUser::create(['email' => 'mohamed@laravel.com']); ModelSerializationTestUser::create(['email' => 'taylor@laravel.com']); @@ -364,7 +360,7 @@ public function testItSerializesACollectionInCorrectOrder() $this->assertSame('mohamed@laravel.com', $unserialized->users->last()->email); } - public function testItCanUnserializeACollectionInCorrectOrderAndHandleDeletedModels() + public function testItCanUnserializeACollectionInCorrectOrderAndHandleDeletedModels(): void { ModelSerializationTestUser::create(['email' => '2@laravel.com']); ModelSerializationTestUser::create(['email' => '3@laravel.com']); @@ -384,7 +380,7 @@ public function testItCanUnserializeACollectionInCorrectOrderAndHandleDeletedMod $this->assertSame('1@laravel.com', $unserialized->users->last()->email); } - public function testItCanUnserializeCustomCollection() + public function testItCanUnserializeCustomCollection(): void { ModelSerializationTestCustomUser::create(['email' => 'mohamed@laravel.com']); ModelSerializationTestCustomUser::create(['email' => 'taylor@laravel.com']); @@ -398,7 +394,7 @@ public function testItCanUnserializeCustomCollection() $this->assertInstanceOf(ModelSerializationTestCustomUserCollection::class, $unserialized->users); } - public function testItSerializesTypedProperties() + public function testItSerializesTypedProperties(): void { require_once __DIR__ . '/typed-properties.php'; @@ -432,7 +428,7 @@ public function testItSerializesTypedProperties() } #[WithConfig('database.default', 'testing')] - public function testModelSerializationStructure() + public function testModelSerializationStructure(): void { $user = ModelSerializationTestUser::create([ 'email' => 'taylor@laravel.com', @@ -444,7 +440,7 @@ public function testModelSerializationStructure() } #[WithConfig('database.default', 'testing')] - public function testItRespectsWithoutRelationsAttribute() + public function testItRespectsWithoutRelationsAttribute(): void { $user = User::create([ 'email' => 'taylor@laravel.com', @@ -456,7 +452,7 @@ public function testItRespectsWithoutRelationsAttribute() } #[WithConfig('database.default', 'testing')] - public function testItRespectsWithoutRelationsAttributeAppliedToClass() + public function testItRespectsWithoutRelationsAttributeAppliedToClass(): void { $user = User::create([ 'email' => 'taylor@laravel.com', @@ -470,10 +466,26 @@ public function testItRespectsWithoutRelationsAttributeAppliedToClass() $unserialized = unserialize($serialized); $this->assertFalse($unserialized->user->relationLoaded('roles')); - $this->assertEquals('hello', $unserialized->value->value); + $this->assertSame('hello', $unserialized->value->value); } - public function testSerializationTypesEmptyCustomEloquentCollection() + #[WithConfig('database.default', 'testing')] + public function testItRespectsWithoutRelationsAttributeAppliedToParentClass(): void + { + $user = User::create([ + 'email' => 'taylor@laravel.com', + ])->load(['roles']); + + $serialized = serialize(new ModelSerializationAttributeTargetsParentClassTestClass($user, new DataValueObject('hello'))); + + /** @var ModelSerializationAttributeTargetsParentClassTestClass $unserialized */ + $unserialized = unserialize($serialized); + + $this->assertFalse($unserialized->user->relationLoaded('roles')); + $this->assertSame('hello', $unserialized->value->value); + } + + public function testSerializationTypesEmptyCustomEloquentCollection(): void { $class = new ModelSerializationTypedCustomCollectionTestClass( new ModelSerializationTestCustomUserCollection @@ -512,7 +524,42 @@ public function testItUsesMorphMapForSerialization(): void } #[WithConfig('database.default', 'testing')] - public function testItUsesMorphMapForCollectionSerialization(): void + public function testItUsesMorphMapForSerializationOfCollection(): void + { + Relation::morphMap([ + 'user' => User::class, + ]); + + ModelIdentifier::useMorphMap(); + + $user = User::create([ + 'email' => 'taylor@laravel.com', + ]); + + $serialized = serialize(new CollectionSerializationTestClass( + new Collection([$user]), + )); + + $this->assertSame( + sprintf( + 'O:%d:"%s":1:{s:5:"users";O:%d:"%s":5:{s:5:"class";s:4:"user";s:2:"id";a:1:{i:0;i:1;}s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";s:15:"collectionClass";N;}}', + strlen(CollectionSerializationTestClass::class), + CollectionSerializationTestClass::class, + strlen(ModelIdentifier::class), + ModelIdentifier::class, + ), + $serialized + ); + + /** @var CollectionSerializationTestClass $unserialized */ + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(Collection::class, $unserialized->users); + $this->assertTrue($unserialized->users->sole()->is($user)); + } + + #[WithConfig('database.default', 'testing')] + public function testItRestoresMorphMappedCollectionsInOrder(): void { Relation::morphMap([ 'user' => User::class, @@ -540,6 +587,9 @@ public function testItUsesMorphMapForCollectionSerialization(): void $this->assertSame('mohamed@laravel.com', $unserialized->users[1]->email); } + /** + * Get the expected serialization of accessible parent properties. + */ private function expectedParentAccessibleSerialization(): string { $class = ModelSerializationParentAccessibleTestClass::class; @@ -562,6 +612,9 @@ private function expectedParentAccessibleSerialization(): string ); } + /** + * Get the expected serialization without relationships. + */ private function expectedWithoutRelationsSerialization(): string { $class = ModelSerializationWithoutRelations::class; @@ -579,6 +632,9 @@ private function expectedWithoutRelationsSerialization(): string ); } + /** + * Get the expected serialization for class-level relation exclusion. + */ private function expectedAttributeTargetsClassSerialization(string $userClass = User::class): string { $class = ModelSerializationAttributeTargetsClassTestClass::class; @@ -605,24 +661,36 @@ trait TraitBootsAndInitializersTest public bool $fooBar = false; + /** + * Toggle the trait initialization state. + */ public function initializeTraitBootsAndInitializersTest(): void { $this->fooBar = ! $this->fooBar; } + /** + * Register the trait's global scope. + */ public static function bootTraitBootsAndInitializersTest(): void { - static::addGlobalScope('foo_bar', function () { + static::addGlobalScope('foo_bar', function (): void { }); } + /** + * Register the attributed trait's global scope. + */ #[Boot] public static function nonConventionalBootFunctionInTrait(): void { - static::addGlobalScope('booted_attr_in_trait', function () { + static::addGlobalScope('booted_attr_in_trait', function (): void { }); } + /** + * Toggle the attributed trait initialization state. + */ #[Initialize] public function nonConventionalInitFunctionInTrait(): void { @@ -638,13 +706,19 @@ class ModelBootTestWithTraitInitialization extends Model public bool $initializedViaAttributeInClass = false; + /** + * Register the attributed model's global scope. + */ #[Boot] public static function nonConventionalBootFunctionInClass(): void { - static::addGlobalScope('booted_attr_in_class', function () { + static::addGlobalScope('booted_attr_in_class', function (): void { }); } + /** + * Toggle the attributed model initialization state. + */ #[Initialize] public function nonConventionalInitFunctionInClass(): void { @@ -671,6 +745,9 @@ class ModelSerializationTypedCustomCollectionTestClass public ModelSerializationTestCustomUserCollection $collection; + /** + * Create a fixture containing a custom collection. + */ public function __construct(ModelSerializationTestCustomUserCollection $collection) { $this->collection = $collection; @@ -685,6 +762,9 @@ class ModelSerializationTestCustomUser extends Model public bool $timestamps = false; + /** + * Create the model's custom collection. + */ public function newCollection(array $models = []): ModelSerializationTestCustomUserCollection { return new ModelSerializationTestCustomUserCollection($models); @@ -701,16 +781,25 @@ class ModelSerializationTestCustomOrder extends Model protected array $with = ['line', 'lines', 'products']; + /** + * Get the order's first line. + */ public function line(): HasOne { return $this->hasOne(Line::class, 'order_id'); } + /** + * Get the order's lines. + */ public function lines(): HasMany { return $this->hasMany(Line::class, 'order_id'); } + /** + * Get the order's products. + */ public function products(): BelongsToMany { return $this->belongsToMany(Product::class, 'lines', 'order_id'); @@ -723,16 +812,25 @@ class Order extends Model public bool $timestamps = false; + /** + * Get the order's first line. + */ public function line(): HasOne { return $this->hasOne(Line::class); } + /** + * Get the order's lines. + */ public function lines(): HasMany { return $this->hasMany(Line::class); } + /** + * Get the order's products. + */ public function products(): BelongsToMany { return $this->belongsToMany(Product::class, 'lines'); @@ -745,6 +843,9 @@ class Line extends Model public bool $timestamps = false; + /** + * Get the line's product. + */ public function product(): BelongsTo { return $this->belongsTo(Product::class); @@ -764,6 +865,9 @@ class User extends Model public bool $timestamps = false; + /** + * Get the user's roles. + */ public function roles(): BelongsToMany { return $this->belongsToMany(Role::class) @@ -777,6 +881,9 @@ class Role extends Model public bool $timestamps = false; + /** + * Get the role's users. + */ public function users(): BelongsToMany { return $this->belongsToMany(User::class) @@ -790,11 +897,17 @@ class RoleUser extends Pivot public bool $timestamps = false; + /** + * Get the pivot's user. + */ public function user(): BelongsTo { return $this->belongsTo(User::class); } + /** + * Get the pivot's role. + */ public function role(): BelongsTo { return $this->belongsTo(Role::class); @@ -807,6 +920,9 @@ class ModelSerializationTestClass public ModelSerializationTestUser|User $user; + /** + * Create a fixture containing a user. + */ public function __construct(ModelSerializationTestUser|User $user) { $this->user = $user; @@ -823,6 +939,9 @@ class ModelSerializationAccessibleTestClass private ModelSerializationTestUser $user3; + /** + * Create a fixture with public, protected and private model properties. + */ public function __construct(ModelSerializationTestUser $user, ModelSerializationTestUser $user2, ModelSerializationTestUser $user3) { $this->user = $user; @@ -842,6 +961,9 @@ class ModelSerializationWithoutRelations #[WithoutRelations] public User $user; + /** + * Create a fixture whose model excludes relationships. + */ public function __construct(User $user) { $this->user = $user; @@ -853,17 +975,27 @@ class ModelSerializationAttributeTargetsClassTestClass { use SerializesModels; + /** + * Create a fixture with class-level relation exclusion. + */ public function __construct(public User $user, public DataValueObject $value) { } } +class ModelSerializationAttributeTargetsParentClassTestClass extends ModelSerializationAttributeTargetsClassTestClass +{ +} + class ModelRelationSerializationTestClass { use SerializesModels; public Order|ModelSerializationTestCustomOrder $order; + /** + * Create a fixture containing an order. + */ public function __construct(Order|ModelSerializationTestCustomOrder $order) { $this->order = $order; @@ -876,6 +1008,9 @@ class CollectionSerializationTestClass public Collection $users; + /** + * Create a fixture containing users. + */ public function __construct(Collection $users) { $this->users = $users; @@ -888,6 +1023,9 @@ class CollectionRelationSerializationTestClass public Collection $orders; + /** + * Create a fixture containing orders. + */ public function __construct(Collection $orders) { $this->orders = $orders; @@ -896,6 +1034,9 @@ public function __construct(Collection $orders) class DataValueObject { + /** + * Create a value object for serialization. + */ public function __construct(public string|int $value = 1) { } diff --git a/tests/Queue/SerializesModelsTest.php b/tests/Queue/SerializesModelsTest.php index c7c5bcb056..64282711e4 100644 --- a/tests/Queue/SerializesModelsTest.php +++ b/tests/Queue/SerializesModelsTest.php @@ -25,14 +25,14 @@ public function testConcreteClassWithoutRelationsAttributeStripsRelations(): voi $this->assertSame([], $payload['entity']->relations); } - public function testInheritedClassWithoutRelationsAttributeIsNotAppliedToChild(): void + public function testInheritedClassWithoutRelationsAttributeIsAppliedToChild(): void { $payload = (new ChildClassInheritingWithoutRelationsSerializationFixture( new QueueableEntitySerializationFixture ))->__serialize(); $this->assertInstanceOf(ModelIdentifier::class, $payload['entity']); - $this->assertSame(['roles'], $payload['entity']->relations); + $this->assertSame([], $payload['entity']->relations); } public function testPropertyWithoutRelationsAttributeStripsRelations(): void @@ -82,6 +82,9 @@ class EloquentModelSerializationFixture { use SerializesModels; + /** + * Create a fixture containing an Eloquent model. + */ public function __construct(public Model $model) { } @@ -96,6 +99,9 @@ class ClassWithoutRelationsSerializationFixture { use SerializesModels; + /** + * Create a fixture with class-level relation exclusion. + */ public function __construct( public QueueableEntitySerializationFixture $entity, ) { @@ -107,6 +113,9 @@ class ParentClassWithoutRelationsSerializationFixture { use SerializesModels; + /** + * Create a parent fixture with relation exclusion. + */ public function __construct( public QueueableEntitySerializationFixture $entity, ) { @@ -121,6 +130,9 @@ class PropertyWithoutRelationsSerializationFixture { use SerializesModels; + /** + * Create a fixture with property-level relation exclusion. + */ public function __construct( #[WithoutRelations] public QueueableEntitySerializationFixture $entity, @@ -131,16 +143,25 @@ public function __construct( class QueueableEntitySerializationFixture extends Model { + /** + * Get the identifier for the fixture. + */ public function getQueueableId(): int { return 1; } + /** + * Get the fixture's queueable relationships. + */ public function getQueueableRelations(): array { return ['roles']; } + /** + * Get the fixture's queueable connection. + */ public function getQueueableConnection(): ?string { return 'testing'; @@ -151,6 +172,9 @@ class NonEloquentQueueablesSerializationFixture { use SerializesModels; + /** + * Create a fixture containing non-Eloquent queueable objects. + */ public function __construct( public NonEloquentQueueableEntitySerializationFixture $entity, public NonEloquentQueueableCollectionSerializationFixture $collection, @@ -160,21 +184,33 @@ public function __construct( class NonEloquentQueueableEntitySerializationFixture implements QueueableEntity { + /** + * Create a queueable entity with the given value. + */ public function __construct( public string $value, ) { } + /** + * Get the queueable identifier. + */ public function getQueueableId(): string { return $this->value; } + /** + * Get the queueable relationships. + */ public function getQueueableRelations(): array { return []; } + /** + * Get the queueable connection. + */ public function getQueueableConnection(): ?string { return null; @@ -183,26 +219,41 @@ public function getQueueableConnection(): ?string class NonEloquentQueueableCollectionSerializationFixture implements QueueableCollection { + /** + * Create a queueable collection with the given items. + */ public function __construct( public array $items, ) { } + /** + * Get the class of the queueable entities. + */ public function getQueueableClass(): ?string { return NonEloquentQueueableEntitySerializationFixture::class; } + /** + * Get the queueable identifiers. + */ public function getQueueableIds(): array { return array_keys($this->items); } + /** + * Get the queueable relationships. + */ public function getQueueableRelations(): array { return []; } + /** + * Get the queueable connection. + */ public function getQueueableConnection(): ?string { return null; diff --git a/tests/Support/ClassMetadataCacheTest.php b/tests/Support/ClassMetadataCacheTest.php index 9c359455a6..af1766f34f 100644 --- a/tests/Support/ClassMetadataCacheTest.php +++ b/tests/Support/ClassMetadataCacheTest.php @@ -130,7 +130,20 @@ public function testConcreteClassAttributePresenceDoesNotWalkParentsOrTraits(): $classAttributePresence = $this->staticProperty('classAttributePresence'); $this->assertArrayHasKey(ClassMetadataCacheAttribute::class, $classAttributePresence[ClassMetadataCacheChildFixture::class]); - $this->assertFalse($classAttributePresence[ClassMetadataCacheChildFixture::class][ClassMetadataCacheAttribute::class]); + $this->assertFalse($classAttributePresence[ClassMetadataCacheChildFixture::class][ClassMetadataCacheAttribute::class][0]); + } + + public function testInheritedClassAttributePresenceIsCachedSeparately(): void + { + $this->assertFalse(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class)); + $this->assertTrue(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class, ascend: true)); + $this->assertFalse(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class)); + $this->assertFalse(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheTraitFixture::class, ClassMetadataCacheAttribute::class, ascend: true)); + + $classAttributePresence = $this->staticProperty('classAttributePresence'); + + $this->assertSame([false, true], $classAttributePresence[ClassMetadataCacheChildFixture::class][ClassMetadataCacheAttribute::class]); + $this->assertFalse($classAttributePresence[ClassMetadataCacheTraitFixture::class][ClassMetadataCacheAttribute::class][1]); } public function testPropertyAttributePresenceIsCached(): void @@ -169,6 +182,7 @@ public function testFlushStateClearsCachedMetadata(): void ClassMetadataCache::getAttribute(ClassMetadataCacheAttributedFixture::class, ClassMetadataCacheAttribute::class); ClassMetadataCache::hasClassAttribute(ClassMetadataCacheParentFixture::class, ClassMetadataCacheAttribute::class); + ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class, ascend: true); ClassMetadataCache::flushState(); $this->assertSame([], $this->staticProperty('methods')); @@ -213,6 +227,9 @@ class ClassMetadataCacheFixture { public string $name = 'hypervel'; + /** + * Return a greeting. + */ public function greet(): string { return 'hello'; @@ -268,6 +285,9 @@ class ClassMetadataCacheErrorFixture #[Attribute(Attribute::TARGET_CLASS)] readonly class ClassMetadataCacheAttribute { + /** + * Create an attribute with the given value. + */ public function __construct( public string $value, ) { @@ -282,6 +302,9 @@ public function __construct( #[Attribute(Attribute::TARGET_CLASS)] readonly class ClassMetadataCacheExceptionAttribute { + /** + * Throw an exception while constructing the attribute. + */ public function __construct() { throw new RuntimeException('Cached as null.'); @@ -291,6 +314,9 @@ public function __construct() #[Attribute(Attribute::TARGET_CLASS)] readonly class ClassMetadataCacheErrorAttribute { + /** + * Throw an error while constructing the attribute. + */ public function __construct() { throw new Error('Uncached attribute error.'); From a5055cc75db6cf04dfdad27d7aceefa19db39e0f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:01:56 +0000 Subject: [PATCH 28/41] Document queued relationship restoration and morph aliases Correct the stale claim that queued Eloquent collections never restore relationships. Explain that shared loaded relations are restored, distinguish plain arrays that carry full model state, and describe WithoutRelations on parent job classes. Document the existing morph-map serialization opt-in at its application-facing surface. Explain how stable aliases support model renames and require producers and workers to retain matching maps and settings while aliased jobs remain queued. Upstream features: https://github.com/laravel/framework/pull/58477 https://github.com/laravel/framework/pull/58482 https://github.com/laravel/framework/pull/59568 Compared with laravel/docs at 89e91b5cff48e1b9b1a7921300653eb1ceb7bfcb, which still contains the stale collection paragraph and omits the other guidance. Validation: documentation checked against collection relation intersection, model restoration and ordinary array serialization. Existing and ported integration tests pass; linked documentation anchor and diff checks verified. --- src/docs/queues.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index 0618eb377c..66580999ea 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -325,6 +325,25 @@ In this example, note that we were able to pass an [Eloquent model](/docs/{{vers If your queued job accepts an Eloquent model in its constructor, only the identifier for the model will be serialized onto the queue. When the job is actually handled, the queue system will automatically re-retrieve the full model instance and its loaded relationships from the database. This approach to model serialization allows for much smaller job payloads to be sent to your queue driver. + +#### Serializing Models Using Morph Maps + +By default, queued models are identified by their fully qualified class names. A stable morph alias lets queued models be restored after their class is renamed or moved, provided the morph map points to the new class. If you have defined a [morph map](/docs/{{version}}/eloquent-relationships#custom-polymorphic-types), you may use its aliases instead by calling `ModelIdentifier::useMorphMap` in the `boot` method of your application's `AppServiceProvider`: + +```php +use Hypervel\Contracts\Database\ModelIdentifier; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + ModelIdentifier::useMorphMap(); +} +``` + +Applications that dispatch or process the same jobs must use the same morph map and enable this setting. Keep the aliases and setting in place while jobs using them remain queued. + #### `handle` Method Dependency Injection @@ -383,7 +402,7 @@ public function __construct( ) {} ``` -For convenience, if you wish to serialize all models without relationships, you may apply the `WithoutRelations` attribute to the entire class instead of applying the attribute to each model: +For convenience, if you wish to serialize all models without relationships, you may apply the `WithoutRelations` attribute to the entire class instead of applying the attribute to each model. The attribute may also be applied to a parent job class: ```php ### Unique Jobs From 03a89f7aff51d9ce8929184db8a97d36c753433d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:24:09 +0000 Subject: [PATCH 29/41] Release owned database lock rows after expiration Complete the release half of Laravel #59948. Ownership inspection already excluded expired rows, but release still used that inspection as a preflight, so an expired original owner could no longer remove its row. Rows remained until reuse or pruning. Use the current upstream key-and-owner DELETE and report its affected-row result. This removes the preliminary SELECT, preserves another owner's row, and retains the concurrency-error handling introduced by #58507. Resolve the connection through Hypervel's existing resolver; coroutine connection retention and pool ownership remain unchanged. Adapt the release unit cases to the single query and consolidate identical zero-row mocks. Preserve the original expiration inspection test, add real expired-owner cleanup and repeat-release coverage, and verify wrong-owner release cannot remove the active owner's row. Keep both upstream error cases with the current single-query expectations. Upstream: https://github.com/laravel/framework/pull/58507 https://github.com/laravel/framework/pull/59948 Source: laravel/framework 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Validation: both edited test files and the Cache ParaTest suite pass; full PHPStan source/type-fixture analysis and PHP-CS-Fixer pass. The expired-owner regression was reproduced against the previous implementation. --- src/cache/src/DatabaseLock.php | 24 +++++++----------- tests/Cache/CacheDatabaseLockTest.php | 25 +++---------------- .../Integration/Database/DatabaseLockTest.php | 21 +++++++++++----- 3 files changed, 28 insertions(+), 42 deletions(-) diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index 50b4d5f198..b7cb3da6c4 100644 --- a/src/cache/src/DatabaseLock.php +++ b/src/cache/src/DatabaseLock.php @@ -119,24 +119,18 @@ public function acquire(): bool */ public function release(): bool { - if ($this->isOwnedByCurrentProcess()) { - try { - $this->connection()->table($this->table) - ->where('key', $this->name) - ->where('owner', $this->owner) - ->delete(); - + try { + return $this->connection()->table($this->table) + ->where('key', $this->name) + ->where('owner', $this->owner) + ->delete() > 0; + } catch (Throwable $e) { + if ($this->causedByConcurrencyError($e)) { return true; - } catch (Throwable $e) { - if ($this->causedByConcurrencyError($e)) { - return true; - } - - throw $e; } - } - return false; + throw $e; + } } /** diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index b93ae4f62d..50932d4303 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -138,37 +138,20 @@ public function testLockCanBeReleased(): void [$lock, $table] = $this->getLock(); $owner = $lock->owner(); - // Check ownership - $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); - $table->shouldReceive('where')->once()->with('expiration', '>', m::type('int'))->andReturn($table); - $table->shouldReceive('first')->once()->andReturn((object) ['owner' => $owner]); - - // Delete $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); $table->shouldReceive('where')->once()->with('owner', $owner)->andReturn($table); - $table->shouldReceive('delete')->once(); + $table->shouldReceive('delete')->once()->andReturn(1); $this->assertTrue($lock->release()); } - public function testLockCannotBeReleasedIfNotOwned(): void + public function testReleaseReturnsFalseWhenNoOwnedRowMatches(): void { [$lock, $table] = $this->getLock(); $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); - $table->shouldReceive('where')->once()->with('expiration', '>', m::type('int'))->andReturn($table); - $table->shouldReceive('first')->once()->andReturn((object) ['owner' => 'different-owner']); - - $this->assertFalse($lock->release()); - } - - public function testLockCannotBeReleasedIfNotExists(): void - { - [$lock, $table] = $this->getLock(); - - $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); - $table->shouldReceive('where')->once()->with('expiration', '>', m::type('int'))->andReturn($table); - $table->shouldReceive('first')->once()->andReturn(null); + $table->shouldReceive('where')->once()->with('owner', $lock->owner())->andReturn($table); + $table->shouldReceive('delete')->once()->andReturn(0); $this->assertFalse($lock->release()); } diff --git a/tests/Integration/Database/DatabaseLockTest.php b/tests/Integration/Database/DatabaseLockTest.php index c124fbd6a3..a1a7bf28ab 100644 --- a/tests/Integration/Database/DatabaseLockTest.php +++ b/tests/Integration/Database/DatabaseLockTest.php @@ -104,6 +104,20 @@ public function testOtherOwnerDoesNotOwnLockAfterRestore(): void $secondLock = Cache::store('database')->restoreLock('foo', 'other_owner'); $this->assertTrue($secondLock->isOwnedBy($firstLock->owner())); $this->assertFalse($secondLock->isOwnedByCurrentProcess()); + $this->assertFalse($secondLock->release()); + $this->assertTrue($firstLock->isOwnedByCurrentProcess()); + } + + public function testExpiredLockCanBeReleasedByItsOwner(): void + { + $lock = Cache::store('database')->lock('foo', 10); + $this->assertTrue($lock->get()); + + DB::table('cache_locks')->update(['expiration' => CarbonImmutable::now()->subDay()->getTimestamp()]); + + $this->assertTrue($lock->release()); + $this->assertSame(0, DB::table('cache_locks')->count()); + $this->assertFalse($lock->release()); } public function testLockCanBeRefreshed(): void @@ -180,15 +194,10 @@ public function testReleaseIgnoresConcurrencyException(string $message, int $cod { $resolver = m::mock(ConnectionResolverInterface::class); $connection = m::mock(Connection::class); - $ownerBuilder = m::mock(Builder::class); $deleteBuilder = m::mock(Builder::class); $owner = 'owner-123'; - $ownerBuilder->shouldReceive('where')->with('key', 'foo')->once()->andReturnSelf(); - $ownerBuilder->shouldReceive('where')->with('expiration', '>', m::type('int'))->once()->andReturnSelf(); - $ownerBuilder->shouldReceive('first')->once()->andReturn((object) ['owner' => $owner]); - $deleteBuilder->shouldReceive('where')->with('key', 'foo')->once()->andReturnSelf(); $deleteBuilder->shouldReceive('where')->with('owner', $owner)->once()->andReturnSelf(); $deleteBuilder->shouldReceive('delete')->once()->andThrow( @@ -200,7 +209,7 @@ public function testReleaseIgnoresConcurrencyException(string $message, int $cod ) ); - $connection->shouldReceive('table')->with('cache_locks')->andReturn($ownerBuilder, $deleteBuilder); + $connection->shouldReceive('table')->with('cache_locks')->once()->andReturn($deleteBuilder); $resolver->shouldReceive('connection')->with(null)->andReturn($connection); $lock = new DatabaseLock($resolver, null, 'foo', 'cache_locks', 10, $owner); From f3defb73aa6f98e0f77cb62481fe5a5d3fad6597 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:49:25 +0000 Subject: [PATCH 30/41] Preserve enum defaults in request input types Carry the default value through InteractsWithData::enum with its own generic so a supplied enum default no longer leaves null in the inferred result. Preserve the existing enum normalization and runtime behavior. Port the complete current Request type fixture, including enum, route and JSON return assertions. Keep the accurate nullable route result when no resolver provides a route, and regenerate the Request facade from the formatted source annotations. Upstream: https://github.com/laravel/framework/pull/58529 Fixture history: https://github.com/laravel/framework/pull/44370 https://github.com/laravel/framework/pull/53625 https://github.com/laravel/framework/pull/55631 Source pin: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: full source and type-fixture PHPStan analysis, formatting, focused request/data tests through ParaTest, and the complete facade-docblock checks passed. --- src/support/src/Facades/Request.php | 2 +- src/support/src/Traits/InteractsWithData.php | 12 ++++++---- types/Http/Request.php | 25 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 types/Http/Request.php diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 32c8244743..68625df40c 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -30,7 +30,7 @@ * @method static \Hypervel\Http\Request dump(mixed $keys = []) * @method static \Hypervel\Http\Request duplicate(array|null $query = null, array|null $request = null, array|null $attributes = null, array|null $cookies = null, array|null $files = null, array|null $server = null) * @method static void enableHttpMethodParameterOverride() - * @method static null|\BackedEnum enum(string $key, string $enumClass, null|\BackedEnum $default = null) + * @method static \BackedEnum|null enum(string $key, string $enumClass, \BackedEnum|null $default = null) * @method static \BackedEnum[] enums(string $key, string $enumClass) * @method static array except(mixed $keys) * @method static bool exists(array|string $key) diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 2d5629fc30..4efc0241b4 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -4,6 +4,7 @@ namespace Hypervel\Support\Traits; +use BackedEnum; use Carbon\CarbonInterface; use Carbon\CarbonInterval; use Carbon\Unit; @@ -168,7 +169,7 @@ public function whenFilled(string $key, callable $callback, ?callable $default = /** * Apply the callback if the instance contains a valid enum value for the given key. * - * @template TEnum of \BackedEnum + * @template TEnum of BackedEnum * @template TReturn * @template TReturnDefault = never * @@ -335,11 +336,12 @@ public function interval(string $key, Unit|string|null $unit = null): ?CarbonInt /** * Retrieve data from the instance as an enum. * - * @template TEnum of \BackedEnum + * @template TEnum of BackedEnum + * @template TDefault of TEnum|null * * @param class-string $enumClass - * @param null|TEnum $default - * @return null|TEnum + * @param TDefault $default + * @return TDefault|TEnum */ public function enum(string $key, string $enumClass, mixed $default = null): mixed { @@ -353,7 +355,7 @@ public function enum(string $key, string $enumClass, mixed $default = null): mix /** * Retrieve data from the instance as an array of enums. * - * @template TEnum of \BackedEnum + * @template TEnum of BackedEnum * * @param class-string $enumClass * @return TEnum[] diff --git a/types/Http/Request.php b/types/Http/Request.php new file mode 100644 index 0000000000..777011ce20 --- /dev/null +++ b/types/Http/Request.php @@ -0,0 +1,25 @@ + 'test', +]); + +assertType('TestEnum|null', $request->enum('key', TestEnum::class)); +assertType('TestEnum|TestEnum::Foo', $request->enum('key', TestEnum::class, TestEnum::Foo)); + +assertType('Hypervel\Routing\Route|null', $request->route()); +assertType('object|string|null', $request->route('key')); + +assertType('Symfony\Component\HttpFoundation\InputBag', $request->json()); +assertType('mixed', $request->json('key')); From 23cc5ad94fee1dee34edadf9344fa060555c121c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:49:36 +0000 Subject: [PATCH 31/41] Document and test multiple MySQL index hints Document comma-separated index names for MySQL and MariaDB beside the existing index-hint APIs. Keep SQLite guidance separate because its indexed-by clause accepts one index name. Extend the existing query-builder test with a multiple-index assertion while retaining the single-index case. This protects the per-name validation already implemented by the grammar against returning to whole-string validation. Upstream: https://github.com/laravel/framework/pull/58505 Source pin: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. No runtime changes were needed. Validation: the complete DatabaseQueryBuilderTest, formatting, full source/type analysis and diff checks passed. --- src/docs/queries.md | 9 +++++++++ tests/Database/DatabaseQueryBuilderTest.php | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/docs/queries.md b/src/docs/queries.md index e661a7b964..2ef8b581aa 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -376,6 +376,15 @@ $users = DB::table('users') ->get(); ``` +When using MariaDB or MySQL, you may specify multiple indexes by separating their names with commas: + +```php +$users = DB::table('users') + ->useIndex('users_email_index, users_name_index') + ->where('email', 'taylor@example.com') + ->get(); +``` + SQLite supports the `forceIndex` method, which compiles to SQLite's `indexed by` clause: ```php diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index d2ed06e9ec..1f601307c7 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -7765,11 +7765,15 @@ public function testWhereColumnQuestionMarkOperatorOnPostgres(): void $this->assertSame('select * from "users" where "foo" ??& "_foo"', $builder->toSql()); } - public function testUseIndexMySql() + public function testUseIndexMySql(): void { $builder = $this->getMySqlBuilder(); $builder->select('foo')->from('users')->useIndex('test_index'); $this->assertSame('select `foo` from `users` use index (test_index)', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('foo')->from('users')->useIndex('test_index, second_index'); + $this->assertSame('select `foo` from `users` use index (test_index, second_index)', $builder->toSql()); } public function testForceIndexMySql() From 162ba368ae8b355b97b3ed9568a0f3c37ab77e6e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:14:51 +0000 Subject: [PATCH 32/41] Verify unique listener lock release with real lock state The unique-until-processing listener test supplied a mock whose acquisition always succeeded. It passed even when the listener ran before its unique lock was released, so it did not protect the behavior its name described. Use an ArrayStore lock held before processing and reacquire that key from the existing listener fixture. Check that the replacement lock survives cleanup, preserving the previous once-only release expectation through observable behavior. Share the expected key between setup and assertions, and remove the fixture's canned-success mock without adding another test. This completes validation of Laravel's unique queued-listener port: https://github.com/laravel/framework/pull/58402 Compared against framework 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The production implementation and its ownership protections are unchanged. Validation: QueuedEventsTest, formatting, and source/type analysis pass. Deliberately late and repeated release each fail the corrected test at their respective assertions. --- tests/Events/QueuedEventsTest.php | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php index 9efcb75df2..2dae81df89 100644 --- a/tests/Events/QueuedEventsTest.php +++ b/tests/Events/QueuedEventsTest.php @@ -716,30 +716,25 @@ public function testUniqueLockIsReleasedOnProcessingWithListenerClassName() $handler->call($job, ['command' => serialize($listener)]); } - public function testUniqueUntilProcessingLockIsReleasedBeforeHandling() + public function testUniqueUntilProcessingLockIsReleasedBeforeHandling(): void { $container = new Container; - $cache = m::mock(Cache::class); - $lock = m::mock(Lock::class); + $cache = new Repository(new ArrayStore); + $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id'; $container->instance(Cache::class, $cache); $container->instance(BusDispatcher::class, new BusDispatcher($container)); TestDispatcherShouldBeUniqueUntilProcessing::$lockReleasedBeforeHandling = null; TestDispatcherShouldBeUniqueUntilProcessing::$cache = $cache; - TestDispatcherShouldBeUniqueUntilProcessing::$expectedLockKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id'; + TestDispatcherShouldBeUniqueUntilProcessing::$expectedLockKey = $expectedKey; $listener = new CallQueuedListener(TestDispatcherShouldBeUniqueUntilProcessing::class, 'handle', ['foo', 'bar']); $listener->shouldBeUnique = true; $listener->shouldBeUniqueUntilProcessing = true; $listener->uniqueId = 'until-processing-id'; - $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id'; - - $cache->shouldReceive('lock') - ->with($expectedKey) - ->andReturn($lock); - $lock->shouldReceive('forceRelease')->once(); + $this->assertTrue($cache->lock($expectedKey, 10)->get()); $job = m::mock(Job::class); $job->shouldReceive('hasFailed')->andReturn(false); @@ -753,6 +748,9 @@ public function testUniqueUntilProcessingLockIsReleasedBeforeHandling() $handler->call($job, ['command' => serialize($listener)]); $this->assertTrue(TestDispatcherShouldBeUniqueUntilProcessing::$lockReleasedBeforeHandling); + + // A replacement dispatch's lock must survive the first listener's cleanup. + $this->assertFalse($cache->lock($expectedKey)->get()); } public function testQueuePropagatesDebounceOptions(): void @@ -1274,14 +1272,11 @@ class TestDispatcherShouldBeUniqueUntilProcessing implements ShouldQueue, Should public static string $expectedLockKey = ''; - public function handle() + /** + * Attempt to acquire the unique lock during handling. + */ + public function handle(): void { - $lock = m::mock(Lock::class); - $lock->shouldReceive('get')->andReturn(true); - static::$cache->shouldReceive('lock') - ->with(static::$expectedLockKey, 10) - ->andReturn($lock); - static::$lockReleasedBeforeHandling = static::$cache->lock(static::$expectedLockKey, 10)->get(); } } From 53d830596f6b81bbf221454a908757f7ba7f6ad0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:15:09 +0000 Subject: [PATCH 33/41] Correct unique listener scope and queue example imports The first unique-listener example has no uniqueId, so its lock covers all instances of that listener rather than individual licenses. Describe that scope explicitly; the following example already introduces per-license uniqueness through uniqueId. Add the missing ShouldQueue and ShouldBeUnique imports to the custom-cache examples, and ShouldQueue to the six job-attribute examples that declare the interface without importing it. Each affected snippet failed interface resolution when evaluated with the real autoloader. These correct omissions inherited from Laravel's current documentation: https://github.com/laravel/framework/pull/58402 https://github.com/laravel/docs/pull/10996 Compared against docs 89e91b5cff48e1b9b1a7921300653eb1ceb7bfcb. Validation: all eight corrected snippets compile in isolated PHP processes. Formatting, source/type analysis and diff checks pass. No runtime behavior or application API changes. --- src/docs/events.md | 4 +++- src/docs/queues.md | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/docs/events.md b/src/docs/events.md index f70646cc79..8bb0a71068 100644 --- a/src/docs/events.md +++ b/src/docs/events.md @@ -600,7 +600,7 @@ class AcquireProductKey implements ShouldQueue, ShouldBeUnique } ``` -In the example above, the `AcquireProductKey` listener is unique. So, the listener will not be queued if another instance of the listener is already on the queue and has not finished processing. This ensures that only one product key is acquired for each license, even if the license is saved multiple times in quick succession. +In the example above, the `AcquireProductKey` listener is unique. So, the listener will not be queued if another instance of the listener is already on the queue and has not finished processing. This applies to all instances of the listener, regardless of which license triggered the event. In certain cases, you may want to define a specific "key" that makes the listener unique or you may want to specify a timeout beyond which the listener no longer stays unique. To accomplish this, you may define `uniqueId` and `uniqueFor` properties or methods on your listener class. The methods receive the event instance, allowing you to use event data to construct the return value: @@ -674,6 +674,8 @@ namespace App\Listeners; use App\Events\LicenseSaved; use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Support\Facades\Cache; class AcquireProductKey implements ShouldQueue, ShouldBeUnique diff --git a/src/docs/queues.md b/src/docs/queues.md index 66580999ea..e40f3922be 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -520,6 +520,8 @@ Behind the scenes, when a `ShouldBeUnique` job is dispatched, Hypervel attempts ```php use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Support\Facades\Cache; class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique @@ -1776,6 +1778,7 @@ You may take a more granular approach by defining the maximum number of times a namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Tries; #[Tries(5)] @@ -1915,6 +1918,7 @@ You may also define the maximum number of seconds a job should be allowed to run namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Timeout; #[Timeout(120)] @@ -1939,6 +1943,7 @@ If you would like to indicate that a job should be marked as [failed](#dealing-w namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\FailOnTimeout; #[FailOnTimeout] @@ -3095,6 +3100,7 @@ If you would like to configure how many seconds Hypervel should wait before retr namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Backoff; #[Backoff(3)] @@ -3123,6 +3129,7 @@ You may easily configure "exponential" backoffs by defining an array of backoff namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Backoff; #[Backoff([1, 5, 10])] @@ -3274,6 +3281,7 @@ For convenience, you may choose to automatically delete jobs with missing models namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\DeleteWhenMissingModels; #[DeleteWhenMissingModels] From 99233113fe9e285d8ec4cbac2625d11edc8a242c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:37:37 +0000 Subject: [PATCH 34/41] Complete associative eager-load key coverage and documentation Port the missing belongs-to-many eager-loading regression and its keyed posts fixture from Laravel. The test verifies that related model IDs remain the collection keys after eager loading; the existing relation dictionaries already implement that behavior. Document afterQuery and a slug-keyed related collection at the eager-loading surface. Match the explicit upstream array-key spelling on the through-relation dictionary without changing its equivalent type or runtime behavior. Add native test and fixture return types while preserving all existing callback and cursor cases. Upstream: https://github.com/laravel/framework/pull/58506 Source: laravel/framework at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: the complete AfterQueryTest passes on isolated in-memory SQLite. The new assertion fails when a scratch relation dictionary drops associative keys. Formatting and full source/type PHPStan checks pass. --- .../Relations/HasOneOrManyThrough.php | 2 +- src/docs/eloquent-relationships.md | 16 ++++++ tests/Integration/Database/AfterQueryTest.php | 56 ++++++++++++++----- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 10c383b3ba..9c9f6de1cd 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -162,7 +162,7 @@ public function addEagerConstraints(array $models): void * Build model dictionary keyed by the relation's foreign key. * * @param \Hypervel\Database\Eloquent\Collection $results - * @return array> + * @return array> */ protected function buildDictionary(EloquentCollection $results): array { diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 24833e26df..dd3a8c36e9 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -2175,6 +2175,22 @@ $users = User::with(['posts' => function ($query) { }])->get(); ``` + +#### Preserving Related Collection Keys + +The `afterQuery` method registers a callback that receives the query results after they have been retrieved. You may return a collection from the callback to replace those results. For example, you may key the posts by their unique slugs: + +```php +use App\Models\User; +use Hypervel\Database\Eloquent\Collection; + +$users = User::with(['posts' => function ($query) { + $query->afterQuery(fn (Collection $posts) => $posts->keyBy('slug')); +}])->get(); +``` + +The associative keys are preserved in each user's related `posts` collection when the relationship is eager loaded. + #### Constraining Eager Loading of `morphTo` Relationships diff --git a/tests/Integration/Database/AfterQueryTest.php b/tests/Integration/Database/AfterQueryTest.php index 37e6f367ce..8c8bd7a69c 100644 --- a/tests/Integration/Database/AfterQueryTest.php +++ b/tests/Integration/Database/AfterQueryTest.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Integration\Database; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Schema; @@ -36,7 +39,7 @@ protected function afterRefreshingDatabase(): void }); } - public function testAfterQueryOnEloquentBuilder() + public function testAfterQueryOnEloquentBuilder(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -57,7 +60,7 @@ public function testAfterQueryOnEloquentBuilder() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); } - public function testAfterQueryOnBaseBuilder() + public function testAfterQueryOnBaseBuilder(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -79,7 +82,7 @@ public function testAfterQueryOnBaseBuilder() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); } - public function testAfterQueryOnEloquentCursor() + public function testAfterQueryOnEloquentCursor(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -100,7 +103,7 @@ public function testAfterQueryOnEloquentCursor() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); } - public function testAfterQueryOnBaseBuilderCursor() + public function testAfterQueryOnBaseBuilderCursor(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -148,7 +151,7 @@ public function testAfterQueryOnBaseBuilderCursorDistinguishesNullFromAnEmptyRes ); } - public function testAfterQueryOnEloquentPluck() + public function testAfterQueryOnEloquentPluck(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -169,7 +172,7 @@ public function testAfterQueryOnEloquentPluck() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); } - public function testAfterQueryOnBaseBuilderPluck() + public function testAfterQueryOnBaseBuilderPluck(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -191,7 +194,7 @@ public function testAfterQueryOnBaseBuilderPluck() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); } - public function testAfterQueryHookOnBelongsToManyRelationship() + public function testAfterQueryHookOnBelongsToManyRelationship(): void { $user = AfterQueryUser::create(); $firstPost = AfterQueryPost::create(); @@ -216,7 +219,21 @@ public function testAfterQueryHookOnBelongsToManyRelationship() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $posts->pluck('id')->toArray()); } - public function testAfterQueryHookOnHasManyThroughRelationship() + public function testAfterQueryKeyByOnEagerBelongsToManyRelationship(): void + { + $user = AfterQueryUser::create(); + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $user->posts()->attach($firstPost); + $user->posts()->attach($secondPost); + + $posts = AfterQueryUser::with('posts')->first()->posts; + + $this->assertEqualsCanonicalizing($posts->pluck('id')->toArray(), $posts->keys()->toArray()); + } + + public function testAfterQueryHookOnHasManyThroughRelationship(): void { $user = AfterQueryUser::create(); $team = AfterQueryTeam::create(['owner_id' => $user->id]); @@ -240,7 +257,7 @@ public function testAfterQueryHookOnHasManyThroughRelationship() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $teamMates->pluck('id')->toArray()); } - public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult() + public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult(): void { $firstUser = AfterQueryUser::create(); $secondUser = AfterQueryUser::create(); @@ -306,7 +323,7 @@ public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult() $this->assertEquals(collect(['foo', 'bar']), $teamMates); } - public function testAfterQueryOnBaseBuilderCanAlterReturnedResult() + public function testAfterQueryOnBaseBuilderCanAlterReturnedResult(): void { $firstUser = AfterQueryUser::create(); $secondUser = AfterQueryUser::create(); @@ -387,14 +404,22 @@ class AfterQueryUser extends Model public bool $timestamps = false; - public function teamMates() + /** + * Get the user's team members. + */ + public function teamMates(): HasManyThrough { return $this->hasManyThrough(self::class, AfterQueryTeam::class, 'owner_id', 'team_id'); } - public function posts() + /** + * Get the user's posts keyed by their IDs. + */ + public function posts(): BelongsToMany { - return $this->belongsToMany(AfterQueryPost::class, 'users_posts', 'user_id', 'post_id')->withTimestamps(); + return $this->belongsToMany(AfterQueryPost::class, 'users_posts', 'user_id', 'post_id') + ->afterQuery(fn (Collection $posts): Collection => $posts->keyBy(fn (AfterQueryPost $post): int => $post->id)) + ->withTimestamps(); } } @@ -406,7 +431,10 @@ class AfterQueryTeam extends Model public bool $timestamps = false; - public function members() + /** + * Get the team's members. + */ + public function members(): HasMany { return $this->hasMany(AfterQueryUser::class, 'team_id'); } From 92a321c70eaa07a333c81efe1edb5d0e0636de3c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:00:23 +0000 Subject: [PATCH 35/41] Complete typed cache getter coverage and documentation Finish the test and documentation portions of Laravel #58451 and #61056 against framework revision 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The repository methods and enum normalization were already implemented. Correct all five missing-key default tests to return null from their store mocks. Previously each mock returned the expected default as a cache hit, so the assertions did not exercise fallback behavior. Preserve the scalar default case and add one closure-default assertion to the same string test. Merge upstream scalar literals into the existing tests, add the missing non-numeric string rejection cases and complete enum mismatch provider, and retain the additional Hypervel cases. Document typed retrieval, defaults, errors and numeric-string handling at the existing cache retrieval surface. Validation: CacheRepositoryTest, composer lint:fix, full source and type-fixture PHPStan, and diff checks pass. No runtime behavior, public API, coroutine state or hot-path work changes. Upstream: https://github.com/laravel/framework/pull/58451 Upstream: https://github.com/laravel/framework/pull/61056 --- src/docs/cache.md | 15 ++++ tests/Cache/CacheRepositoryTest.php | 122 ++++++++++++++++++++-------- 2 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/docs/cache.md b/src/docs/cache.md index 0ecf65d715..bf35992467 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -367,6 +367,21 @@ Cache::put(CacheKey::Visits, 10, 600); $visits = Cache::get(CacheKey::Visits); ``` + +#### Retrieving Typed Values + +You may use the `string`, `integer`, `float`, `boolean`, and `array` methods to retrieve a cache item as a specific type: + +```php +$name = Cache::string('user:display_name', 'Guest'); +$attempts = Cache::integer('login:attempts', 0); +$rating = Cache::float('product:rating', 0.0); +$active = Cache::boolean('user:active', false); +$settings = Cache::array('user:settings', []); +``` + +Like `get`, these methods accept a default value or a closure that returns the default when the item is missing. An `InvalidArgumentException` is thrown if the retrieved value has an incompatible type, or if the item is missing and no suitable default is provided. The `integer` and `float` methods also accept numeric strings that represent valid integer or float values, respectively. + #### Determining Item Existence diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index c11f8e7110..7deef807e2 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -1356,7 +1356,7 @@ public function testTaggedPutManyHandlesIntegerArrayKeys() $this->assertSame('string-value', $repo->get('a')); } - public function testStringTypedGetter() + public function testStringTypedGetter(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); @@ -1364,34 +1364,35 @@ public function testStringTypedGetter() $this->assertSame('bar', $repo->string('foo')); } - public function testStringTypedGetterThrowsExceptionForNonString() + public function testStringTypedGetterThrowsExceptionForNonString(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be a string, integer given.'); $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(1); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(123); $repo->string('foo'); } - public function testStringTypedGetterReturnsDefaultWhenKeyNotFound() + public function testStringTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('default'); + $repo->getStore()->shouldReceive('get')->twice()->with('foo')->andReturn(null); $this->assertSame('default', $repo->string('foo', 'default')); + $this->assertSame('resolved', $repo->string('foo', fn (): string => 'resolved')); } - public function testIntegerTypedGetter() + public function testIntegerTypedGetter(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(42); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(123); - $this->assertSame(42, $repo->integer('foo')); + $this->assertSame(123, $repo->integer('foo')); } - public function testIntegerTypedGetterParsesNumericString() + public function testIntegerTypedGetterParsesNumericString(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('123'); @@ -1399,7 +1400,7 @@ public function testIntegerTypedGetterParsesNumericString() $this->assertSame(123, $repo->integer('foo')); } - public function testIntegerTypedGetterThrowsExceptionForNonInteger() + public function testIntegerTypedGetterThrowsExceptionForNonInteger(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be an integer, array given.'); @@ -1410,15 +1411,24 @@ public function testIntegerTypedGetterThrowsExceptionForNonInteger() $repo->integer('foo'); } - public function testIntegerTypedGetterReturnsDefaultWhenKeyNotFound() + public function testItThrowsExceptionWhenGettingNonIntegerAsInteger(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Cache value for key [foo] must be an integer, string given.')); + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); + $repo->integer('foo'); + } + + public function testIntegerTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(100); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); - $this->assertSame(100, $repo->integer('foo', 100)); + $this->assertSame(456, $repo->integer('foo', 456)); } - public function testItThrowsExceptionWhenGettingFloatStringAsInteger() + public function testItThrowsExceptionWhenGettingFloatStringAsInteger(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be an integer, string given.'); @@ -1428,23 +1438,23 @@ public function testItThrowsExceptionWhenGettingFloatStringAsInteger() $repo->integer('foo'); } - public function testFloatTypedGetter() + public function testFloatTypedGetter(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(3.14); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(1.5); - $this->assertSame(3.14, $repo->float('foo')); + $this->assertSame(1.5, $repo->float('foo')); } - public function testFloatTypedGetterParsesNumericString() + public function testFloatTypedGetterParsesNumericString(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('3.14'); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('1.5'); - $this->assertSame(3.14, $repo->float('foo')); + $this->assertSame(1.5, $repo->float('foo')); } - public function testFloatTypedGetterThrowsExceptionForNonFloat() + public function testFloatTypedGetterThrowsExceptionForNonFloat(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be a float, array given.'); @@ -1455,15 +1465,24 @@ public function testFloatTypedGetterThrowsExceptionForNonFloat() $repo->float('foo'); } - public function testFloatTypedGetterReturnsDefaultWhenKeyNotFound() + public function testItThrowsExceptionWhenGettingNonFloatAsFloat(): void { + $this->expectExceptionObject(new InvalidArgumentException('Cache value for key [foo] must be a float, string given.')); + $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(2.5); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); + $repo->float('foo'); + } + + public function testFloatTypedGetterReturnsDefaultWhenKeyNotFound(): void + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); $this->assertSame(2.5, $repo->float('foo', 2.5)); } - public function testBooleanTypedGetter() + public function testBooleanTypedGetter(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(true); @@ -1471,7 +1490,7 @@ public function testBooleanTypedGetter() $this->assertTrue($repo->boolean('foo')); } - public function testBooleanTypedGetterReturnsFalse() + public function testBooleanTypedGetterReturnsFalse(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(false); @@ -1479,7 +1498,7 @@ public function testBooleanTypedGetterReturnsFalse() $this->assertFalse($repo->boolean('foo')); } - public function testBooleanTypedGetterThrowsExceptionForNonBoolean() + public function testBooleanTypedGetterThrowsExceptionForNonBoolean(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be a boolean, string given.'); @@ -1490,15 +1509,24 @@ public function testBooleanTypedGetterThrowsExceptionForNonBoolean() $repo->boolean('foo'); } - public function testBooleanTypedGetterReturnsDefaultWhenKeyNotFound() + public function testItThrowsExceptionWhenGettingNonBooleanAsBoolean(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Cache value for key [foo] must be a boolean, string given.')); + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); + $repo->boolean('foo'); + } + + public function testBooleanTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(true); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); - $this->assertTrue($repo->boolean('foo', true)); + $this->assertFalse($repo->boolean('foo', false)); } - public function testArrayTypedGetter() + public function testArrayTypedGetter(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(['bar', 'baz']); @@ -1506,7 +1534,7 @@ public function testArrayTypedGetter() $this->assertSame(['bar', 'baz'], $repo->array('foo')); } - public function testArrayTypedGetterReturnsAssociativeArray() + public function testArrayTypedGetterReturnsAssociativeArray(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(['key' => 'value']); @@ -1514,7 +1542,7 @@ public function testArrayTypedGetterReturnsAssociativeArray() $this->assertSame(['key' => 'value'], $repo->array('foo')); } - public function testArrayTypedGetterThrowsExceptionForNonArray() + public function testArrayTypedGetterThrowsExceptionForNonArray(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be an array, string given.'); @@ -1525,14 +1553,40 @@ public function testArrayTypedGetterThrowsExceptionForNonArray() $repo->array('foo'); } - public function testArrayTypedGetterReturnsDefaultWhenKeyNotFound() + public function testArrayTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(['default']); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); $this->assertSame(['default'], $repo->array('foo', ['default'])); } + #[DataProvider('typedGetterTypeMismatchProvider')] + public function testTypedGettersReportTypeMismatchesForEnumKeys(string $method, int|string $value, string $message): void + { + $this->expectExceptionObject(new InvalidArgumentException($message)); + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn($value); + $repo->{$method}(TestCacheKey::Foo); + } + + /** + * Provide typed getter values and their expected mismatch messages. + * + * @return array + */ + public static function typedGetterTypeMismatchProvider(): array + { + return [ + ['string', 123, 'Cache value for key [foo] must be a string, integer given.'], + ['integer', 'bar', 'Cache value for key [foo] must be an integer, string given.'], + ['float', 'bar', 'Cache value for key [foo] must be a float, string given.'], + ['boolean', 'bar', 'Cache value for key [foo] must be a boolean, string given.'], + ['array', 'bar', 'Cache value for key [foo] must be an array, string given.'], + ]; + } + public function testRememberFiresEventsWithRedisStore() { $store = m::mock(RedisStore::class); From a6602548ee622b81d6f1e40a4559168e94518c82 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:00:34 +0000 Subject: [PATCH 36/41] Align the column starting-value description with upstream Port Laravel #58573 from framework revision 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Remove the spaces around the database separator in the from() annotation, matching the adjacent startingValue() description and the current upstream text. This changes documentation formatting only. Formatting, full source and type-fixture PHPStan, and diff checks pass; no runtime test is needed for the spacing change. Upstream: https://github.com/laravel/framework/pull/58573 --- src/database/src/Schema/ColumnDefinition.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/src/Schema/ColumnDefinition.php b/src/database/src/Schema/ColumnDefinition.php index a62b767957..3a7d66eaa8 100644 --- a/src/database/src/Schema/ColumnDefinition.php +++ b/src/database/src/Schema/ColumnDefinition.php @@ -17,7 +17,7 @@ * @method $this comment(string $comment) Add a comment to the column (MySQL/PostgreSQL) * @method $this default(mixed $value) Specify a "default" value for the column * @method $this first() Place the column "first" in the table (MySQL) - * @method $this from(int $startingValue) Set the starting value of an auto-incrementing field (MySQL / PostgreSQL) + * @method $this from(int $startingValue) Set the starting value of an auto-incrementing field (MySQL/PostgreSQL) * @method $this fulltext(bool|string $indexName = null) Add a fulltext index * @method $this generatedAs(string|\Hypervel\Contracts\Database\Query\Expression $expression = null) Create a SQL compliant identity column (PostgreSQL) * @method $this instant() Specify that algorithm=instant should be used for the column operation (MySQL) From 5e80d6b3952f73ed5d983ab4cd971924bf72b975 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:22:48 +0000 Subject: [PATCH 37/41] Format summarized zero using the requested locale Route exact zero through Number::format at every precision so locales with non-Latin digits produce the same zero representation as rounded values. This also lets the existing sign comparison suppress a minus sign when a small negative number rounds to localized zero. Keep the protected return refinement accurate for locale-dependent output. Extend the existing human-readable and abbreviated formatting tests with Arabic exact zero, rounded zero and maximum-precision cases. This completes the formatting corrections around Laravel's number history: https://github.com/laravel/framework/pull/58358 https://github.com/laravel/framework/pull/61457 Upstream reference: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: Number tests, affected parallel tests, formatting and full source/type-fixture static analysis pass. --- src/support/src/Number.php | 4 ++-- tests/Support/SupportNumberTest.php | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/support/src/Number.php b/src/support/src/Number.php index 0896c38400..fd075e4249 100644 --- a/src/support/src/Number.php +++ b/src/support/src/Number.php @@ -223,7 +223,7 @@ public static function forHumans(float|int $number, int $precision = 0, ?int $ma /** * Convert the number to its human-readable equivalent. * - * @phpstan-return ($number is INF ? '∞' : ($number is NAN ? 'NaN' : ($number is 0 ? ($precision is non-positive-int ? '0' : non-empty-string|false) : non-empty-string|false))) + * @phpstan-return non-empty-string|false */ protected static function summarize(float|int $number, int $precision = 0, ?int $maxPrecision = null, array $units = []): false|string { @@ -243,7 +243,7 @@ protected static function summarize(float|int $number, int $precision = 0, ?int switch (true) { case (float) $number === 0.0: - return $precision > 0 ? static::format(0, $precision, $maxPrecision) : '0'; + return static::format(0, $precision, $maxPrecision); case $number < 0: $summary = static::summarize(abs($number), $precision, $maxPrecision, $units); diff --git a/tests/Support/SupportNumberTest.php b/tests/Support/SupportNumberTest.php index 6e45cedb29..2c10623c93 100644 --- a/tests/Support/SupportNumberTest.php +++ b/tests/Support/SupportNumberTest.php @@ -283,6 +283,12 @@ public function testToHuman(): void $this->assertSame('0.005', Number::forHumans(0.005, precision: 3)); $this->assertSame('-0.005', Number::forHumans(-0.005, precision: 3)); + Number::withLocale('ar', function () { + $this->assertSame('٠', Number::forHumans(0)); + $this->assertSame('٠', Number::forHumans(-0.004)); + $this->assertSame('٠', Number::forHumans(-0.004, maxPrecision: 2)); + }); + $this->assertSame('999 thousand', Number::forHumans(999499)); $this->assertSame('1 million', Number::forHumans(999500)); $this->assertSame('1 million', Number::forHumans(999999)); @@ -358,6 +364,12 @@ public function testSummarize(): void $this->assertSame('0', Number::abbreviate(-0.005)); $this->assertSame('0.005', Number::abbreviate(0.005, precision: 3)); + Number::withLocale('ar', function () { + $this->assertSame('٠', Number::abbreviate(0)); + $this->assertSame('٠', Number::abbreviate(-0.004)); + $this->assertSame('٠', Number::abbreviate(-0.004, maxPrecision: 2)); + }); + $this->assertSame('999K', Number::abbreviate(999499)); $this->assertSame('1M', Number::abbreviate(999500)); $this->assertSame('1M', Number::abbreviate(999999)); From f83605d3b40f1a45c1ca8566a43d82045446f7b6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:23:05 +0000 Subject: [PATCH 38/41] Compare vector casts at database storage precision Implement the existing cast comparison contract so a hydrated MariaDB vector and an equivalent assigned SQL expression are not always dirty. Decode each representation and compare native float32 bytes, matching MariaDB and PostgreSQL vector storage. Recomputed embeddings that round to the stored values no longer cause unnecessary updates, while the next representable float32 value still counts as a change. Handle a null original before packing. Cover both database representations, reassignment, recomputation, changed values, changed lengths and both null transitions through the model's dirty-tracking API. The reverse null transition remains handled by Eloquent's existing early exit. Correct the casting documentation to distinguish primitive null handling from custom cast classes, and explicitly describe nullable vector values. No mutable caster state or new comparison mechanism is introduced. Follow-up to the vector port: https://github.com/laravel/framework/pull/58337 https://github.com/laravel/framework/pull/61250 https://github.com/laravel/framework/pull/61337 Upstream reference: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: vector model tests, affected parallel tests, formatting and full source/type-fixture static analysis pass. Existing MariaDB integration coverage still exercises vector insertion, updates and distance queries. --- src/database/src/Eloquent/Casts/AsVector.php | 21 +++++++++- src/docs/eloquent-mutators.md | 4 +- .../DatabaseEloquentAsVectorCastTest.php | 42 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/database/src/Eloquent/Casts/AsVector.php b/src/database/src/Eloquent/Casts/AsVector.php index 6f9f5ad970..6e4a53b12a 100644 --- a/src/database/src/Eloquent/Casts/AsVector.php +++ b/src/database/src/Eloquent/Casts/AsVector.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Contracts\Database\Eloquent\ComparesCastableAttributes; use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Database\Eloquent\Model; @@ -24,7 +25,7 @@ class AsVector implements Castable */ public static function castUsing(array $arguments): CastsAttributes { - return new class implements CastsAttributes { + return new class implements CastsAttributes, ComparesCastableAttributes { // Eloquent otherwise caches an assigned Arrayable and returns it instead of a float array. public bool $withoutObjectCaching = true; @@ -92,6 +93,24 @@ public function set(Model $model, string $key, mixed $value, array $attributes): : $vector, ]; } + + /** + * Determine if the given values are equal. + * + * @throws JsonException + */ + public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue): bool + { + $first = $this->get($model, $key, $firstValue, []); + $second = $this->get($model, $key, $secondValue, []); + + if ($first === null || $second === null) { + return $first === $second; + } + + // Both supported engines store 32-bit floats, so compare the values as they are stored. + return pack('g*', ...$first) === pack('g*', ...$second); + } }; } } diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index d02fc4a485..74a8a6326d 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -302,7 +302,7 @@ $user->mergeCasts([ ``` > [!WARNING] -> Attributes that are `null` will not be cast. In addition, you should never define a cast (or an attribute) that has the same name as a relationship or assign a cast to the model's primary key. +> Attributes that are `null` remain `null` when using casts such as `integer`, `boolean`, or `array`. Custom cast classes handle `null` values themselves. In addition, you should never define a cast (or an attribute) that has the same name as a relationship or assign a cast to the model's primary key. #### Stringable Casting @@ -603,7 +603,7 @@ protected function casts(): array } ``` -When setting the attribute, the cast accepts a PHP array or an `Arrayable` instance, such as a Hypervel collection. When retrieving the attribute, the cast returns an array of floats. +When setting the attribute, the cast accepts a PHP array, an `Arrayable` instance such as a Hypervel collection, or `null`. When retrieving the attribute, the cast returns an array of floats, or `null` if the stored value is `null`. ### Binary Casting diff --git a/tests/Database/DatabaseEloquentAsVectorCastTest.php b/tests/Database/DatabaseEloquentAsVectorCastTest.php index 1cbd4b537c..05f6b65a01 100644 --- a/tests/Database/DatabaseEloquentAsVectorCastTest.php +++ b/tests/Database/DatabaseEloquentAsVectorCastTest.php @@ -16,6 +16,7 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentAsVectorCastTest extends TestCase { @@ -144,6 +145,47 @@ public function testVectorCanBeReadBackBeforeSavingOnPostgres(): void $this->assertSame([0.5, -1.25, 3.0], $model->embedding); } + #[DataProvider('storedVectorProvider')] + public function testDirtyTrackingUsesStoredVectorPrecision(string $grammar, string $stored): void + { + $this->useGrammar($grammar); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => $stored], true); + + $model->embedding = $model->embedding; + $this->assertFalse($model->isDirty('embedding')); + + // Recomputing the same embedding must compare at the database's float32 precision. + $model->embedding = [0.1, 0.2, 0.30000001]; + $this->assertFalse($model->isDirty('embedding')); + + // The next representable float32 value must still count as a change. + $model->embedding = [0.1000000089407, 0.2, 0.30000001]; + $this->assertTrue($model->isDirty('embedding')); + + $model->embedding = [0.1, 0.2]; + $this->assertTrue($model->isDirty('embedding')); + + $model->embedding = null; + $this->assertTrue($model->isDirty('embedding')); + + $model->syncOriginal(); + $model->embedding = [0.1, 0.2, 0.30000001]; + $this->assertTrue($model->isDirty('embedding')); + } + + /** + * Provide the database representations of the same vector. + */ + public static function storedVectorProvider(): array + { + return [ + 'MariaDB' => [MariaDbGrammar::class, pack('g*', 0.1, 0.2, 0.30000001)], + 'PostgreSQL' => [PostgresGrammar::class, '[0.1,0.2,0.3]'], + ]; + } + /** * Use the given query grammar for model connections. * From 30073bc1c3a23f0423537906f1a86af271d8279f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:23:24 +0000 Subject: [PATCH 39/41] Complete enum notification channel resolution coverage Port both upstream string-backed enum cases for named channels and custom channel classes. Hypervel already normalizes enum identifiers in the shared manager, so no redundant driver override is needed. Retain the existing integer-backed enum coverage and use the local typed container fixture. Document enum identifiers specifically when resolving a channel instance with Notification::channel. Point the repeated-send regression to the separate real-provider test that proves channel-owned failure events are not dispatched twice, preserving each test's distinct purpose. Laravel PR: https://github.com/laravel/framework/pull/59783 Original change: 95891fa4a7dc34648d7e78695173148ba9a41299. Current upstream reference: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: channel-manager and failure-event tests pass, including the affected parallel run. Formatting and full static analysis pass. --- src/docs/notifications.md | 2 ++ .../NotificationChannelManagerTest.php | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/docs/notifications.md b/src/docs/notifications.md index 3b5e1e0670..6c89797200 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -1997,3 +1997,5 @@ public function via(object $notifiable): array return ['voice']; } ``` + +The `Notification::channel` method resolves a channel instance by name. It also accepts an enum whose value is a channel name or a custom channel class. diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index cc281551a2..bf8bcb6215 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -107,6 +107,25 @@ public function testNotificationCanBeDispatchedToDriver(): void $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); } + public function testChannelCanBeResolvedUsingBackedEnum(): void + { + $container = $this->getContainer(); + + $manager = new ChannelManager($container); + $manager->extend('test', fn () => new NotificationChannelManagerTestCustomChannel); + + $this->assertInstanceOf(NotificationChannelManagerTestCustomChannel::class, $manager->channel(NotificationChannelManagerTestChannelEnum::Test)); + } + + public function testDriverCanBeResolvedUsingBackedEnum(): void + { + $container = $this->getContainer(); + + $manager = new ChannelManager($container); + + $this->assertInstanceOf(NotificationChannelManagerTestCustomChannel::class, $manager->driver(NotificationChannelManagerTestChannelEnum::Custom)); + } + public function testNotificationNotSentOnHalt(): void { $container = $this->getContainer(); @@ -194,6 +213,7 @@ public function send(mixed $notifiable, Notification $notification): void }); // The provider owns the listener; sending must not register additional listeners. + // NotificationFailedEventTest covers channel-owned failure deduplication through the real provider. $events->shouldNotReceive('listen'); $events->shouldReceive('until')->times(3)->with(m::type(NotificationSending::class))->andReturn(true); $events->shouldReceive('dispatch')->once()->with(m::type(NotificationFailed::class)); @@ -703,3 +723,13 @@ public function afterSending($notifiable, $channel, $response) static::$afterSendingResponse = $response; } } + +enum NotificationChannelManagerTestChannelEnum: string +{ + case Test = 'test'; + case Custom = NotificationChannelManagerTestCustomChannel::class; +} + +class NotificationChannelManagerTestCustomChannel +{ +} From 1d40ae00f344e70f770082d9fbbc969a3dae5d63 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:23:24 +0000 Subject: [PATCH 40/41] Explain the queue identifier storage regression Clarify that a native PostgreSQL UUID column would reject the supplied identifier accepted by the failed-job provider. The comment now explains why the fixture protects the shipped migration's string column choice, without suggesting that the generated table still uses a native UUID. The test continues to generate the real queue migrations and verify exact payload bytes and supplied identifiers through the queue and failed-job provider. No schema or runtime behavior changes in this commit. Related migration port: https://github.com/laravel/framework/pull/60073 Validation: the generated-table regression passes using isolated in-memory SQLite; formatting and whitespace checks pass. --- tests/Integration/Database/Queue/QueuePayloadStorageTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Database/Queue/QueuePayloadStorageTest.php b/tests/Integration/Database/Queue/QueuePayloadStorageTest.php index 0385d6b70d..4ae29a1b09 100644 --- a/tests/Integration/Database/Queue/QueuePayloadStorageTest.php +++ b/tests/Integration/Database/Queue/QueuePayloadStorageTest.php @@ -29,7 +29,7 @@ public function testGeneratedTablesPreserveRawPayloadsAndSuppliedIdentifiers(): foreach ([ [null, '{invalid'], - // PostgreSQL enforces native UUIDs; MySQL accepts this ID even in char(36). + // A native UUID column would reject this supported identifier on PostgreSQL. ['uuid-1', '{ "uuid": "uuid-1", "job": "ExampleJob", "data": {"b":2,"a":1} }'], ] as [$identifier, $payload]) { $queue->pushRaw($payload); From 0598d2418929c81a0bb9ebd93fbf3fe90cbd74a5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:47:14 +0000 Subject: [PATCH 41/41] Select Arabic-Indic digits explicitly in number tests The localized zero regressions assumed that the ar locale always selected Arabic-Indic digits. ICU 74.2 uses those digits by default, while ICU 76.1 in both PHP CI images selects Latin digits, causing correct formatter output to fail the assertions. Request ar@numbers=arab explicitly in the existing forHumans and abbreviate cases and explain why the numbering system is specified. Preserve every assertion and the source behavior. The tests still reject the earlier ASCII-zero implementation on both ICU versions. Validated the complete test file locally and in the PHP 8.4 and PHP 8.5 CI images, plus targeted formatting and whitespace checks. This corrects the regression coverage associated with the Number formatting port: https://github.com/laravel/framework/pull/58358. --- tests/Support/SupportNumberTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Support/SupportNumberTest.php b/tests/Support/SupportNumberTest.php index 2c10623c93..bffab922fc 100644 --- a/tests/Support/SupportNumberTest.php +++ b/tests/Support/SupportNumberTest.php @@ -283,7 +283,8 @@ public function testToHuman(): void $this->assertSame('0.005', Number::forHumans(0.005, precision: 3)); $this->assertSame('-0.005', Number::forHumans(-0.005, precision: 3)); - Number::withLocale('ar', function () { + // Request Arabic-Indic digits explicitly because ICU versions use different defaults for ar. + Number::withLocale('ar@numbers=arab', function () { $this->assertSame('٠', Number::forHumans(0)); $this->assertSame('٠', Number::forHumans(-0.004)); $this->assertSame('٠', Number::forHumans(-0.004, maxPrecision: 2)); @@ -364,7 +365,8 @@ public function testSummarize(): void $this->assertSame('0', Number::abbreviate(-0.005)); $this->assertSame('0.005', Number::abbreviate(0.005, precision: 3)); - Number::withLocale('ar', function () { + // Request Arabic-Indic digits explicitly because ICU versions use different defaults for ar. + Number::withLocale('ar@numbers=arab', function () { $this->assertSame('٠', Number::abbreviate(0)); $this->assertSame('٠', Number::abbreviate(-0.004)); $this->assertSame('٠', Number::abbreviate(-0.004, maxPrecision: 2));