From d7f928651ebbc3da2dd700649ed7b2e5df234695 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 01/24] Include soft-deleted rows in mass pruning
Build the pruning query with trashed rows included for soft-deletable models. Preserve chunk limits and event handling, and cover pruning mixed active and deleted rows.
Upstream: https://github.com/laravel/framework/pull/61425
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/database/src/Eloquent/MassPrunable.php | 11 +++---
.../Database/EloquentMassPrunableTest.php | 35 ++++++++++++++++---
2 files changed, 36 insertions(+), 10 deletions(-)
diff --git a/src/database/src/Eloquent/MassPrunable.php b/src/database/src/Eloquent/MassPrunable.php
index 54de357531..8b08b3caba 100644
--- a/src/database/src/Eloquent/MassPrunable.php
+++ b/src/database/src/Eloquent/MassPrunable.php
@@ -15,17 +15,16 @@ trait MassPrunable
*/
public function pruneAll(int $chunkSize = 1000): int
{
- $query = tap($this->prunable(), function ($query) use ($chunkSize) {
- $query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) {
- $query->limit($chunkSize);
- });
+ $softDeletable = static::isSoftDeletable();
+
+ $query = tap($this->prunable(), function (Builder $query) use ($chunkSize, $softDeletable): void {
+ $query->when($softDeletable, fn (Builder $query): Builder => $query->withTrashed())
+ ->when(! $query->getQuery()->limit, fn (Builder $query): Builder => $query->limit($chunkSize));
});
$total = 0;
$events = null;
- $softDeletable = static::isSoftDeletable();
-
do {
$total += $count = $softDeletable
? $query->forceDelete()
diff --git a/tests/Integration/Database/EloquentMassPrunableTest.php b/tests/Integration/Database/EloquentMassPrunableTest.php
index fdb5dec2cd..776590610e 100644
--- a/tests/Integration/Database/EloquentMassPrunableTest.php
+++ b/tests/Integration/Database/EloquentMassPrunableTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Integration\Database;
use Hypervel\Contracts\Events\Dispatcher;
+use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\MassPrunable;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\SoftDeletes;
@@ -16,6 +17,9 @@
class EloquentMassPrunableTest extends DatabaseTestCase
{
+ /**
+ * Create the pruning tables.
+ */
protected function afterRefreshingDatabase(): void
{
collect([
@@ -40,7 +44,7 @@ public function testPrunableMethodMustBeImplemented(): void
MassPrunableTestModelMissingPrunableMethod::create()->pruneAll();
}
- public function testPrunesRecords()
+ public function testPrunesRecords(): void
{
Event::fake();
@@ -75,7 +79,7 @@ static function (ModelsPruned $event) use (&$observedEvents): void {
$this->assertSame([], $observedEvents);
}
- public function testPrunesSoftDeletedRecords()
+ public function testPrunesSoftDeletedRecords(): void
{
Event::fake();
@@ -93,13 +97,33 @@ public function testPrunesSoftDeletedRecords()
Event::assertDispatched(ModelsPruned::class, 3);
}
+
+ public function testPrunesActiveAndSoftDeletedRecords(): void
+ {
+ Event::fake();
+
+ MassPrunableSoftDeleteTestModel::insert([
+ ['deleted_at' => null],
+ ['deleted_at' => now()],
+ ]);
+
+ $count = (new MassPrunableSoftDeleteTestModel)->pruneAll();
+
+ $this->assertEquals(2, $count);
+ $this->assertEquals(0, MassPrunableSoftDeleteTestModel::withTrashed()->count());
+
+ Event::assertDispatched(ModelsPruned::class, 1);
+ }
}
class MassPrunableTestModel extends Model
{
use MassPrunable;
- public function prunable()
+ /**
+ * Get the prunable model query.
+ */
+ public function prunable(): Builder
{
return $this->where('id', '<=', 1500);
}
@@ -110,7 +134,10 @@ class MassPrunableSoftDeleteTestModel extends Model
use MassPrunable;
use SoftDeletes;
- public function prunable()
+ /**
+ * Get the prunable model query.
+ */
+ public function prunable(): Builder
{
return $this->where('id', '<=', 3000);
}
From 798b55d1cf7f94c8ad183c5dac9824548a39379f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 02/24] Forward validation rule arguments to their constructors
Let In, NotIn, Contains and DoesntContain normalize their own arguments. Preserve native Arrayable, enum and array support without a second conversion in Rule.
Upstream: https://github.com/laravel/framework/pull/61445
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/validation/src/Rule.php | 24 ++++--------------------
1 file changed, 4 insertions(+), 20 deletions(-)
diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php
index 3439e77ce2..047ad9c7cd 100644
--- a/src/validation/src/Rule.php
+++ b/src/validation/src/Rule.php
@@ -125,11 +125,7 @@ public static function exists(string $table, string $column = 'NULL'): Exists
*/
public static function in(array|Arrayable|UnitEnum|string $values): In
{
- if ($values instanceof Arrayable) {
- $values = $values->toArray();
- }
-
- return new In(is_array($values) ? $values : func_get_args());
+ return new In(...func_get_args());
}
/**
@@ -137,11 +133,7 @@ public static function in(array|Arrayable|UnitEnum|string $values): In
*/
public static function notIn(array|Arrayable|UnitEnum|string $values): NotIn
{
- if ($values instanceof Arrayable) {
- $values = $values->toArray();
- }
-
- return new NotIn(is_array($values) ? $values : func_get_args());
+ return new NotIn(...func_get_args());
}
/**
@@ -149,11 +141,7 @@ public static function notIn(array|Arrayable|UnitEnum|string $values): NotIn
*/
public static function contains(array|Arrayable|UnitEnum|string $values): Contains
{
- if ($values instanceof Arrayable) {
- $values = $values->toArray();
- }
-
- return new Contains(is_array($values) ? $values : func_get_args());
+ return new Contains(...func_get_args());
}
/**
@@ -161,11 +149,7 @@ public static function contains(array|Arrayable|UnitEnum|string $values): Contai
*/
public static function doesntContain(array|Arrayable|UnitEnum|string $values): DoesntContain
{
- if ($values instanceof Arrayable) {
- $values = $values->toArray();
- }
-
- return new DoesntContain(is_array($values) ? $values : func_get_args());
+ return new DoesntContain(...func_get_args());
}
/**
From 563a92c8d6e5e9fb955ada076ecd71f1036d47d0 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 03/24] Describe query-log callback results precisely
Add the parenthesized closure return shape to withFreshQueryLog so callback results retain query, binding and timing information in static analysis.
Upstream: https://github.com/laravel/framework/pull/61444
Upstream: https://github.com/laravel/framework/pull/61458
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/database/src/Connection.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php
index c2548a003e..969c14c932 100755
--- a/src/database/src/Connection.php
+++ b/src/database/src/Connection.php
@@ -496,6 +496,7 @@ public function withoutPretending(Closure $callback): mixed
/**
* Execute the given callback in "dry run" mode.
*
+ * @param (Closure(): (array{query: string, bindings: array, time: null|float}[])) $callback
* @return array{query: string, bindings: array, time: null|float}[]
*/
protected function withFreshQueryLog(Closure $callback): array
From 5b8c9c427de1850e89d548f2676aef3c4c8b81a4 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 04/24] Match wildcard paths in missing JSON assertions
Match each wildcard against a single path segment and report unexpected paths. Preserve ordinary path assertions and handle numeric root keys under strict types. Port the upstream regressions and document wildcard assertions.
Upstream: https://github.com/laravel/framework/pull/61441
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/docs/http-tests.md | 6 ++++
src/testing/src/AssertableJsonString.php | 17 ++++++++-
tests/Testing/TestResponseTest.php | 45 ++++++++++++++++++++++++
3 files changed, 67 insertions(+), 1 deletion(-)
diff --git a/src/docs/http-tests.md b/src/docs/http-tests.md
index b684f71879..afe65b2dde 100644
--- a/src/docs/http-tests.md
+++ b/src/docs/http-tests.md
@@ -1637,6 +1637,12 @@ You may assert that it does not contain the `email` property of the `user` objec
$response->assertJsonMissingPath('user.email');
```
+You may use `*` to assert that a path is missing from every item in an array:
+
+```php
+$response->assertJsonMissingPath('users.*.password');
+```
+
#### assertJsonMissingPaths
diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php
index 4d6bf59c9c..009936f07c 100644
--- a/src/testing/src/AssertableJsonString.php
+++ b/src/testing/src/AssertableJsonString.php
@@ -202,7 +202,22 @@ public function assertMissingExact(array $data): static
*/
public function assertMissingPath(string $path): static
{
- PHPUnit::assertFalse(Arr::has($this->json(), $path));
+ if (! str_contains($path, '*')) {
+ PHPUnit::assertFalse(Arr::has($this->json(), $path));
+
+ return $this;
+ }
+
+ $pattern = '#^' . (new Collection(explode('.', $path)))
+ ->map(fn (string $segment): string => $segment === '*' ? '[^.]+' : preg_quote($segment, '#'))
+ ->implode('\.') . '(\.|$)#';
+
+ PHPUnit::assertFalse(
+ (new Collection(Arr::dot((array) $this->json())))
+ ->keys()
+ ->contains(fn (int|string $key): bool => preg_match($pattern, (string) $key) === 1),
+ "Found unexpected path [{$path}] within the response JSON."
+ );
return $this;
}
diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php
index 61fbba8722..92b8c4a929 100644
--- a/tests/Testing/TestResponseTest.php
+++ b/tests/Testing/TestResponseTest.php
@@ -1906,6 +1906,51 @@ public function testAssertJsonMissingPathCanFail3(): void
$response->assertJsonMissingPath('numeric_keys.3');
}
+ public function testAssertJsonMissingPathWithWildcard(): void
+ {
+ $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub));
+
+ $response->assertJsonMissingPath('bars.*.missing');
+ $response->assertJsonMissingPath('missing.*.bar');
+
+ // A wildcard matches a single segment, so this must not match "barfoo.*.bar.foo"...
+ $response->assertJsonMissingPath('barfoo.*.foo');
+ }
+
+ public function testAssertJsonMissingPathWithTrailingWildcardCanFail(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+
+ $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub));
+
+ $response->assertJsonMissingPath('bars.*');
+ }
+
+ public function testAssertJsonMissingPathWithWildcardCanFail(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+
+ $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub));
+
+ $response->assertJsonMissingPath('bars.*.bar');
+ }
+
+ public function testAssertJsonMissingPathWithWildcardCanFailWhenPresentOnSomeItems(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+
+ $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub));
+
+ $response->assertJsonMissingPath('barfoo.*.bar.foo');
+ }
+
+ public function testAssertJsonMissingPathWithWildcardCanFailForTopLevelArray(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+
+ TestResponse::fromBaseResponse(new JsonResponse(['present']))->assertJsonMissingPath('*');
+ }
+
public function testAssertJsonMissingPaths(): void
{
$response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub));
From 4c934a37b3ce191c65228df033004dfc52d4b95a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 05/24] Inherit custom model builders and expose violation
exceptions
Resolve custom-builder attributes through the existing inherited-attribute cache. Pass the corresponding exception to lazy-loading, missing-attribute and discarded-attribute callbacks while retaining coroutine-local suppression and boot-time configuration rules. Consolidate inherited-builder tests and document the callback arguments. Replace six unused assignment arguments with their matching named arguments across model, batch and view calls.
Upstream: https://github.com/laravel/framework/pull/61440
Upstream: https://github.com/laravel/framework/pull/61504
Upstream: https://github.com/laravel/framework/pull/61513
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/bus/src/Batch.php | 4 +-
.../src/Eloquent/Concerns/HasAttributes.php | 19 +++--
src/database/src/Eloquent/Model.php | 54 ++++++-------
src/docs/eloquent-relationships.md | 8 +-
src/docs/eloquent.md | 4 +-
.../src/Compilers/ComponentTagCompiler.php | 4 +-
src/view/src/ComponentAttributeBag.php | 2 +-
tests/Database/DatabaseEloquentModelTest.php | 50 ++++++++++--
.../Eloquent/UseEloquentBuilderTest.php | 80 -------------------
.../Database/EloquentStrictLoadingTest.php | 10 ++-
10 files changed, 108 insertions(+), 127 deletions(-)
diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php
index 643ed9ee66..e12a4f6201 100644
--- a/src/bus/src/Batch.php
+++ b/src/bus/src/Batch.php
@@ -90,8 +90,8 @@ public function add(array|object $jobs): ?Batch
$this->queue->connection($this->options['connection'] ?? null)->bulk(
$jobs->all(),
- $data = '',
- $this->options['queue'] ?? null
+ data: '',
+ queue: $this->options['queue'] ?? null
);
});
diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php
index 1dbd95e0bd..098118b66f 100644
--- a/src/database/src/Eloquent/Concerns/HasAttributes.php
+++ b/src/database/src/Eloquent/Concerns/HasAttributes.php
@@ -522,11 +522,13 @@ protected function throwMissingAttributeExceptionIfApplicable(string $key): mixe
&& ! $this->wasRecentlyCreated
&& static::preventsAccessingMissingAttributes()
&& ! CoroutineContext::get(self::MISSING_ATTRIBUTE_ACCESS_SUPPRESSED_CONTEXT_KEY, false)) {
+ $exception = new MissingAttributeException($this, $key);
+
if (isset(static::$missingAttributeViolationCallback)) {
- return call_user_func(static::$missingAttributeViolationCallback, $this, $key);
+ return call_user_func(static::$missingAttributeViolationCallback, $this, $key, $exception);
}
- throw new MissingAttributeException($this, $key);
+ throw $exception;
}
return null;
@@ -600,15 +602,22 @@ public function isRelation(string $key): bool
*/
protected function handleLazyLoadingViolation(string $key): mixed
{
+ $exception = new LazyLoadingViolationException($this, $key);
+
if (isset(static::$lazyLoadingViolationCallback)) {
- return call_user_func(static::$lazyLoadingViolationCallback, $this, $key);
+ return call_user_func(
+ static::$lazyLoadingViolationCallback,
+ $this,
+ $key,
+ $exception
+ );
}
if (! $this->exists || $this->wasRecentlyCreated) {
return null;
}
- throw new LazyLoadingViolationException($this, $key);
+ throw $exception;
}
/**
@@ -1981,7 +1990,7 @@ public function getOriginal(?string $key = null, mixed $default = null): mixed
{
return (new static)->setRawAttributes(
$this->original,
- $sync = true
+ sync: true
)->getOriginalWithoutRewindingModel($key, $default);
}
diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php
index 0b22ce421a..69291bde2d 100644
--- a/src/database/src/Eloquent/Model.php
+++ b/src/database/src/Eloquent/Model.php
@@ -36,6 +36,7 @@
use Hypervel\Database\Eloquent\Relations\HasManyThrough;
use Hypervel\Database\Eloquent\Relations\Pivot;
use Hypervel\Database\Eloquent\Relations\Relation;
+use Hypervel\Database\LazyLoadingViolationException;
use Hypervel\Database\Query\Builder as QueryBuilder;
use Hypervel\Engine\Coroutine;
use Hypervel\Support\Arr;
@@ -228,9 +229,9 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
/**
* The callback that is responsible for handling lazy loading violations.
*
- * @var null|(callable(self, string): mixed)
+ * @var null|(callable(self, string, LazyLoadingViolationException): mixed)
*/
- protected static $lazyLoadingViolationCallback;
+ protected static mixed $lazyLoadingViolationCallback = null;
/**
* Indicates if an exception should be thrown instead of silently discarding non-fillable attributes.
@@ -240,9 +241,9 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
/**
* The callback that is responsible for handling discarded attribute violations.
*
- * @var null|(callable(self, array): mixed)
+ * @var null|(callable(self, array, MassAssignmentException): mixed)
*/
- protected static $discardedAttributeViolationCallback;
+ protected static mixed $discardedAttributeViolationCallback = null;
/**
* Indicates if an exception should be thrown when trying to access a missing attribute on a retrieved model.
@@ -252,9 +253,9 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
/**
* The callback that is responsible for handling missing attribute violations.
*
- * @var null|(callable(self, string): mixed)
+ * @var null|(callable(self, string, MissingAttributeException): mixed)
*/
- protected static $missingAttributeViolationCallback;
+ protected static mixed $missingAttributeViolationCallback = null;
/**
* Indicates if invalid value exceptions during implicit route model binding should be reported.
@@ -679,7 +680,7 @@ public static function automaticallyEagerLoadRelationships(bool $value = true):
* Boot-only. The callback persists in a static property for the worker
* lifetime and runs on every lazy-loading violation across all coroutines.
*
- * @param null|(callable(self, string): mixed) $callback
+ * @param null|(callable(self, string, LazyLoadingViolationException): mixed) $callback
*/
public static function handleLazyLoadingViolationUsing(?callable $callback): void
{
@@ -704,7 +705,7 @@ public static function preventSilentlyDiscardingAttributes(bool $value = true):
* lifetime and runs on every discarded-attribute violation across all
* coroutines.
*
- * @param null|(callable(self, array): mixed) $callback
+ * @param null|(callable(self, array, MassAssignmentException): mixed) $callback
*/
public static function handleDiscardedAttributeViolationUsing(?callable $callback): void
{
@@ -729,7 +730,7 @@ public static function preventAccessingMissingAttributes(bool $value = true): vo
* lifetime and runs on every missing-attribute violation across all
* coroutines.
*
- * @param null|(callable(self, string): mixed) $callback
+ * @param null|(callable(self, string, MissingAttributeException): mixed) $callback
*/
public static function handleMissingAttributeViolationUsing(?callable $callback): void
{
@@ -796,14 +797,16 @@ public function fill(array $attributes): static
if ($this->isFillable($key)) {
$this->setAttribute($key, $value);
} elseif ($totallyGuarded || static::preventsSilentlyDiscardingAttributes()) {
+ $exception = new MassAssignmentException(sprintf(
+ 'Add [%s] to fillable property to allow mass assignment on [%s].',
+ $key,
+ get_class($this)
+ ));
+
if (isset(static::$discardedAttributeViolationCallback)) {
- call_user_func(static::$discardedAttributeViolationCallback, $this, [$key]);
+ call_user_func(static::$discardedAttributeViolationCallback, $this, [$key], $exception);
} else {
- throw new MassAssignmentException(sprintf(
- 'Add [%s] to fillable property to allow mass assignment on [%s].',
- $key,
- get_class($this)
- ));
+ throw $exception;
}
}
}
@@ -812,14 +815,16 @@ public function fill(array $attributes): static
&& static::preventsSilentlyDiscardingAttributes()) {
$keys = array_diff(array_keys($attributes), array_keys($fillable));
+ $exception = new MassAssignmentException(sprintf(
+ 'Add fillable property [%s] to allow mass assignment on [%s].',
+ implode(', ', $keys),
+ get_class($this)
+ ));
+
if (isset(static::$discardedAttributeViolationCallback)) {
- call_user_func(static::$discardedAttributeViolationCallback, $this, $keys);
+ call_user_func(static::$discardedAttributeViolationCallback, $this, $keys, $exception);
} else {
- throw new MassAssignmentException(sprintf(
- 'Add fillable property [%s] to allow mass assignment on [%s].',
- implode(', ', $keys),
- get_class($this)
- ));
+ throw $exception;
}
}
@@ -2016,12 +2021,7 @@ public function newEloquentBuilder(QueryBuilder $query): Builder
*/
protected function resolveCustomBuilderClass(): string|false
{
- $attributes = (new ReflectionClass($this))
- ->getAttributes(UseEloquentBuilder::class);
-
- return ! empty($attributes)
- ? $attributes[0]->newInstance()->builderClass
- : false;
+ return static::resolveClassAttribute(UseEloquentBuilder::class, 'builderClass') ?? false;
}
/**
diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md
index 122b09deb3..dbd3178502 100644
--- a/src/docs/eloquent-relationships.md
+++ b/src/docs/eloquent-relationships.md
@@ -2427,13 +2427,17 @@ After preventing lazy loading, Eloquent will throw a `Hypervel\Database\LazyLoad
You may customize the behavior of lazy loading violations using the `handleLazyLoadingViolationUsing` method. For example, using this method, you may instruct lazy loading violations to only be logged instead of interrupting the application's execution with exceptions:
```php
-Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
+use Hypervel\Database\LazyLoadingViolationException;
+
+Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation, LazyLoadingViolationException $exception) {
$class = $model::class;
- info("Attempted to lazy load [{$relation}] on model [{$class}].");
+ info("Attempted to lazy load [{$relation}] on model [{$class}].", ['exception' => $exception]);
});
```
+The callback receives the violation exception as its third argument.
+
## Inserting and Updating Related Models
diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md
index 204a45220c..2330909d9a 100644
--- a/src/docs/eloquent.md
+++ b/src/docs/eloquent.md
@@ -467,7 +467,7 @@ If you would like to enable all three strictness behaviors, you may invoke the `
Model::shouldBeStrict(! $this->app->isProduction());
```
-Hypervel also allows you to customize how lazy loading violations, discarded attributes, and missing attributes are handled using the `handleLazyLoadingViolationUsing`, `handleDiscardedAttributeViolationUsing`, and `handleMissingAttributeViolationUsing` methods.
+Hypervel also allows you to customize how lazy loading violations, discarded attributes, and missing attributes are handled using the `handleLazyLoadingViolationUsing`, `handleDiscardedAttributeViolationUsing`, and `handleMissingAttributeViolationUsing` methods. Each callback receives the corresponding violation exception as its third argument.
> [!WARNING]
> Eloquent strictness methods configure worker-wide static state in Hypervel. These methods should be called while your application is booting, such as from a service provider, and not from request-specific code.
@@ -1856,6 +1856,8 @@ class User extends Model
}
```
+Child models inherit the attribute unless they declare their own `UseEloquentBuilder` attribute.
+
Your custom builder should extend Hypervel's base Eloquent builder:
```php
diff --git a/src/view/src/Compilers/ComponentTagCompiler.php b/src/view/src/Compilers/ComponentTagCompiler.php
index 67d33e87e0..d34d643d04 100644
--- a/src/view/src/Compilers/ComponentTagCompiler.php
+++ b/src/view/src/Compilers/ComponentTagCompiler.php
@@ -244,7 +244,7 @@ protected function componentString(string $component, array $attributes): string
$parameters = [
'view' => $view,
- 'data' => '[' . $this->attributesToString($data->all(), $escapeBound = false) . ']',
+ 'data' => '[' . $this->attributesToString($data->all(), escapeBound: false) . ']',
];
$class = AnonymousComponent::class;
@@ -252,7 +252,7 @@ protected function componentString(string $component, array $attributes): string
$parameters = $data->all();
}
- return "##BEGIN-COMPONENT-CLASS##@component('{$class}', '{$component}', [" . $this->attributesToString($parameters, $escapeBound = false) . '])
+ return "##BEGIN-COMPONENT-CLASS##@component('{$class}', '{$component}', [" . $this->attributesToString($parameters, escapeBound: false) . '])
except(\\' . $class . '::ignoredParameterNames()); ?>
diff --git a/src/view/src/ComponentAttributeBag.php b/src/view/src/ComponentAttributeBag.php
index 9ca55fd52a..da846473c5 100644
--- a/src/view/src/ComponentAttributeBag.php
+++ b/src/view/src/ComponentAttributeBag.php
@@ -305,7 +305,7 @@ public function setAttributes(array $attributes): void
unset($attributes['attributes']);
- $attributes = $parentBag->merge($attributes, $escape = false)->getAttributes();
+ $attributes = $parentBag->merge($attributes, escape: false)->getAttributes();
}
$this->attributes = $attributes;
diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php
index ba4dbf5939..5d37c0f0c6 100755
--- a/tests/Database/DatabaseEloquentModelTest.php
+++ b/tests/Database/DatabaseEloquentModelTest.php
@@ -25,6 +25,7 @@
use Hypervel\Database\Eloquent\Attributes\ObservedBy;
use Hypervel\Database\Eloquent\Attributes\RouteKey;
use Hypervel\Database\Eloquent\Attributes\Table;
+use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder;
use Hypervel\Database\Eloquent\Attributes\UseFactory;
use Hypervel\Database\Eloquent\Attributes\WithoutTimestamps;
use Hypervel\Database\Eloquent\Builder;
@@ -2083,9 +2084,11 @@ public function testUsesOverriddenHandlerWhenDiscardingAttributes(): void
$callbackModel = null;
$callbackKeys = null;
- Model::handleDiscardedAttributeViolationUsing(function (Model $model, array $keys) use (&$callbackModel, &$callbackKeys): void {
+ $callbackException = null;
+ Model::handleDiscardedAttributeViolationUsing(function (Model $model, array $keys, MassAssignmentException $exception) use (&$callbackModel, &$callbackKeys, &$callbackException): void {
$callbackModel = $model;
$callbackKeys = $keys;
+ $callbackException = $exception;
});
$model = new ModelStub;
@@ -2094,6 +2097,8 @@ public function testUsesOverriddenHandlerWhenDiscardingAttributes(): void
$this->assertInstanceOf(ModelStub::class, $callbackModel);
$this->assertEquals(['Foo'], $callbackKeys);
+ $this->assertInstanceOf(MassAssignmentException::class, $callbackException);
+ $this->assertSame('Add [Foo] to fillable property to allow mass assignment on [' . ModelStub::class . '].', $callbackException->getMessage());
Model::preventSilentlyDiscardingAttributes(false);
Model::handleDiscardedAttributeViolationUsing(null);
@@ -4221,9 +4226,12 @@ public function testUsesOverriddenHandlerWhenAccessingMissingAttributes(): void
$callbackModel = null;
$callbackKey = null;
- Model::handleMissingAttributeViolationUsing(function (Model $model, string $key) use (&$callbackModel, &$callbackKey): void {
+ $callbackException = null;
+
+ Model::handleMissingAttributeViolationUsing(function (Model $model, string $key, MissingAttributeException $exception) use (&$callbackModel, &$callbackKey, &$callbackException): void {
$callbackModel = $model;
$callbackKey = $key;
+ $callbackException = $exception;
});
$model = new ModelStub(['id' => 1]);
@@ -4235,6 +4243,7 @@ public function testUsesOverriddenHandlerWhenAccessingMissingAttributes(): void
$this->assertInstanceOf(ModelStub::class, $callbackModel);
$this->assertSame('this_attribute_does_not_exist', $callbackKey);
+ $this->assertInstanceOf(MissingAttributeException::class, $callbackException);
Model::preventAccessingMissingAttributes($originalMode);
Model::handleMissingAttributeViolationUsing(null);
@@ -4845,7 +4854,7 @@ protected static function boot(): void
};
}
- public function testUseCustomBuilderWithUseEloquentBuilderAttribute()
+ public function testUseCustomBuilderWithUseEloquentBuilderAttribute(): void
{
$model = new ModelWithUseEloquentBuilderAttributeStub;
@@ -4855,7 +4864,25 @@ public function testUseCustomBuilderWithUseEloquentBuilderAttribute()
$this->assertInstanceOf(CustomBuilder::class, $eloquentBuilder);
}
- public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPresent()
+ public function testUseEloquentBuilderAttributeIsInherited(): void
+ {
+ $model = new EloquentModelInheritingBuilderStub;
+
+ $query = $this->createStub(BaseBuilder::class);
+
+ $this->assertInstanceOf(CustomBuilder::class, $model->newEloquentBuilder($query));
+ }
+
+ public function testUseEloquentBuilderAttributeOnChildClassOverridesParentAttribute(): void
+ {
+ $model = new EloquentModelOverridingBuilderStub;
+
+ $query = $this->createStub(BaseBuilder::class);
+
+ $this->assertInstanceOf(ChildCustomBuilder::class, $model->newEloquentBuilder($query));
+ }
+
+ public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPresent(): void
{
$model = new ModelWithoutUseEloquentBuilderAttributeStub;
@@ -4884,11 +4911,24 @@ class CustomBuilder extends Builder
{
}
-#[\Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder(CustomBuilder::class)]
+class ChildCustomBuilder extends Builder
+{
+}
+
+#[UseEloquentBuilder(CustomBuilder::class)]
class ModelWithUseEloquentBuilderAttributeStub extends Model
{
}
+class EloquentModelInheritingBuilderStub extends ModelWithUseEloquentBuilderAttributeStub
+{
+}
+
+#[UseEloquentBuilder(ChildCustomBuilder::class)]
+class EloquentModelOverridingBuilderStub extends ModelWithUseEloquentBuilderAttributeStub
+{
+}
+
class ModelWithoutUseEloquentBuilderAttributeStub extends Model
{
}
diff --git a/tests/Database/Eloquent/UseEloquentBuilderTest.php b/tests/Database/Eloquent/UseEloquentBuilderTest.php
index 72f238e65b..0307530513 100644
--- a/tests/Database/Eloquent/UseEloquentBuilderTest.php
+++ b/tests/Database/Eloquent/UseEloquentBuilderTest.php
@@ -12,17 +12,6 @@
class UseEloquentBuilderTest extends TestCase
{
- protected function tearDown(): void
- {
- // Clear the static cache between tests
- UseEloquentBuilderTestModel::clearResolvedBuilderClasses();
- UseEloquentBuilderTestModelWithAttribute::clearResolvedBuilderClasses();
- UseEloquentBuilderTestChildModel::clearResolvedBuilderClasses();
- UseEloquentBuilderTestChildModelWithOwnAttribute::clearResolvedBuilderClasses();
-
- parent::tearDown();
- }
-
public function testNewModelBuilderReturnsDefaultBuilderWhenNoAttribute(): void
{
$model = new UseEloquentBuilderTestModel;
@@ -92,28 +81,6 @@ public function testDifferentModelsUseDifferentCaches(): void
$this->assertNotInstanceOf(CustomTestBuilder::class, $builder1);
$this->assertInstanceOf(CustomTestBuilder::class, $builder2);
}
-
- public function testChildModelWithoutAttributeUsesDefaultBuilder(): void
- {
- $model = new UseEloquentBuilderTestChildModel;
- $query = m::mock(\Hypervel\Database\Query\Builder::class);
-
- $builder = $model->newEloquentBuilder($query);
-
- // PHP attributes are not inherited - child needs its own attribute
- $this->assertInstanceOf(Builder::class, $builder);
- $this->assertNotInstanceOf(CustomTestBuilder::class, $builder);
- }
-
- public function testChildModelWithOwnAttributeUsesOwnBuilder(): void
- {
- $model = new UseEloquentBuilderTestChildModelWithOwnAttribute;
- $query = m::mock(\Hypervel\Database\Query\Builder::class);
-
- $builder = $model->newEloquentBuilder($query);
-
- $this->assertInstanceOf(AnotherCustomTestBuilder::class, $builder);
- }
}
// Test fixtures
@@ -129,14 +96,6 @@ public function testResolveCustomBuilderClass(): string|false
{
return $this->resolveCustomBuilderClass();
}
-
- /**
- * Clear the static cache for testing.
- */
- public static function clearResolvedBuilderClasses(): void
- {
- static::$resolvedBuilderClasses = [];
- }
}
#[UseEloquentBuilder(CustomTestBuilder::class)]
@@ -151,37 +110,6 @@ public function testResolveCustomBuilderClass(): string|false
{
return $this->resolveCustomBuilderClass();
}
-
- /**
- * Clear the static cache for testing.
- */
- public static function clearResolvedBuilderClasses(): void
- {
- static::$resolvedBuilderClasses = [];
- }
-}
-
-class UseEloquentBuilderTestChildModel extends UseEloquentBuilderTestModelWithAttribute
-{
- /**
- * Clear the static cache for testing.
- */
- public static function clearResolvedBuilderClasses(): void
- {
- static::$resolvedBuilderClasses = [];
- }
-}
-
-#[UseEloquentBuilder(AnotherCustomTestBuilder::class)]
-class UseEloquentBuilderTestChildModelWithOwnAttribute extends UseEloquentBuilderTestModelWithAttribute
-{
- /**
- * Clear the static cache for testing.
- */
- public static function clearResolvedBuilderClasses(): void
- {
- static::$resolvedBuilderClasses = [];
- }
}
/**
@@ -191,11 +119,3 @@ public static function clearResolvedBuilderClasses(): void
class CustomTestBuilder extends Builder
{
}
-
-/**
- * @template TModel of Model
- * @extends Builder
- */
-class AnotherCustomTestBuilder extends Builder
-{
-}
diff --git a/tests/Integration/Database/EloquentStrictLoadingTest.php b/tests/Integration/Database/EloquentStrictLoadingTest.php
index 73cd4aeff1..af330b6ebf 100644
--- a/tests/Integration/Database/EloquentStrictLoadingTest.php
+++ b/tests/Integration/Database/EloquentStrictLoadingTest.php
@@ -116,11 +116,15 @@ public function testStrictModeThrowsAnExceptionOnLazyLoadingInRelations()
$models[0]->modelTwos[0]->modelThrees;
}
- public function testStrictModeWithCustomCallbackOnLazyLoading()
+ public function testStrictModeWithCustomCallbackOnLazyLoading(): void
{
Event::fake();
- Model::handleLazyLoadingViolationUsing(function ($model, $key) {
+ $callbackException = null;
+
+ Model::handleLazyLoadingViolationUsing(function (Model $model, string $key, LazyLoadingViolationException $exception) use (&$callbackException): void {
+ $callbackException = $exception;
+
event(new ViolatedLazyLoadingEvent($model, $key));
});
@@ -132,6 +136,8 @@ public function testStrictModeWithCustomCallbackOnLazyLoading()
$models[0]->modelTwos;
Event::assertDispatched(ViolatedLazyLoadingEvent::class);
+ $this->assertInstanceOf(LazyLoadingViolationException::class, $callbackException);
+ $this->assertSame(EloquentStrictLoadingTestModel1::class, $callbackException->model);
}
public function testStrictModeWithOverriddenHandlerOnLazyLoading(): void
From 29cbe972792e2f0612719843a5f92f1472b09916 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 06/24] Expose the Vite development server URL
Add devServerUrl and use it when building hot asset URLs. Respect custom hot-file paths, return null outside hot mode, and retain explicit failures when a present hot file cannot be read. Include upstream tests, facade annotations and usage documentation.
Upstream: https://github.com/laravel/framework/pull/61465
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/docs/vite.md | 8 ++++++++
src/foundation/src/Vite.php | 14 +++++++++++++-
src/support/src/Facades/Vite.php | 1 +
tests/Foundation/FoundationViteTest.php | 23 +++++++++++++++++++++++
4 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/src/docs/vite.md b/src/docs/vite.md
index d2a52e232d..ed16276531 100644
--- a/src/docs/vite.md
+++ b/src/docs/vite.md
@@ -218,6 +218,14 @@ npm run dev
npm run build
```
+You may retrieve the development server's URL using `Vite::devServerUrl()`. The method respects custom hot file paths and returns `null` when the development server is not running:
+
+```php
+use Hypervel\Support\Facades\Vite;
+
+$url = Vite::devServerUrl();
+```
+
## Working With JavaScript
diff --git a/src/foundation/src/Vite.php b/src/foundation/src/Vite.php
index 7d26a3b639..b725ca671a 100644
--- a/src/foundation/src/Vite.php
+++ b/src/foundation/src/Vite.php
@@ -766,6 +766,18 @@ public function reactRefresh(): ?HtmlString
*/
protected function hotAsset(string $asset): string
{
+ return $this->devServerUrl() . '/' . $asset;
+ }
+
+ /**
+ * Get the URL of the running Vite development server.
+ */
+ public function devServerUrl(): ?string
+ {
+ if (! $this->isRunningHot()) {
+ return null;
+ }
+
$path = $this->hotFile();
$contents = @file_get_contents($path);
@@ -773,7 +785,7 @@ protected function hotAsset(string $asset): string
throw new ViteException("Unable to read the Vite hot file at [{$path}].");
}
- return rtrim($contents) . '/' . $asset;
+ return rtrim($contents);
}
/**
diff --git a/src/support/src/Facades/Vite.php b/src/support/src/Facades/Vite.php
index 4338e0ffea..dc59d1a499 100644
--- a/src/support/src/Facades/Vite.php
+++ b/src/support/src/Facades/Vite.php
@@ -11,6 +11,7 @@
* @method static string content(string $asset, string|null $buildDirectory = null)
* @method static \Hypervel\Foundation\Vite createAssetPathsUsing(callable|null $resolver)
* @method static string|null cspNonce()
+ * @method static string|null devServerUrl()
* @method static void flush()
* @method static void flushMacros()
* @method static \Hypervel\Support\HtmlString fonts(null|array|string $aliases = null)
diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php
index 6254f02ca6..baed453d5b 100644
--- a/tests/Foundation/FoundationViteTest.php
+++ b/tests/Foundation/FoundationViteTest.php
@@ -126,6 +126,29 @@ public function testViteWithNestedCssImport(): void
);
}
+ public function testItCanRetrieveTheDevServerUrl(): void
+ {
+ $this->makeViteHotFile();
+
+ $this->assertSame('http://localhost:3000', app(Vite::class)->devServerUrl());
+ }
+
+ public function testTheDevServerUrlIsNullWhenNotRunningHot(): void
+ {
+ $this->assertNull(app(Vite::class)->devServerUrl());
+ }
+
+ public function testTheDevServerUrlRespectsACustomHotFile(): void
+ {
+ $path = $this->tempDir . '/custom-hot';
+ $this->makeViteHotFile($path);
+
+ $this->assertSame(
+ 'http://localhost:3000',
+ app(Vite::class)->useHotFile($path)->devServerUrl()
+ );
+ }
+
public function testViteWithCyclicNestedImports(): void
{
$buildDir = Str::random();
From 62eefbc279070838e5449d0134fe9e13394d7893 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 07/24] Invalidate Redis connections after transport warnings
Recognize native TLS and stream-write warnings as transport failures so the next pool acquisition rebuilds the client. Preserve the original exception and never replay a possibly committed command. Route transformed scans through the common failure boundary, and apply the same invalidation policy to cached Lua evaluation. Cover unrelated warnings, transaction opening, scan failures and read-only replicas. Keep failed pipeline and transaction recovery under the existing discard-without-replay policy.
Upstream: https://github.com/laravel/framework/pull/61462
Upstream: https://github.com/laravel/framework/pull/61632
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/redis/src/PhpRedisClusterConnection.php | 9 +-
src/redis/src/RedisConnection.php | 139 +++++++++++-------
tests/Redis/PhpRedisClusterConnectionTest.php | 8 +
tests/Redis/RedisConnectionTest.php | 78 +++++++++-
4 files changed, 165 insertions(+), 69 deletions(-)
diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php
index 6d234d7b82..e5f3c6a400 100644
--- a/src/redis/src/PhpRedisClusterConnection.php
+++ b/src/redis/src/PhpRedisClusterConnection.php
@@ -93,17 +93,10 @@ public function masters(): array
/**
* Scan all keys based on the given options.
*
- * @param mixed $cursor
- * @param mixed ...$arguments
- *
* @throws InvalidArgumentException
*/
- public function scan(&$cursor, ...$arguments): mixed
+ protected function callScan(mixed &$cursor, mixed ...$arguments): mixed
{
- if (! $this->shouldTransform) {
- return $this->__call('scan', array_merge([&$cursor], $arguments));
- }
-
$options = $this->getScanOptions($arguments);
if (isset($options['node'])) {
diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php
index a38963d432..2fd1614566 100644
--- a/src/redis/src/RedisConnection.php
+++ b/src/redis/src/RedisConnection.php
@@ -6,6 +6,7 @@
use BadMethodCallException;
use Closure;
+use ErrorException;
use Generator;
use Hypervel\ConnectionPool\Connection as BaseConnection;
use Hypervel\ConnectionPool\Exceptions\ConnectionException;
@@ -393,7 +394,7 @@ public function __call(string $name, array $arguments): mixed
$name = strtolower($name);
$result = $this->executeCommand($name, $arguments);
- } catch (RedisException|RedisClusterException $exception) {
+ } catch (RedisException|RedisClusterException|ErrorException $exception) {
// REMOVED: Laravel's command retry loop can replay writes Redis already committed.
if ($this->shouldInvalidateAfter($exception)) {
$this->markInvalid();
@@ -983,8 +984,17 @@ public function isLifetimeExpired(?float $now = null): bool
/**
* Determine whether a failed command left the connection unsafe to reuse.
*/
- protected function shouldInvalidateAfter(RedisException|RedisClusterException $exception): bool
+ protected function shouldInvalidateAfter(RedisException|RedisClusterException|ErrorException $exception): bool
{
+ if ($exception instanceof ErrorException) {
+ // Stream transport failures may surface as warnings before phpredis throws.
+ $message = $exception->getMessage();
+
+ return str_contains($message, ' bytes failed with errno=')
+ || str_contains($message, 'SSL: ')
+ || str_contains($message, 'SSL operation failed with code ');
+ }
+
if ($this->connection->getLastError() !== $exception->getMessage()) {
return true;
}
@@ -1646,17 +1656,18 @@ protected function getScanOptions(array $arguments): array
}
/**
- * Scans all keys based on options.
- *
- * @param array $arguments
- * @param mixed $cursor
+ * Scan all keys based on options.
*/
- public function scan(&$cursor, ...$arguments): mixed
+ public function scan(mixed &$cursor, mixed ...$arguments): mixed
{
- if (! $this->shouldTransform) {
- return $this->__call('scan', array_merge([&$cursor], $arguments));
- }
+ return $this->__call('scan', array_merge([&$cursor], $arguments));
+ }
+ /**
+ * Execute the transformed SCAN command.
+ */
+ protected function callScan(mixed &$cursor, mixed ...$arguments): mixed
+ {
$options = $this->getScanOptions($arguments);
$result = $this->connection->scan(
@@ -1673,18 +1684,20 @@ public function scan(&$cursor, ...$arguments): mixed
}
/**
- * Scans the given set for all values based on options.
+ * Scan the given set for all values based on options.
*
* @param string $key
- * @param array $arguments
- * @param mixed $cursor
*/
- public function zscan($key, &$cursor, ...$arguments): mixed
+ public function zscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed
{
- if (! $this->shouldTransform) {
- return $this->__call('zScan', array_merge([$key, &$cursor], $arguments));
- }
+ return $this->__call('zScan', array_merge([$key, &$cursor], $arguments));
+ }
+ /**
+ * Execute the transformed ZSCAN command.
+ */
+ protected function callZscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed
+ {
$options = $this->getScanOptions($arguments);
$result = $this->connection->zscan(
@@ -1702,18 +1715,20 @@ public function zscan($key, &$cursor, ...$arguments): mixed
}
/**
- * Scans the given hash for all values based on options.
+ * Scan the given hash for all values based on options.
*
* @param string $key
- * @param array $arguments
- * @param mixed $cursor
*/
- public function hscan($key, &$cursor, ...$arguments): mixed
+ public function hscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed
{
- if (! $this->shouldTransform) {
- return $this->__call('hScan', array_merge([$key, &$cursor], $arguments));
- }
+ return $this->__call('hScan', array_merge([$key, &$cursor], $arguments));
+ }
+ /**
+ * Execute the transformed HSCAN command.
+ */
+ protected function callHscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed
+ {
$options = $this->getScanOptions($arguments);
$result = $this->connection->hscan(
@@ -1731,18 +1746,20 @@ public function hscan($key, &$cursor, ...$arguments): mixed
}
/**
- * Scans the given set for all values based on options.
+ * Scan the given set for all values based on options.
*
* @param string $key
- * @param array $arguments
- * @param mixed $cursor
*/
- public function sscan($key, &$cursor, ...$arguments): mixed
+ public function sscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed
{
- if (! $this->shouldTransform) {
- return $this->__call('sScan', array_merge([$key, &$cursor], $arguments));
- }
+ return $this->__call('sScan', array_merge([$key, &$cursor], $arguments));
+ }
+ /**
+ * Execute the transformed SSCAN command.
+ */
+ protected function callSscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed
+ {
$options = $this->getScanOptions($arguments);
$result = $this->connection->sscan(
@@ -2023,36 +2040,44 @@ public function evalWithShaCache(string $script, array $keys = [], array $args =
// combined_args = keys first, then other args
$combinedArgs = [...$keys, ...$args];
- // Clear any stale error from previous commands to ensure getLastError()
- // reflects this call, not a previous one
- $this->connection->clearLastError();
-
- // Try evalSha first - uses cached compiled script
- $result = $this->connection->evalSha($sha, $combinedArgs, $numKeys);
-
- if ($result === false) {
- $error = $this->connection->getLastError();
-
- // NOSCRIPT means script not cached yet - fall back to eval
- if ($error !== null && str_contains($error, 'NOSCRIPT')) {
- $this->connection->clearLastError();
- $result = $this->connection->eval($script, $combinedArgs, $numKeys);
-
- if ($result === false) {
- $evalError = $this->connection->getLastError();
- if ($evalError !== null) {
- throw $this->scriptException($evalError);
+ try {
+ // Clear any stale error from previous commands to ensure getLastError()
+ // reflects this call, not a previous one
+ $this->connection->clearLastError();
+
+ // Try evalSha first - uses cached compiled script
+ $result = $this->connection->evalSha($sha, $combinedArgs, $numKeys);
+
+ if ($result === false) {
+ $error = $this->connection->getLastError();
+
+ // NOSCRIPT means script not cached yet - fall back to eval
+ if ($error !== null && str_contains($error, 'NOSCRIPT')) {
+ $this->connection->clearLastError();
+ $result = $this->connection->eval($script, $combinedArgs, $numKeys);
+
+ if ($result === false) {
+ $evalError = $this->connection->getLastError();
+ if ($evalError !== null) {
+ throw $this->scriptException($evalError);
+ }
+ // If no error, script legitimately returned nil (which becomes false)
}
- // If no error, script legitimately returned nil (which becomes false)
+ } elseif ($error !== null) {
+ // Some other error (syntax, OOM, WRONGTYPE, etc.)
+ throw $this->scriptException($error);
}
- } elseif ($error !== null) {
- // Some other error (syntax, OOM, WRONGTYPE, etc.)
- throw $this->scriptException($error);
+ // If $error is null and $result is false, the script legitimately returned false
+ }
+
+ return $this->normalizeNullReplies($result);
+ } catch (RedisException|RedisClusterException|ErrorException $exception) {
+ if ($this->shouldInvalidateAfter($exception)) {
+ $this->markInvalid();
}
- // If $error is null and $result is false, the script legitimately returned false
- }
- return $this->normalizeNullReplies($result);
+ throw $exception;
+ }
}
/**
diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php
index d9fd2d0024..f2cedb667a 100644
--- a/tests/Redis/PhpRedisClusterConnectionTest.php
+++ b/tests/Redis/PhpRedisClusterConnectionTest.php
@@ -257,6 +257,7 @@ public function testItScansUsingOptionNode(): void
public function testItThrowsExceptionWithoutNodes(): void
{
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->expects('_masters')->andReturn([]);
$client->shouldNotReceive('scan');
$connection = new PhpRedisClusterConnectionStub;
@@ -272,6 +273,7 @@ public function testItReturnsFalseWhenCursorIsZeroAndResultIsEmpty(): void
{
$master = ['127.0.0.1', 6379];
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->expects('_masters')->andReturn([$master]);
$client->expects('scan')->with(0, $master, '*', 10)->andReturnFalse();
$connection = new PhpRedisClusterConnectionStub;
@@ -287,6 +289,7 @@ public function testItScansEveryMasterInTurn(): void
$masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379], ['127.0.0.3', 6379]];
$keys = ['a', 'b', 'c'];
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->allows('_masters')->andReturn($masters);
foreach ($masters as $index => $master) {
@@ -321,6 +324,7 @@ public function testItResumesAMasterFromTheEncodedCursor(array $firstPage): void
{
$master = ['127.0.0.1', 6379];
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->allows('_masters')->andReturn([$master]);
$client->expects('scan')->with(null, $master, '*', 10)
->andReturnUsing(function (&$cursor) use ($firstPage): array {
@@ -363,6 +367,7 @@ public function testItKeepsScanningWhenAMasterReturnsNoKeys(): void
{
$masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379]];
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->allows('_masters')->andReturn($masters);
$client->expects('scan')->with(null, $masters[0], '*', 10)
->andReturnUsing(function (&$cursor): array {
@@ -388,6 +393,7 @@ public function testItPreservesLargeStringCursors(): void
$master = ['127.0.0.1', 6379];
$largeCursor = '18446744073709551615';
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->allows('_masters')->andReturn([$master]);
$client->expects('scan')->with(null, $master, '*', 10)
->andReturnUsing(function (&$cursor) use ($largeCursor): array {
@@ -413,6 +419,7 @@ public function testItKeepsNodeAffinityWhenMastersAreReordered(): void
{
$masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379]];
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->expects('_masters')->twice()->andReturn($masters, array_reverse($masters));
foreach ($masters as $index => $master) {
@@ -436,6 +443,7 @@ public function testItContinuesWithAnotherMasterWhenTheCurrentMasterDisappears()
{
$masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379]];
$client = m::mock(RedisCluster::class);
+ $client->allows('getMode')->andReturn(Redis::ATOMIC);
$client->expects('_masters')->twice()->andReturn($masters, [$masters[1]]);
$client->expects('scan')->with(null, $masters[0], '*', 10)
->andReturnUsing(function (&$cursor): array {
diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php
index 6cdc5b9651..baa7ab32e7 100644
--- a/tests/Redis/RedisConnectionTest.php
+++ b/tests/Redis/RedisConnectionTest.php
@@ -6,6 +6,7 @@
use BadMethodCallException;
use Closure;
+use ErrorException;
use Hypervel\ConnectionPool\Events\ConnectionReleasing;
use Hypervel\ConnectionPool\Exceptions\ConnectionException;
use Hypervel\ConnectionPool\PoolOptions;
@@ -1579,10 +1580,10 @@ public function testClusterTransportFailureInvalidatesWithoutReplayingCommand():
$this->assertTrue($connection->isInvalidForTest());
}
- // REMOVED: Automatic read/write retries and configured command retries can replay committed commands.
+ // REMOVED: Automatic command retries and reopening pipelines/transactions can replay committed commands.
#[DataProvider('connectionFailureProvider')]
public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplayingCommand(
- RedisException|RedisClusterException $exception,
+ RedisException|RedisClusterException|ErrorException $exception,
string $command,
array $arguments,
bool $synchronized,
@@ -1592,7 +1593,9 @@ public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplaying
$this->expectDefaultConnectionOptions($failedClient);
$this->expectDefaultConnectionOptions($healthyClient);
$failedClient->expects($command)->once()->with(...$arguments)->andThrow($exception);
- $failedClient->expects('getLastError')->andReturn($synchronized ? $exception->getMessage() : null);
+ if (! $exception instanceof ErrorException) {
+ $failedClient->expects('getLastError')->andReturn($synchronized ? $exception->getMessage() : null);
+ }
$failedClient->shouldNotReceive('isConnected');
$healthyClient->expects('get')->once()->with('foo')->andReturn('bar');
@@ -1623,7 +1626,7 @@ protected function createRedis(array $config): Redis
try {
$connection->__call($command, $arguments);
$this->fail('Expected the command failure to propagate.');
- } catch (RedisException|RedisClusterException $throwable) {
+ } catch (RedisException|RedisClusterException|ErrorException $throwable) {
$this->assertSame($exception, $throwable);
}
@@ -1644,12 +1647,36 @@ public static function connectionFailureProvider(): array
'read-only replica' => [new RedisException('READONLY replica is read-only'), 'set', ['foo', 'bar'], true],
'disconnected replica' => [new RedisException('MASTERDOWN link is down'), 'get', ['foo'], true],
'read failure' => [new RedisException('Connection lost'), 'get', ['foo'], false],
+ 'warning-shaped reset' => [new ErrorException('Redis::get(): SSL: Connection reset by peer'), 'get', ['foo'], false],
+ 'stream write failure' => [new ErrorException('Redis::get(): Send of 30 bytes failed with errno=32 Broken pipe'), 'get', ['foo'], false],
+ 'TLS protocol failure' => [new ErrorException('Redis::get(): SSL operation failed with code 1. OpenSSL Error messages: bad length'), 'get', ['foo'], false],
+ 'transaction opening' => [new RedisException('Connection lost'), 'multi', [], false],
'non-idempotent write' => [new RedisException('Connection lost'), 'incr', ['foo'], false],
'write with options' => [new RedisException('Connection lost'), 'set', ['foo', 'bar', ['ex' => 60]], false],
'cluster response error' => [new RedisClusterException('Error processing response from Redis node!'), 'get', ['foo'], false],
];
}
+ public function testConnectionDoesNotRetryUnrelatedWarnings(): void
+ {
+ $exception = new ErrorException('Redis::get(): Undefined variable');
+ $redis = m::mock(Redis::class);
+ $redis->expects('get')->once()->with('foo')->andThrow($exception);
+ $redis->shouldNotReceive('getLastError');
+ $connection = new PhpRedisConnectionStub($this->getContainer(), $this->getMockedPool());
+ $connection->setActiveConnection($redis);
+
+ try {
+ $connection->__call('get', ['foo']);
+ $this->fail('Expected the unrelated warning to propagate.');
+ } catch (ErrorException $throwable) {
+ $this->assertSame($exception, $throwable);
+ }
+
+ $this->assertFalse($connection->isInvalidForTest());
+ $this->assertSame($redis, $connection->client());
+ }
+
#[DataProvider('synchronizedServerErrorDispositionProvider')]
public function testSynchronizedServerErrorDispositionDoesNotReplayCommand(
string $message,
@@ -2080,6 +2107,28 @@ public static function scanOptions(): array
];
}
+ public function testTransformedScanInvalidatesOnTransportFailure(): void
+ {
+ $exception = new RedisException('Connection lost');
+ $redis = m::mock(Redis::class);
+ $redis->expects('getMode')->andReturn(Redis::ATOMIC);
+ $redis->expects('scan')->once()->with(null, '*', 10)->andThrow($exception);
+ $redis->expects('getLastError')->andReturnNull();
+ $connection = new PhpRedisConnectionStub($this->getContainer(), $this->getMockedPool());
+ $connection->setActiveConnection($redis);
+ $connection->shouldTransform(true);
+ $cursor = null;
+
+ try {
+ $connection->scan($cursor);
+ $this->fail('Expected the scan failure to propagate.');
+ } catch (RedisException $throwable) {
+ $this->assertSame($exception, $throwable);
+ }
+
+ $this->assertTrue($connection->isInvalidForTest());
+ }
+
public function testScanWithOptions(): void
{
$connection = $this->mockRedisConnection(transform: true);
@@ -2952,6 +3001,27 @@ public function testEvalWithShaCacheThrowsOnNonNoscriptError(): void
$connection->evalWithShaCache($script, ['mykey']);
}
+ public function testEvalWithShaCacheInvalidatesAReadOnlyReplica(): void
+ {
+ $exception = new RedisException('READONLY replica is read-only');
+ $redis = m::mock(Redis::class);
+ $redis->expects('clearLastError')->once();
+ $redis->expects('evalSha')->once()->with(sha1('return 1'), [], 0)->andThrow($exception);
+ $redis->expects('getLastError')->andReturn($exception->getMessage());
+ $redis->shouldNotReceive('eval');
+ $connection = new PhpRedisConnectionStub($this->getContainer(), $this->getMockedPool());
+ $connection->setActiveConnection($redis);
+
+ try {
+ $connection->evalWithShaCache('return 1');
+ $this->fail('Expected the replica error to propagate.');
+ } catch (RedisException $throwable) {
+ $this->assertSame($exception, $throwable);
+ }
+
+ $this->assertTrue($connection->isInvalidForTest());
+ }
+
public function testEvalWithShaCacheReturnsLegitimatelyFalseResult(): void
{
$connection = $this->mockRedisConnection();
From 33c8e774ae09cbb7f0e02c1d576cbe8c530f945d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 08/24] Qualify soft-delete columns with query table aliases
Use the query alias when qualifying columns and applying soft-delete scopes, while retaining columns qualified for other tables. Port the builder, scope and aliased-query regressions.
Upstream: https://github.com/laravel/framework/pull/61456
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/database/src/Eloquent/Builder.php | 32 ++++++++++++++++++-
.../src/Eloquent/SoftDeletingScope.php | 8 ++---
.../Database/DatabaseEloquentBuilderTest.php | 15 +++++++++
...baseEloquentSoftDeletesIntegrationTest.php | 13 ++++++++
.../DatabaseSoftDeletingScopeTest.php | 3 ++
5 files changed, 66 insertions(+), 5 deletions(-)
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index 7ed161b531..148fc6d717 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -2015,6 +2015,16 @@ public function qualifyColumn(Expression|string $column): string
{
$column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column;
+ if (! is_null($alias = $this->getTableAlias())) {
+ if (! str_contains($column, '.')) {
+ return $alias . '.' . $column;
+ }
+
+ if (str_starts_with($column, $table = $this->model->getTable() . '.')) {
+ return $alias . '.' . substr($column, strlen($table));
+ }
+ }
+
return $this->model->qualifyColumn($column);
}
@@ -2023,7 +2033,27 @@ public function qualifyColumn(Expression|string $column): string
*/
public function qualifyColumns(array $columns): array
{
- return $this->model->qualifyColumns($columns);
+ $qualified = [];
+
+ foreach ($columns as $key => $column) {
+ $qualified[$key] = $this->qualifyColumn($column);
+ }
+
+ return $qualified;
+ }
+
+ /**
+ * Get the alias given to the query's table, if any.
+ */
+ protected function getTableAlias(): ?string
+ {
+ if (! is_string($this->query->from)) {
+ return null;
+ }
+
+ $segments = preg_split('/\s+as\s+/i', $this->query->from);
+
+ return count($segments) > 1 ? array_last($segments) : null;
}
/**
diff --git a/src/database/src/Eloquent/SoftDeletingScope.php b/src/database/src/Eloquent/SoftDeletingScope.php
index 454179ee92..998947c710 100644
--- a/src/database/src/Eloquent/SoftDeletingScope.php
+++ b/src/database/src/Eloquent/SoftDeletingScope.php
@@ -26,7 +26,7 @@ class SoftDeletingScope implements Scope
*/
public function apply(Builder $builder, Model $model): void
{
- $builder->whereNull($model->getQualifiedDeletedAtColumn());
+ $builder->whereNull($builder->qualifyColumn($model->getQualifiedDeletedAtColumn()));
}
/**
@@ -57,7 +57,7 @@ public function extend(Builder $builder): void
protected function getDeletedAtColumn(Builder $builder): string
{
if ((array) $builder->getQuery()->joins !== []) {
- return $builder->getModel()->getQualifiedDeletedAtColumn();
+ return $builder->qualifyColumn($builder->getModel()->getQualifiedDeletedAtColumn());
}
return $builder->getModel()->getDeletedAtColumn();
@@ -138,7 +138,7 @@ protected function addWithoutTrashed(Builder $builder): void
// @phpstan-ignore argument.type (local macros retain their lexical $this at runtime)
$builder->withoutGlobalScope($this)->whereNull(
- $model->getQualifiedDeletedAtColumn()
+ $builder->qualifyColumn($model->getQualifiedDeletedAtColumn())
);
return $builder;
@@ -157,7 +157,7 @@ protected function addOnlyTrashed(Builder $builder): void
// @phpstan-ignore argument.type (local macros retain their lexical $this at runtime)
$builder->withoutGlobalScope($this)->whereNotNull(
- $model->getQualifiedDeletedAtColumn()
+ $builder->qualifyColumn($model->getQualifiedDeletedAtColumn())
);
return $builder;
diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php
index 6810e47669..01151603b2 100755
--- a/tests/Database/DatabaseEloquentBuilderTest.php
+++ b/tests/Database/DatabaseEloquentBuilderTest.php
@@ -382,6 +382,21 @@ public function testQualifyColumns(): void
$this->assertEquals(['foo_table.column', 'foo_table.name'], $builder->qualifyColumns(['column', 'name']));
}
+ public function testQualifyColumnWithTableAlias(): void
+ {
+ $query = m::mock(BaseBuilder::class);
+ $query->expects('from')->with('foo_table');
+ $query->from = 'foo_table as alias';
+
+ $builder = new Builder($query);
+ $builder->setModel(new StubStringPrimaryKey);
+
+ $this->assertSame('alias.column', $builder->qualifyColumn('column'));
+ $this->assertSame('alias.column', $builder->qualifyColumn('foo_table.column'));
+ $this->assertSame('other_table.column', $builder->qualifyColumn('other_table.column'));
+ $this->assertEquals(['alias.column', 'alias.name'], $builder->qualifyColumns(['column', 'name']));
+ }
+
public function testGetMethodLoadsModelsAndHydratesEagerRelations(): void
{
$builder = m::mock(Builder::class . '[getModels,eagerLoadRelations]', [$this->getMockQueryBuilder()]);
diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php
index af7f5b3564..b963def1bb 100644
--- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php
+++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php
@@ -115,6 +115,19 @@ public function testSoftDeletesAreNotRetrieved()
$this->assertNull(User::find(1));
}
+ public function testSoftDeletesAreNotRetrievedWhenTableIsAliased(): void
+ {
+ $this->createUsers();
+
+ $users = User::from('users as u')->get();
+
+ $this->assertCount(1, $users);
+ $this->assertEquals(2, $users->first()->id);
+ $this->assertCount(2, User::from('users as u')->withTrashed()->get());
+ $this->assertCount(1, User::from('users as u')->withTrashed()->withoutTrashed()->get());
+ $this->assertEquals(1, User::from('users as u')->onlyTrashed()->first()->id);
+ }
+
public function testSoftDeletesAreNotRetrievedFromBaseQuery()
{
$this->createUsers();
diff --git a/tests/Database/DatabaseSoftDeletingScopeTest.php b/tests/Database/DatabaseSoftDeletingScopeTest.php
index 6d118b4953..59017cdc3c 100644
--- a/tests/Database/DatabaseSoftDeletingScopeTest.php
+++ b/tests/Database/DatabaseSoftDeletingScopeTest.php
@@ -22,6 +22,7 @@ public function testApplyingScopeToABuilder(): void
$builder = m::mock(EloquentBuilder::class);
$model = m::mock(Model::class);
$model->expects('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at');
+ $builder->expects('qualifyColumn')->with('table.deleted_at')->andReturn('table.deleted_at');
$builder->expects('whereNull')->with('table.deleted_at');
$scope->apply($builder, $model);
@@ -125,6 +126,7 @@ public function testOnlyTrashedExtension(): void
$givenBuilder->expects('getModel')->andReturn($model);
$givenBuilder->expects('withoutGlobalScope')->with($scope)->andReturn($givenBuilder);
$model->expects('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at');
+ $givenBuilder->expects('qualifyColumn')->with('table.deleted_at')->andReturn('table.deleted_at');
$givenBuilder->expects('whereNotNull')->with('table.deleted_at');
$result = $callback($givenBuilder);
@@ -146,6 +148,7 @@ public function testWithoutTrashedExtension(): void
$givenBuilder->expects('getModel')->andReturn($model);
$givenBuilder->expects('withoutGlobalScope')->with($scope)->andReturn($givenBuilder);
$model->expects('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at');
+ $givenBuilder->expects('qualifyColumn')->with('table.deleted_at')->andReturn('table.deleted_at');
$givenBuilder->expects('whereNull')->with('table.deleted_at');
$result = $callback($givenBuilder);
From 3730ca2d27a953ccd9e2a4ff631e2ef427c91fce Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 09/24] Complete native session ID handler methods
Add create_sid and validateId to supported session handlers. Cookie validation uses the coroutine-local request; Redis validation checks the prefixed session payload through its connection. Preserve Hypervel's dedicated Redis handler instead of introducing a shared-cache session handler. Include upstream ID tests and Redis existence coverage.
Upstream: https://github.com/laravel/framework/pull/61469
Upstream: https://github.com/laravel/framework/pull/61517
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/session/src/ArraySessionHandler.php | 17 +++++++++++++++
src/session/src/CookieSessionHandler.php | 17 +++++++++++++++
src/session/src/DatabaseSessionHandler.php | 17 +++++++++++++++
src/session/src/FileSessionHandler.php | 17 +++++++++++++++
src/session/src/NullSessionHandler.php | 17 +++++++++++++++
src/session/src/RedisSessionHandler.php | 18 ++++++++++++++++
tests/Session/ArraySessionHandlerTest.php | 19 ++++++++++++++++
tests/Session/FileSessionHandlerTest.php | 18 ++++++++++++++++
tests/Session/RedisSessionHandlerTest.php | 25 ++++++++++++++++++++++
9 files changed, 165 insertions(+)
diff --git a/src/session/src/ArraySessionHandler.php b/src/session/src/ArraySessionHandler.php
index 21530ae39c..812069e20c 100644
--- a/src/session/src/ArraySessionHandler.php
+++ b/src/session/src/ArraySessionHandler.php
@@ -5,6 +5,7 @@
namespace Hypervel\Session;
use Hypervel\Support\InteractsWithTime;
+use RuntimeException;
use SessionHandlerInterface;
class ArraySessionHandler implements SessionHandlerInterface
@@ -36,6 +37,22 @@ public function close(): bool
return true;
}
+ /**
+ * Create a new session ID.
+ */
+ public function create_sid(): string
+ {
+ return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.');
+ }
+
+ /**
+ * Determine if the session ID exists.
+ */
+ public function validateId(string $id): bool
+ {
+ return isset($this->storage[$id]);
+ }
+
public function read(string $sessionId): false|string
{
if (! isset($this->storage[$sessionId])) {
diff --git a/src/session/src/CookieSessionHandler.php b/src/session/src/CookieSessionHandler.php
index 848d5b1ad6..29c73ec737 100644
--- a/src/session/src/CookieSessionHandler.php
+++ b/src/session/src/CookieSessionHandler.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Cookie\QueueingFactory as CookieJar;
use Hypervel\Http\Request;
use Hypervel\Support\InteractsWithTime;
+use RuntimeException;
use SessionHandlerInterface;
class CookieSessionHandler implements SessionHandlerInterface
@@ -43,6 +44,22 @@ public function close(): bool
return true;
}
+ /**
+ * Create a new session ID.
+ */
+ public function create_sid(): string
+ {
+ return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.');
+ }
+
+ /**
+ * Determine if the session ID exists.
+ */
+ public function validateId(string $id): bool
+ {
+ return $this->getRequest()->cookies->has($id);
+ }
+
public function read(string $sessionId): false|string
{
$value = $this->getRequest()->cookies->get($sessionId) ?: '';
diff --git a/src/session/src/DatabaseSessionHandler.php b/src/session/src/DatabaseSessionHandler.php
index 20d43edb4c..61da5f9fb5 100644
--- a/src/session/src/DatabaseSessionHandler.php
+++ b/src/session/src/DatabaseSessionHandler.php
@@ -16,6 +16,7 @@
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Collection;
use Hypervel\Support\InteractsWithTime;
+use RuntimeException;
use SessionHandlerInterface;
class DatabaseSessionHandler implements CanManageUserSessions, ExistenceAwareInterface, SessionHandlerInterface
@@ -67,6 +68,22 @@ public function close(): bool
return true;
}
+ /**
+ * Create a new session ID.
+ */
+ public function create_sid(): string
+ {
+ return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.');
+ }
+
+ /**
+ * Determine if the session ID exists.
+ */
+ public function validateId(string $id): bool
+ {
+ return ! is_null($this->getQuery()->find($id));
+ }
+
public function read(string $sessionId): false|string
{
$session = (object) $this->getQuery()->find($sessionId);
diff --git a/src/session/src/FileSessionHandler.php b/src/session/src/FileSessionHandler.php
index 5076efffca..7e18b005a2 100644
--- a/src/session/src/FileSessionHandler.php
+++ b/src/session/src/FileSessionHandler.php
@@ -7,6 +7,7 @@
use Hypervel\Contracts\Filesystem\FileNotFoundException;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Support\CarbonImmutable;
+use RuntimeException;
use SessionHandlerInterface;
use Symfony\Component\Finder\Finder;
@@ -36,6 +37,22 @@ public function close(): bool
return true;
}
+ /**
+ * Create a new session ID.
+ */
+ public function create_sid(): string
+ {
+ return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.');
+ }
+
+ /**
+ * Determine if the session ID exists.
+ */
+ public function validateId(string $id): bool
+ {
+ return $this->files->isFile($this->path . '/' . $id);
+ }
+
public function read(string $sessionId): false|string
{
if ($this->files->isFile($path = $this->path . '/' . $sessionId)
diff --git a/src/session/src/NullSessionHandler.php b/src/session/src/NullSessionHandler.php
index 730aeb2b3b..23e6beb464 100644
--- a/src/session/src/NullSessionHandler.php
+++ b/src/session/src/NullSessionHandler.php
@@ -4,6 +4,7 @@
namespace Hypervel\Session;
+use RuntimeException;
use SessionHandlerInterface;
class NullSessionHandler implements SessionHandlerInterface
@@ -18,6 +19,22 @@ public function close(): bool
return true;
}
+ /**
+ * Create a new session ID.
+ */
+ public function create_sid(): string
+ {
+ return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.');
+ }
+
+ /**
+ * Determine if the session ID exists.
+ */
+ public function validateId(string $id): bool
+ {
+ return true;
+ }
+
public function read(string $sessionId): string
{
return '';
diff --git a/src/session/src/RedisSessionHandler.php b/src/session/src/RedisSessionHandler.php
index 21c4766c98..da19f97bfc 100644
--- a/src/session/src/RedisSessionHandler.php
+++ b/src/session/src/RedisSessionHandler.php
@@ -309,6 +309,24 @@ public function close(): bool
return true;
}
+ /**
+ * Create a new session ID.
+ */
+ public function create_sid(): string
+ {
+ return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.');
+ }
+
+ /**
+ * Determine if the session ID exists.
+ */
+ public function validateId(string $id): bool
+ {
+ return $this->withConnection(
+ fn (RedisConnection $connection): bool => $connection->exists($this->payloadKey($id)) > 0,
+ );
+ }
+
/**
* Read the session data.
*/
diff --git a/tests/Session/ArraySessionHandlerTest.php b/tests/Session/ArraySessionHandlerTest.php
index a6a1f1b38b..399238734d 100644
--- a/tests/Session/ArraySessionHandlerTest.php
+++ b/tests/Session/ArraySessionHandlerTest.php
@@ -20,6 +20,14 @@ public function testIsSessionHandlerInterface(): void
);
}
+ public function testItCreatesSessionIds(): void
+ {
+ $sessionId = (new ArraySessionHandler(10))->create_sid();
+
+ $this->assertIsString($sessionId);
+ $this->assertNotEmpty($sessionId);
+ }
+
public function testInitializeSession(): void
{
$handler = new ArraySessionHandler(10);
@@ -27,6 +35,17 @@ public function testInitializeSession(): void
$this->assertTrue($handler->open('', ''));
}
+ public function testItValidatesSessionIds(): void
+ {
+ $handler = new ArraySessionHandler(10);
+
+ $this->assertFalse($handler->validateId('foo'));
+
+ $handler->write('foo', 'bar');
+
+ $this->assertTrue($handler->validateId('foo'));
+ }
+
public function testCloseSession(): void
{
$handler = new ArraySessionHandler(10);
diff --git a/tests/Session/FileSessionHandlerTest.php b/tests/Session/FileSessionHandlerTest.php
index 733b34f54b..aa07018b53 100644
--- a/tests/Session/FileSessionHandlerTest.php
+++ b/tests/Session/FileSessionHandlerTest.php
@@ -40,6 +40,24 @@ public function testClose(): void
$this->assertTrue($this->sessionHandler->close());
}
+ public function testItCreatesSessionIds(): void
+ {
+ $sessionId = $this->sessionHandler->create_sid();
+
+ $this->assertIsString($sessionId);
+ $this->assertNotEmpty($sessionId);
+ }
+
+ public function testValidateIdChecksFileExists(): void
+ {
+ $sessionId = 'session_id';
+ $path = '/path/to/sessions/' . $sessionId;
+
+ $this->files->expects('isFile')->with($path)->andReturn(true);
+
+ $this->assertTrue($this->sessionHandler->validateId($sessionId));
+ }
+
public function testReadReturnsDataWhenFileExistsAndIsValid(): void
{
$sessionId = 'session_id';
diff --git a/tests/Session/RedisSessionHandlerTest.php b/tests/Session/RedisSessionHandlerTest.php
index 1008f59784..73be0f5e44 100644
--- a/tests/Session/RedisSessionHandlerTest.php
+++ b/tests/Session/RedisSessionHandlerTest.php
@@ -54,6 +54,31 @@ public function testLifecycleAndCapabilityMethodsDoNotUseRedis(): void
$this->assertTrue($tracked->supportsUserSessionManagement());
}
+ public function testItCreatesSessionIds(): void
+ {
+ $sessionId = $this->handler()->create_sid();
+
+ $this->assertIsString($sessionId);
+ $this->assertNotEmpty($sessionId);
+ }
+
+ #[DataProvider('sessionExistenceProvider')]
+ public function testValidateIdChecksRedis(int $exists, bool $expected): void
+ {
+ $connection = $this->expectConnection();
+ $connection->expects('exists')->with('sessions:' . self::SESSION_ID)->andReturn($exists);
+
+ $this->assertSame($expected, $this->handler()->validateId(self::SESSION_ID));
+ }
+
+ /**
+ * Provide Redis session existence results.
+ */
+ public static function sessionExistenceProvider(): array
+ {
+ return [[0, false], [1, true]];
+ }
+
#[DataProvider('storedPayloadProvider')]
public function testReadUnderstandsRawAndVersionedPayloads(string $stored, string $expected): void
{
From 8fe79b113c644557319c78535af0093d211cfbc2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 10/24] Support explicitly encoded route parameters
Add EncodedParameter and the upstream encodeParameter extension point. Preserve existing percent, delimiter and brace escaping for ordinary values and retain raw serving-route paths. Include percent round-trip and encoded-parameter regressions, signed URL coverage, consistent docblock wording and usage documentation.
Upstream: https://github.com/laravel/framework/pull/61475
Upstream: https://github.com/laravel/framework/pull/61610
Upstream: https://github.com/laravel/framework/pull/61631
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/docs/urls.md | 8 ++++
src/routing/src/EncodedParameter.php | 33 +++++++++++++++
src/routing/src/RouteUrlGenerator.php | 23 +++++++----
tests/Integration/Routing/UrlSigningTest.php | 26 ++++++++++++
tests/Routing/RoutingUrlGeneratorTest.php | 42 ++++++++++++++++++++
5 files changed, 125 insertions(+), 7 deletions(-)
create mode 100644 src/routing/src/EncodedParameter.php
diff --git a/src/docs/urls.md b/src/docs/urls.md
index 56a73333fa..a39a459440 100644
--- a/src/docs/urls.md
+++ b/src/docs/urls.md
@@ -209,6 +209,14 @@ echo route('post.show', ['post' => 1, 'search' => 'rocket']);
You may also pass enum cases as route or query parameters, including values in nested query arrays. Backed enums use their backing value, while unit enums use their case name.
+Route parameters should normally contain unencoded values. If a value is already URL-encoded, wrap it in an `EncodedParameter` to avoid encoding it again:
+
+```php
+use Hypervel\Routing\EncodedParameter;
+
+echo route('post.show', ['post' => new EncodedParameter('hello%20world')]);
+```
+
#### Eloquent Models
diff --git a/src/routing/src/EncodedParameter.php b/src/routing/src/EncodedParameter.php
new file mode 100644
index 0000000000..058cf66d19
--- /dev/null
+++ b/src/routing/src/EncodedParameter.php
@@ -0,0 +1,33 @@
+value;
+ }
+
+ /**
+ * Get the encoded parameter value.
+ */
+ public function __toString(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/src/routing/src/RouteUrlGenerator.php b/src/routing/src/RouteUrlGenerator.php
index 90c38d8eea..e5c72d9881 100644
--- a/src/routing/src/RouteUrlGenerator.php
+++ b/src/routing/src/RouteUrlGenerator.php
@@ -8,6 +8,7 @@
use Hypervel\Routing\Exceptions\UrlGenerationException;
use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
+use Stringable;
use function Hypervel\Support\enum_value;
@@ -42,7 +43,7 @@ class RouteUrlGenerator
public array $defaultParameters = [];
/**
- * Characters that should not be URL encoded.
+ * Characters that should not be URL-encoded.
*/
public array $dontEncode = [
'%2F' => '/',
@@ -372,7 +373,7 @@ protected function replaceRouteParameters(
return (! isset($parameters[0]) && ! str_ends_with($match[0], '?}'))
? $match[0]
- : $this->escapeParameterValue(Arr::pull($parameters, 0));
+ : $this->encodeParameter(Arr::pull($parameters, 0));
}, $path);
return trim(preg_replace('/\{.*?\?\}/', '', $path), '/');
@@ -388,12 +389,12 @@ protected function replaceNamedParameters(
): string {
return preg_replace_callback('/\{(.*?)(\?)?\}/', function ($m) use (&$parameters, $rootDefaultParameters) {
if (isset($parameters[$m[1]]) && $parameters[$m[1]] !== '') {
- return $this->escapeParameterValue(Arr::pull($parameters, $m[1]));
+ return $this->encodeParameter(Arr::pull($parameters, $m[1]));
}
if (isset($rootDefaultParameters[$m[1]])) {
Arr::pull($parameters, $m[1]);
- return $this->escapeParameterValue($rootDefaultParameters[$m[1]]);
+ return $this->encodeParameter($rootDefaultParameters[$m[1]]);
}
if (isset($parameters[$m[1]])) {
Arr::pull($parameters, $m[1]);
@@ -404,11 +405,19 @@ protected function replaceNamedParameters(
}
/**
- * Escape delimiters while the value is still known to be parameter data.
+ * Encode a parameter value that is being substituted into a route URI.
+ *
+ * Values wrapped in an EncodedParameter are already URL-encoded and are used as-is.
*/
- protected function escapeParameterValue(mixed $value): string
+ protected function encodeParameter(mixed $value): mixed
{
- return strtr((string) $value, self::PARAMETER_ESCAPES);
+ if ($value instanceof EncodedParameter) {
+ return $value->value();
+ }
+
+ return is_string($value) || $value instanceof Stringable
+ ? strtr((string) $value, self::PARAMETER_ESCAPES)
+ : $value;
}
/**
diff --git a/tests/Integration/Routing/UrlSigningTest.php b/tests/Integration/Routing/UrlSigningTest.php
index 8963548b6a..eb6e4ed16c 100644
--- a/tests/Integration/Routing/UrlSigningTest.php
+++ b/tests/Integration/Routing/UrlSigningTest.php
@@ -13,6 +13,7 @@
use Hypervel\Support\Facades\Route;
use Hypervel\Support\Facades\URL;
use Hypervel\Support\Uri;
+use Hypervel\Testing\TestResponse;
use Hypervel\Tests\Integration\Routing\RoutingTestCase;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\Response;
@@ -78,6 +79,31 @@ public function testSigningUrlWithCustomRouteSlug()
});
}
+ public function testSigningUrlWithPercentSignInRouteSlug(): void
+ {
+ Route::get('/foo/{post:slug}', function (Request $request, string $slug): array {
+ return ['slug' => $slug, 'valid' => $request->hasValidSignature() ? 'valid' : 'invalid'];
+ })->name('foo');
+
+ $model = new RoutableInterfaceStub;
+ $model->slug = '%66oo';
+
+ // The percent sign has to be escaped in the generated URL. Otherwise the router
+ // decodes "%66" back into an "f" when matching the URL and binds a different
+ // model than the one the URL was generated for...
+ $this->assertSame(
+ '/foo/%2566oo',
+ parse_url($url = URL::signedRoute('foo', ['post' => $model]), PHP_URL_PATH)
+ );
+
+ tap($this->get($url), function (TestResponse $response): void {
+ $this->assertSame('valid', $response->original['valid']);
+ $this->assertSame('%66oo', $response->original['slug']);
+
+ $this->assertSame('%66oo', $response->baseRequest->route('post'));
+ });
+ }
+
public function testTemporarySignedUrls(): void
{
Route::get('/foo/{id}', function (Request $request, $id) {
diff --git a/tests/Routing/RoutingUrlGeneratorTest.php b/tests/Routing/RoutingUrlGeneratorTest.php
index 918b2d3847..43f2a95828 100755
--- a/tests/Routing/RoutingUrlGeneratorTest.php
+++ b/tests/Routing/RoutingUrlGeneratorTest.php
@@ -9,6 +9,7 @@
use Hypervel\Contracts\Routing\UrlRoutable;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Http\Request;
+use Hypervel\Routing\EncodedParameter;
use Hypervel\Routing\Exceptions\UrlGenerationException;
use Hypervel\Routing\Route;
use Hypervel\Routing\RouteCollection;
@@ -1048,6 +1049,47 @@ public function testRouteNotDefinedException(): void
$url->route('not_exists_route');
}
+ public function testRouteParametersContainingPercentSignsAreEncoded(): void
+ {
+ $url = new UrlGenerator(
+ $routes = new RouteCollection,
+ $request = Request::create('http://www.foo.com/')
+ );
+
+ $routes->add(new Route(['GET'], 'foo/{bar}', ['as' => 'foo', function (): void {
+ }]));
+
+ // A raw percent sign would be decoded again when the router matches the URL,
+ // so the parameter has to survive a round trip through rawurldecode()...
+ $this->assertSame('http://www.foo.com/foo/%2566oo', $url->route('foo', ['bar' => '%66oo']));
+ $this->assertSame('http://www.foo.com/foo/100%25', $url->route('foo', ['bar' => '100%']));
+
+ $this->assertSame('%66oo', rawurldecode('%2566oo'));
+ $this->assertSame('100%', rawurldecode('100%25'));
+
+ // Values without a percent sign are unaffected...
+ $this->assertSame('http://www.foo.com/foo/bar', $url->route('foo', ['bar' => 'bar']));
+ $this->assertSame('http://www.foo.com/foo/1', $url->route('foo', ['bar' => 1]));
+ }
+
+ public function testEncodedRouteParametersAreNotEncodedAgain(): void
+ {
+ $url = new UrlGenerator(
+ $routes = new RouteCollection,
+ $request = Request::create('http://www.foo.com/')
+ );
+
+ $routes->add(new Route(['GET'], 'foo/{bar}', ['as' => 'foo', function (): void {
+ }]));
+
+ // Values that are already URL encoded may be marked as such so they are used as-is...
+ $this->assertSame('http://www.foo.com/foo/foo%20bar', $url->route('foo', ['bar' => new EncodedParameter('foo%20bar')]));
+ $this->assertSame('http://www.foo.com/foo/%66oo', $url->route('foo', ['bar' => new EncodedParameter('%66oo')]));
+
+ // While plain strings continue to be encoded...
+ $this->assertSame('http://www.foo.com/foo/foo%2520bar', $url->route('foo', ['bar' => 'foo%20bar']));
+ }
+
public function testSignedUrl()
{
$url = new UrlGenerator(
From 61b00134e22a847b08273080ba04d25a4f9359e6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 11/24] Retain Resend message IDs on the sent message
Set the transport message ID and attach the Resend header to the original message, rather than a temporary message copy. Extend the existing payload test with both upstream ID assertions.
Upstream: https://github.com/laravel/framework/pull/61476
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/mail/src/Transport/ResendTransport.php | 6 +++++-
tests/Mail/MailResendTransportTest.php | 8 +++++++-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/mail/src/Transport/ResendTransport.php b/src/mail/src/Transport/ResendTransport.php
index d3a20d5fca..5492f745c3 100644
--- a/src/mail/src/Transport/ResendTransport.php
+++ b/src/mail/src/Transport/ResendTransport.php
@@ -132,7 +132,11 @@ protected function doSend(SentMessage $message): void
$messageId = $result->id;
- $email->getHeaders()->addHeader('X-Resend-Email-ID', $messageId);
+ $message->setMessageId($messageId);
+
+ if ($message->getOriginalMessage() instanceof Message) {
+ $message->getOriginalMessage()->getHeaders()->addHeader('X-Resend-Email-ID', $messageId);
+ }
}
/**
diff --git a/tests/Mail/MailResendTransportTest.php b/tests/Mail/MailResendTransportTest.php
index d1e123f172..62e8c69703 100644
--- a/tests/Mail/MailResendTransportTest.php
+++ b/tests/Mail/MailResendTransportTest.php
@@ -67,7 +67,13 @@ public function testSend(): void
$client = m::mock(Client::class);
$client->emails = $emails;
- (new ResendTransport($client))->send($message);
+ $sentMessage = (new ResendTransport($client))->send($message);
+
+ $this->assertSame('resend-message-id', $sentMessage->getMessageId());
+ $this->assertSame(
+ 'resend-message-id',
+ $sentMessage->getOriginalMessage()->getHeaders()->get('X-Resend-Email-ID')?->getBodyAsString()
+ );
}
public function testSendError(): void
From 283ee53eacd13da23be228532a9e6984c5d85dc1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 12/24] Accept arrays in cache existence and removal methods
Check every requested key in has and delegate array removal to deleteMultiple. Keep enum-key normalization and existing tagged-cache restrictions, widen the repository contract consistently, and update generated facade annotations, tests and documentation.
Upstream: https://github.com/laravel/framework/pull/61466
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/cache/src/AnyModeTaggedCache.php | 2 +-
src/cache/src/Repository.php | 10 +++++++++-
src/contracts/src/Cache/Repository.php | 2 +-
src/docs/cache.md | 12 ++++++++++++
src/support/src/Facades/Cache.php | 2 +-
tests/Cache/CacheRepositoryTest.php | 20 ++++++++++++++++++++
6 files changed, 44 insertions(+), 4 deletions(-)
diff --git a/src/cache/src/AnyModeTaggedCache.php b/src/cache/src/AnyModeTaggedCache.php
index 2c435c03f9..16570e1466 100644
--- a/src/cache/src/AnyModeTaggedCache.php
+++ b/src/cache/src/AnyModeTaggedCache.php
@@ -126,7 +126,7 @@ public function pull(UnitEnum|string $key, mixed $default = null): mixed
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
- public function forget(UnitEnum|string $key): bool
+ public function forget(array|UnitEnum|string $key): bool
{
throw new BadMethodCallException(
'Cannot forget items via tags in any mode. Tags are for writing and flushing only. '
diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php
index 8b99d48938..9ef0aaaedf 100644
--- a/src/cache/src/Repository.php
+++ b/src/cache/src/Repository.php
@@ -105,6 +105,10 @@ public function __construct(Store $store, array $config = [])
*/
public function has(array|UnitEnum|string $key): bool
{
+ if (is_array($key)) {
+ return ! in_array(null, $this->many($key), true);
+ }
+
return ! is_null($this->get($key));
}
@@ -807,8 +811,12 @@ public function funnel(UnitEnum|string $name): ConcurrencyLimiterBuilder
/**
* Remove an item from the cache.
*/
- public function forget(UnitEnum|string $key): bool
+ public function forget(array|UnitEnum|string $key): bool
{
+ if (is_array($key)) {
+ return $this->deleteMultiple($key);
+ }
+
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
if ($this->events?->hasListeners(ForgettingKey::class)) {
diff --git a/src/contracts/src/Cache/Repository.php b/src/contracts/src/Cache/Repository.php
index 3999bd3482..bd46871b80 100644
--- a/src/contracts/src/Cache/Repository.php
+++ b/src/contracts/src/Cache/Repository.php
@@ -180,7 +180,7 @@ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int $
/**
* Remove an item from the cache.
*/
- public function forget(UnitEnum|string $key): bool;
+ public function forget(array|UnitEnum|string $key): bool;
/**
* Get the cache store implementation.
diff --git a/src/docs/cache.md b/src/docs/cache.md
index f88fd34100..5371f81c94 100644
--- a/src/docs/cache.md
+++ b/src/docs/cache.md
@@ -401,6 +401,12 @@ if (Cache::has('key')) {
}
```
+Pass an array of keys to check that every item exists and has a non-null value:
+
+```php
+Cache::has(['first', 'second']);
+```
+
#### Incrementing / Decrementing Values
@@ -569,6 +575,12 @@ You may remove items from the cache using the `forget` method:
Cache::forget('key');
```
+You may pass an array of keys to remove multiple items. The method returns `true` only if every removal succeeds:
+
+```php
+Cache::forget(['first', 'second']);
+```
+
You may also remove items by providing a zero or negative number of expiration seconds:
```php
diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php
index 6256caa6ff..8812171234 100644
--- a/src/support/src/Facades/Cache.php
+++ b/src/support/src/Facades/Cache.php
@@ -37,7 +37,7 @@
* @method static void flushMacros()
* @method static void flushState()
* @method static bool forever(\UnitEnum|string $key, mixed $value)
- * @method static bool forget(\UnitEnum|string $key)
+ * @method static bool forget(\UnitEnum|array|string $key)
* @method static \Hypervel\Cache\Limiters\ConcurrencyLimiterBuilder funnel(\UnitEnum|string $name)
* @method static mixed get(\UnitEnum|array|string $key, mixed $default = null)
* @method static int|null getDefaultCacheTime()
diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php
index 66b800f7e0..efa73498f3 100644
--- a/tests/Cache/CacheRepositoryTest.php
+++ b/tests/Cache/CacheRepositoryTest.php
@@ -185,6 +185,16 @@ public function testHasMethod()
$this->assertTrue($repo->has('baz'));
}
+ public function testHasMethodWithArray(): void
+ {
+ $repo = $this->getRepository();
+ $repo->getStore()->expects('many')->with(['foo', 'bar'])->andReturn(['foo' => 'foo', 'bar' => 'bar']);
+ $repo->getStore()->expects('many')->with(['foo', 'baz'])->andReturn(['foo' => 'foo', 'baz' => null]);
+
+ $this->assertTrue($repo->has(['foo', TestCacheKey::Bar]));
+ $this->assertFalse($repo->has(['foo', 'baz']));
+ }
+
public function testMissingMethod()
{
$repo = $this->getRepository();
@@ -961,6 +971,15 @@ public function testForgettingCacheKey(): void
$repo->forget('a-key');
}
+ public function testForgettingWithArray(): void
+ {
+ $repo = $this->getRepository();
+ $repo->getStore()->expects('forget')->with('foo')->andReturn(true);
+ $repo->getStore()->expects('forget')->with('bar')->andReturn(false);
+
+ $this->assertFalse($repo->forget(['foo', TestCacheKey::Bar]));
+ }
+
public function testRemovingCacheKey(): void
{
// Alias of Forget
@@ -1865,4 +1884,5 @@ protected static function getTestDate(): string
enum TestCacheKey: string
{
case Foo = 'foo';
+ case Bar = 'bar';
}
From 0bb82b6236eeb671749b5ef40be53129fb81abca Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 13/24] Type console completion results as lists
Describe askWithCompletion callbacks as returning lists of strings, including the forwarding anticipate API. Runtime signatures and completion behavior are unchanged.
Upstream: https://github.com/laravel/framework/pull/61487
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/console/src/Concerns/InteractsWithIO.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/console/src/Concerns/InteractsWithIO.php b/src/console/src/Concerns/InteractsWithIO.php
index 3a57f3a295..55fc3ce60b 100644
--- a/src/console/src/Concerns/InteractsWithIO.php
+++ b/src/console/src/Concerns/InteractsWithIO.php
@@ -146,7 +146,7 @@ public function ask(string $question, ?string $default = null): mixed
/**
* Prompt the user for input with auto completion.
*
- * @param (callable(string): string[])|iterable $choices
+ * @param (callable(string): list)|iterable $choices
*/
public function anticipate(string $question, iterable|callable $choices, ?string $default = null): mixed
{
@@ -156,7 +156,7 @@ public function anticipate(string $question, iterable|callable $choices, ?string
/**
* Prompt the user for input with auto completion.
*
- * @param (callable(string): string[])|iterable $choices
+ * @param (callable(string): list)|iterable $choices
*/
public function askWithCompletion(string $question, iterable|callable $choices, ?string $default = null): mixed
{
From 7aa51efa4958fb8b5e52cb6147ef0030bedac258 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 14/24] Compare schema index names without case differences
Normalize requested index names to match the normalized metadata returned by schema processors. Add the upstream mixed-case index regression without changing column-list matching.
Upstream: https://github.com/laravel/framework/pull/61506
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/database/src/Schema/Builder.php | 2 ++
tests/Integration/Database/SchemaBuilderTest.php | 11 +++++++++++
2 files changed, 13 insertions(+)
diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php
index 4974638f48..853ca35da8 100755
--- a/src/database/src/Schema/Builder.php
+++ b/src/database/src/Schema/Builder.php
@@ -420,6 +420,8 @@ public function hasIndex(string $table, array|string $index, ?string $type = nul
{
$type = is_null($type) ? $type : strtolower($type);
+ $index = is_string($index) ? strtolower($index) : $index;
+
foreach ($this->getIndexes($table) as $value) {
$typeMatches = is_null($type)
|| ($type === 'primary' && $value['primary'])
diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php
index b2a2571d64..180a13213d 100644
--- a/tests/Integration/Database/SchemaBuilderTest.php
+++ b/tests/Integration/Database/SchemaBuilderTest.php
@@ -467,6 +467,17 @@ public function testGetPartialIndexMetadata(): void
], collect($indexes)->firstWhere('name', 'partial_index_records_email_unique'));
}
+ public function testHasIndexNameIsCaseInsensitive(): void
+ {
+ Schema::create('foo', function (Blueprint $table): void {
+ $table->string('bar')->index('IDX_MyIndex');
+ });
+
+ $this->assertTrue(Schema::hasIndex('foo', 'IDX_MyIndex'));
+ $this->assertTrue(Schema::hasIndex('foo', 'idx_myindex'));
+ $this->assertSame('idx_myindex', Schema::getIndexes('foo')[0]['name']);
+ }
+
public function testGetUniqueIndexes()
{
Schema::create('foo', function (Blueprint $table) {
From 7ae3a3c57bd6063d0ee62d67e69ab104ae7e6157 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 15/24] Handle null collection matches and expose higher-order
sole
Use array_any for eager strict predicate matching and a sentinel for lazy matching so a matching null value is not confused with no match. Register higher-order sole and its generic annotation. Port the upstream cases for both collection types and update the higher-order message documentation.
Upstream: https://github.com/laravel/framework/pull/61507
Upstream: https://github.com/laravel/framework/pull/61667
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/collections/src/Collection.php | 2 +-
src/collections/src/LazyCollection.php | 4 +++-
src/collections/src/Traits/EnumeratesValues.php | 2 ++
src/docs/collections.md | 2 +-
tests/Support/SupportCollectionTest.php | 17 ++++++++++++++++-
5 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php
index 3cf879fdba..42df2837fc 100644
--- a/src/collections/src/Collection.php
+++ b/src/collections/src/Collection.php
@@ -226,7 +226,7 @@ public function containsStrict(mixed $key, mixed $value = null): bool
}
if ($this->useAsCallable($key)) {
- return ! is_null($this->first($key));
+ return array_any($this->items, $key);
}
return in_array($key, $this->items, true);
diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php
index 16e56150dd..b8243ea8e5 100644
--- a/src/collections/src/LazyCollection.php
+++ b/src/collections/src/LazyCollection.php
@@ -318,7 +318,9 @@ public function containsStrict(mixed $key, mixed $value = null): bool
}
if ($this->useAsCallable($key)) {
- return ! is_null($this->first($key));
+ $placeholder = new stdClass;
+
+ return $this->first($key, $placeholder) !== $placeholder;
}
foreach ($this as $item) {
diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php
index 2e7031f563..413bda172c 100644
--- a/src/collections/src/Traits/EnumeratesValues.php
+++ b/src/collections/src/Traits/EnumeratesValues.php
@@ -50,6 +50,7 @@
* @property-read HigherOrderCollectionProxy<'reject', TValue, static> $reject
* @property-read HigherOrderCollectionProxy<'skipUntil', TValue, static> $skipUntil
* @property-read HigherOrderCollectionProxy<'skipWhile', TValue, static> $skipWhile
+ * @property-read HigherOrderCollectionProxy<'sole', TValue, static> $sole
* @property-read HigherOrderCollectionProxy<'some', TValue, static> $some
* @property-read HigherOrderCollectionProxy<'sortBy', TValue, static> $sortBy
* @property-read HigherOrderCollectionProxy<'sortByDesc', TValue, static> $sortByDesc
@@ -96,6 +97,7 @@ trait EnumeratesValues
'reject',
'skipUntil',
'skipWhile',
+ 'sole',
'some',
'sortBy',
'sortByDesc',
diff --git a/src/docs/collections.md b/src/docs/collections.md
index 3c10d81d27..ed089b26e9 100644
--- a/src/docs/collections.md
+++ b/src/docs/collections.md
@@ -4305,7 +4305,7 @@ $zipped->all();
## Higher Order Messages
-Collections also provide support for "higher order messages", which are short-cuts for performing common actions on collections. The collection methods that provide higher order messages are: [average](#method-average), [avg](#method-avg), [contains](#method-contains), [each](#method-each), [every](#method-every), [filter](#method-filter), [first](#method-first), [flatMap](#method-flatmap), [groupBy](#method-groupby), [keyBy](#method-keyby), [map](#method-map), [max](#method-max), [min](#method-min), [partition](#method-partition), [reject](#method-reject), [skipUntil](#method-skipuntil), [skipWhile](#method-skipwhile), [some](#method-some), [sortBy](#method-sortby), [sortByDesc](#method-sortbydesc), [sum](#method-sum), [takeUntil](#method-takeuntil), [takeWhile](#method-takewhile), and [unique](#method-unique).
+Collections also provide support for "higher order messages", which are short-cuts for performing common actions on collections. The collection methods that provide higher order messages are: [average](#method-average), [avg](#method-avg), [contains](#method-contains), [each](#method-each), [every](#method-every), [filter](#method-filter), [first](#method-first), [flatMap](#method-flatmap), [groupBy](#method-groupby), [keyBy](#method-keyby), [map](#method-map), [max](#method-max), [min](#method-min), [partition](#method-partition), [reject](#method-reject), [skipUntil](#method-skipuntil), [skipWhile](#method-skipwhile), [sole](#method-sole), [some](#method-some), [sortBy](#method-sortby), [sortByDesc](#method-sortbydesc), [sum](#method-sum), [takeUntil](#method-takeuntil), [takeWhile](#method-takewhile), and [unique](#method-unique).
Each higher order message can be accessed as a dynamic property on a collection instance. For instance, let's use the `each` higher order message to call a method on each object within a collection:
diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php
index 921445907a..3cd6be9c8b 100644
--- a/tests/Support/SupportCollectionTest.php
+++ b/tests/Support/SupportCollectionTest.php
@@ -1097,6 +1097,17 @@ public function active()
$this->assertCount(1, $c->filter->active());
}
+ #[DataProvider('collectionClassProvider')]
+ public function testHigherOrderSole(string $collection): void
+ {
+ $c = new $collection([
+ new TestSupportCollectionHigherOrderItem('Adam'),
+ new TestSupportCollectionHigherOrderItem('Taylor'),
+ ]);
+
+ $this->assertSame('Taylor', $c->sole->is('Taylor')->name);
+ }
+
#[DataProvider('collectionClassProvider')]
public function testWhere(string $collection): void
{
@@ -4329,8 +4340,12 @@ public function testSome($collection): void
}
#[DataProvider('collectionClassProvider')]
- public function testContainsStrict($collection): void
+ public function testContainsStrict(string $collection): void
{
+ $c = new $collection([1, null, 2]);
+ $this->assertTrue($c->containsStrict(fn (mixed $value): bool => is_null($value)));
+ $this->assertFalse($c->containsStrict(fn (mixed $value): bool => $value === 0));
+
$c = new $collection([1, 3, 5, '02']);
$this->assertTrue($c->containsStrict(1));
From dccfea47ada69006d19dd6eb5919e15fd8d929b7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:52 +0000
Subject: [PATCH 16/24] Cover truncated UTF-8 in binary detection
Add the deterministic incomplete UTF-8 sequence from upstream. Retain the existing regression proving that binary UUID bytes can also be valid UTF-8.
Upstream: https://github.com/laravel/framework/pull/61512
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
tests/Support/SupportBinaryCodecTest.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/Support/SupportBinaryCodecTest.php b/tests/Support/SupportBinaryCodecTest.php
index 0f2aa172f0..f36d4c0661 100644
--- a/tests/Support/SupportBinaryCodecTest.php
+++ b/tests/Support/SupportBinaryCodecTest.php
@@ -244,6 +244,7 @@ public function testIsBinary(): void
// Invalid UTF-8 sequences
$this->assertTrue(BinaryCodec::isBinary("\xFF\xFE"));
+ $this->assertTrue(BinaryCodec::isBinary("\xE3\x81"));
// Binary identifier bytes can still look like text
$this->assertFalse(BinaryCodec::isBinary(Uuid::fromString(self::UTF8_SAFE_BINARY_UUID)->toBinary()));
From 538e5c9ebef0d58d03a99eb5af8ef70284ef2864 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:53 +0000
Subject: [PATCH 17/24] Return no factory attributes for a zero count
Return an empty array when a counted factory requests fewer than one item, instead of constructing values from range(1, 0). Preserve the uncounted single-record path and add the upstream zero-count regression.
Upstream: https://github.com/laravel/framework/pull/61510
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/database/src/Eloquent/Factories/Factory.php | 4 ++++
tests/Database/DatabaseEloquentFactoryTest.php | 7 +++++++
2 files changed, 11 insertions(+)
diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php
index f6dbe2feee..1e069364a0 100644
--- a/src/database/src/Eloquent/Factories/Factory.php
+++ b/src/database/src/Eloquent/Factories/Factory.php
@@ -209,6 +209,10 @@ public function raw(callable|array $attributes = [], ?Model $parent = null): arr
return $this->state($attributes)->getExpandedAttributes($parent);
}
+ if ($this->count < 1) {
+ return [];
+ }
+
return array_map(function () use ($attributes, $parent) {
return $this->state($attributes)->getExpandedAttributes($parent);
}, range(1, $this->count));
diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php
index ad15ea580d..4af34f0127 100644
--- a/tests/Database/DatabaseEloquentFactoryTest.php
+++ b/tests/Database/DatabaseEloquentFactoryTest.php
@@ -289,6 +289,13 @@ public function testMultipleModelAttributesCanBeCreated()
$this->assertCount(10, $posts);
}
+ public function testModelAttributesCanBeCreatedWithACountOfZero(): void
+ {
+ $posts = PostFactory::new()->count(0)->raw();
+
+ $this->assertSame([], $posts);
+ }
+
public function testAfterCreatingAndMakingCallbacksAreCalled()
{
$user = UserFactory::new()
From bf3e36554a010c63857ae4d124fe3914bd9ca891 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 01:59:53 +0000
Subject: [PATCH 18/24] Add pool-safe cross-disk file transfers
Add copyToDisk and moveToDisk for named, enum and filesystem-instance destinations. Stream directly from ordinary sources; spool leased streams into bounded-memory temporary storage and release the source lease before destination writes. Keep source files after failed writes. Resolve scoped sources once, preserve same-disk/path guards through Sentry decorators, and record destination paths and named disks in spans. Include all upstream cases plus pool-capacity, cleanup, scoping and tracing regressions, generated facade annotations and temporary-space guidance.
Upstream: https://github.com/laravel/framework/pull/61511
Upstream: https://github.com/laravel/framework/pull/61519
Framework source: 7068848dfe48fc3a433598e09ce798799d442a52.
Documentation source: laravel/docs 13.x at ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc.
Validated with affected tests, repository formatting, full source and type-fixture analysis, and the full parallel test suite.
---
src/docs/filesystem.md | 14 ++-
.../InteractsWithPooledFilesystem.php | 1 +
.../src/Concerns/TransfersFiles.php | 77 ++++++++++++++++
src/filesystem/src/FilesystemAdapter.php | 2 +
src/filesystem/src/ScopedFilesystemProxy.php | 49 +++++++++++
.../Features/Storage/FilesystemDecorator.php | 42 +++++++++
src/support/src/Facades/Storage.php | 2 +
.../Filesystem/ClientPooledFilesystemTest.php | 45 ++++++++++
tests/Filesystem/FilesystemAdapterTest.php | 87 +++++++++++++++++++
tests/Filesystem/FilesystemPoolProxyTest.php | 21 +++++
.../Filesystem/ScopedFilesystemProxyTest.php | 60 +++++++++++++
.../Features/StorageIntegrationTest.php | 69 +++++++++++++++
12 files changed, 468 insertions(+), 1 deletion(-)
create mode 100644 src/filesystem/src/Concerns/TransfersFiles.php
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index 1ea6e9d732..6f67b06c93 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -373,7 +373,7 @@ New files and directory listings use the primary disk. URLs, file existence chec
To scope a read-through disk per request or tenant, wrap it in `ScopedCloudFilesystemProxy`. Dynamic scoped proxies cannot be used as its primary or fallback disk.
-Fallback reads promote files by default. Set `copy` to `false` to read fallback files without copying them. With promotion enabled, fallback stream reads finish copying the file before returning the stream.
+Fallback reads promote files by default. Set `copy` to `false` to read fallback files without copying them. With promotion enabled, fallback stream reads finish copying the file before returning the stream. Stream promotion and fallback copy/move operations buffer up to 2 MB in memory, then use PHP's system temporary directory; allow enough temporary disk space for large files and concurrent operations.
Promoted files use the primary disk's default visibility rather than inheriting the fallback file's visibility. Fallback copy and move operations use the same default. Configure a private primary disk when migrating private files.
@@ -766,6 +766,18 @@ Storage::copy('old/file.jpg', 'new/file.jpg');
Storage::move('old/file.jpg', 'new/file.jpg');
```
+You may use the `copyToDisk` and `moveToDisk` methods to copy or move a file to another disk. The source file's path will be used on the destination disk unless you provide a third argument:
+
+```php
+Storage::disk('local')->copyToDisk('s3', 'reports/report.csv');
+
+Storage::disk('local')->moveToDisk(
+ 's3', 'reports/report.csv', 'archive/report.csv'
+);
+```
+
+Transfers from pooled disks, including S3 and Google Cloud Storage, buffer the source before writing to the destination so the source's pool slot is available during the write. Buffering keeps up to 2 MB in memory per transfer, then uses PHP's system temporary directory; allow enough temporary disk space for large files and concurrent transfers. Local sources stream directly.
+
### Automatic Streaming
diff --git a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php
index 2d98031201..6c77c80675 100644
--- a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php
+++ b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php
@@ -28,6 +28,7 @@
trait InteractsWithPooledFilesystem
{
use Conditionable;
+ use TransfersFiles;
protected ?Closure $serveCallback = null;
diff --git a/src/filesystem/src/Concerns/TransfersFiles.php b/src/filesystem/src/Concerns/TransfersFiles.php
new file mode 100644
index 0000000000..53cccaf455
--- /dev/null
+++ b/src/filesystem/src/Concerns/TransfersFiles.php
@@ -0,0 +1,77 @@
+make(FilesystemFactory::class)->disk($disk);
+
+ if ($destination === $this && ($to ?? $from) === $from) {
+ throw new InvalidArgumentException('Cannot copy a file to the same disk and path.');
+ }
+
+ return $this->transferFile($this, $from, $destination, $to ?? $from);
+ }
+
+ /**
+ * Move a file to another disk.
+ */
+ public function moveToDisk(FilesystemContract|string|UnitEnum $disk, string $from, ?string $to = null): bool
+ {
+ return $this->copyToDisk($disk, $from, $to) && $this->delete($from);
+ }
+
+ /**
+ * Transfer a file without holding a source lease during the destination write.
+ */
+ protected function transferFile(FilesystemContract $source, string $from, FilesystemContract $destination, string $to): bool
+ {
+ $stream = $source->readStream($from);
+
+ if (! is_resource($stream)) {
+ return false;
+ }
+
+ $temporary = null;
+
+ try {
+ if ((stream_get_meta_data($stream)['wrapper_data'] ?? null) instanceof LeasedStream) {
+ // The destination may borrow from the same bounded pool as the source.
+ $temporary = fopen('php://temp', 'w+b');
+
+ if ($temporary === false || stream_copy_to_stream($stream, $temporary) === false || ! rewind($temporary)) {
+ throw UnableToReadFile::fromLocation($from);
+ }
+
+ fclose($stream);
+ }
+
+ return $destination->writeStream($to, $temporary ?? $stream);
+ } finally {
+ if (is_resource($stream)) {
+ fclose($stream);
+ }
+
+ if (is_resource($temporary)) {
+ fclose($temporary);
+ }
+ }
+ }
+}
diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php
index b86abc813a..3351b0372f 100644
--- a/src/filesystem/src/FilesystemAdapter.php
+++ b/src/filesystem/src/FilesystemAdapter.php
@@ -14,6 +14,7 @@
use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Contracts\Filesystem\Cloud as CloudFilesystemContract;
use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract;
+use Hypervel\Filesystem\Concerns\TransfersFiles;
use Hypervel\Http\File;
use Hypervel\Http\Request;
use Hypervel\Http\UploadedFile;
@@ -61,6 +62,7 @@
class FilesystemAdapter implements CloudFilesystemContract
{
use Conditionable;
+ use TransfersFiles;
use Macroable {
__call as macroCall;
}
diff --git a/src/filesystem/src/ScopedFilesystemProxy.php b/src/filesystem/src/ScopedFilesystemProxy.php
index f808d983b3..29b9902f78 100644
--- a/src/filesystem/src/ScopedFilesystemProxy.php
+++ b/src/filesystem/src/ScopedFilesystemProxy.php
@@ -7,18 +7,23 @@
use BadMethodCallException;
use Closure;
use DateTimeInterface;
+use Hypervel\Container\Container;
+use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory;
use Hypervel\Contracts\Filesystem\Filesystem;
+use Hypervel\Filesystem\Concerns\TransfersFiles;
use Hypervel\Http\File;
use Hypervel\Http\Request;
use Hypervel\Http\UploadedFile;
use Hypervel\Image\Image;
use Hypervel\Image\ImageException;
use Hypervel\Support\Traits\Conditionable;
+use InvalidArgumentException;
use League\Flysystem\PathNormalizer;
use League\Flysystem\WhitespacePathNormalizer;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
+use UnitEnum;
/**
* Isolate every filesystem path behind a dynamically resolved prefix on a
@@ -33,6 +38,7 @@
class ScopedFilesystemProxy implements Filesystem
{
use Conditionable;
+ use TransfersFiles;
protected PathNormalizer $normalizer;
@@ -466,6 +472,49 @@ public function move(string $from, string $to): bool
]);
}
+ /**
+ * Copy a scoped file to another disk.
+ */
+ public function copyToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool
+ {
+ return $this->transferToDisk($disk, $from, $to, false);
+ }
+
+ /**
+ * Move a scoped file to another disk.
+ */
+ public function moveToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool
+ {
+ return $this->transferToDisk($disk, $from, $to, true);
+ }
+
+ /**
+ * Transfer a file using one resolved source disk and prefix.
+ */
+ protected function transferToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to, bool $move): bool
+ {
+ $destination = $disk instanceof Filesystem
+ ? $disk
+ : Container::getInstance()->make(FilesystemFactory::class)->disk($disk);
+ $to ??= $from;
+
+ if ($destination === $this && $to === $from) {
+ throw new InvalidArgumentException('Cannot copy a file to the same disk and path.');
+ }
+
+ $prefix = $this->prefix();
+ $source = $this->resolveDisk();
+ $from = $this->applyPrefix($prefix, $from);
+
+ if ($destination === $this) {
+ $destination = $source;
+ $to = $this->applyPrefix($prefix, $to);
+ }
+
+ return $this->transferFile($source, $from, $destination, $to)
+ && (! $move || $source->delete($from));
+ }
+
/**
* Get the size of a scoped file.
*/
diff --git a/src/sentry/src/Features/Storage/FilesystemDecorator.php b/src/sentry/src/Features/Storage/FilesystemDecorator.php
index 7b1937f0ed..34ae436570 100644
--- a/src/sentry/src/Features/Storage/FilesystemDecorator.php
+++ b/src/sentry/src/Features/Storage/FilesystemDecorator.php
@@ -4,6 +4,8 @@
namespace Hypervel\Sentry\Features\Storage;
+use Hypervel\Container\Container;
+use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory;
use Hypervel\Contracts\Filesystem\Filesystem;
use Hypervel\Contracts\ObjectPool\InvalidatesPool;
use Hypervel\Http\File;
@@ -13,7 +15,9 @@
use Psr\Http\Message\StreamInterface;
use Sentry\Breadcrumb;
use Sentry\Tracing\SpanContext;
+use UnitEnum;
+use function Hypervel\Support\enum_value;
use function Sentry\trace;
/**
@@ -225,6 +229,44 @@ public function move(string $from, string $to): bool
return $this->withSentry(__FUNCTION__, func_get_args(), sprintf('from "%s" to "%s"', $from, $to), compact('from', 'to'));
}
+ /**
+ * Copy a file to another disk.
+ */
+ public function copyToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool
+ {
+ return $this->transferToDisk(__FUNCTION__, $disk, $from, $to);
+ }
+
+ /**
+ * Move a file to another disk.
+ */
+ public function moveToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool
+ {
+ return $this->transferToDisk(__FUNCTION__, $disk, $from, $to);
+ }
+
+ /**
+ * Trace a transfer while preserving the wrapped disk's identity check.
+ */
+ protected function transferToDisk(string $method, Filesystem|string|UnitEnum $disk, string $from, ?string $to): bool
+ {
+ $destination = $disk instanceof Filesystem
+ ? $disk
+ : Container::getInstance()->make(FilesystemFactory::class)->disk($disk);
+ $data = ['from' => $from, 'to' => $to ?? $from];
+
+ if (! $disk instanceof Filesystem) {
+ $data['destination_disk'] = enum_value($disk);
+ }
+
+ return $this->withSentry(
+ $method,
+ [$destination === $this ? $this->filesystem : $destination, $from, $to],
+ sprintf('from "%s" to "%s"', $from, $to ?? $from),
+ $data,
+ );
+ }
+
/**
* Get the file size of a given file.
*/
diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php
index 5d40705f85..39f9371ca7 100644
--- a/src/support/src/Facades/Storage.php
+++ b/src/support/src/Facades/Storage.php
@@ -69,6 +69,7 @@
* @method static void buildTemporaryUploadUrlsUsing(\Closure|null $callback)
* @method static void buildTemporaryUrlsUsing(\Closure|null $callback)
* @method static string|false checksum(string $path, array $options = [])
+ * @method static bool copyToDisk(\Hypervel\Contracts\Filesystem\Filesystem|UnitEnum|string $disk, string $from, string|null $to = null)
* @method static bool directoryExists(string $path)
* @method static bool directoryMissing(string $path)
* @method static \Symfony\Component\HttpFoundation\StreamedResponse download(string $path, string|null $name = null, array $headers = [])
@@ -79,6 +80,7 @@
* @method static array|string|int|float|bool|null json(string $path, int $flags = 0)
* @method static string|false mimeType(string $path)
* @method static bool missing(string $path)
+ * @method static bool moveToDisk(\Hypervel\Contracts\Filesystem\Filesystem|UnitEnum|string $disk, string $from, string|null $to = null)
* @method static bool providesTemporaryUploadUrls()
* @method static bool providesTemporaryUrls()
* @method static \Symfony\Component\HttpFoundation\StreamedResponse response(string $path, string|null $name = null, array $headers = [], string $disposition = 'inline')
diff --git a/tests/Filesystem/ClientPooledFilesystemTest.php b/tests/Filesystem/ClientPooledFilesystemTest.php
index d78ae8f2f9..6b17cd6b61 100644
--- a/tests/Filesystem/ClientPooledFilesystemTest.php
+++ b/tests/Filesystem/ClientPooledFilesystemTest.php
@@ -10,6 +10,7 @@
use Hypervel\Container\Container;
use Hypervel\Context\RequestContext;
use Hypervel\Contracts\Debug\ExceptionHandler;
+use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract;
use Hypervel\Contracts\ObjectPool\Factory;
use Hypervel\Contracts\ObjectPool\InvalidatesPool;
use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract;
@@ -70,6 +71,50 @@ protected function tearDown(): void
parent::tearDown();
}
+ public function testCrossDiskTransfersReleaseSharedClientBeforeWriting(): void
+ {
+ $definition = new PoolDefinition('filesystem:transfer', 'local-client', 'shared', PoolOptions::fromArray([
+ 'max_objects' => 1,
+ 'wait_timeout' => 0.02,
+ ]));
+ $makeDisk = fn (string $root): ClientPooledFilesystem => new ClientPooledFilesystem(
+ $definition,
+ static fn (): object => new stdClass,
+ static function (object $client) use ($root): FilesystemAdapter {
+ $adapter = new LocalFilesystemAdapter($root);
+
+ return new FilesystemAdapter(new Filesystem($adapter), $adapter, ['root' => $root]);
+ },
+ $this->pools,
+ ['root' => $root],
+ );
+ $source = $makeDisk($this->tempDir . '/source');
+ $destination = $makeDisk($this->tempDir . '/destination');
+ $source->put('file.txt', 'contents');
+
+ $this->assertTrue($source->copyToDisk($destination, 'file.txt'));
+ $this->assertSame('contents', $destination->get('file.txt'));
+ $this->assertTrue($source->moveToDisk($destination, 'file.txt', 'moved.txt'));
+ $this->assertSame('contents', $destination->get('moved.txt'));
+ $this->assertFalse($source->exists('file.txt'));
+
+ $source->put('file.txt', 'retained');
+ $failedDestination = m::mock(FilesystemContract::class);
+ $stream = null;
+ $failedDestination->shouldReceive('writeStream')->once()->with('file.txt', m::on(function (mixed $resource) use (&$stream): bool {
+ $stream = $resource;
+ $this->assertSame(0, $this->pools->get('filesystem:transfer')->getBorrowedCount());
+ $this->assertSame('retained', stream_get_contents($resource));
+
+ return true;
+ }))->andReturnFalse();
+
+ $this->assertFalse($source->moveToDisk($failedDestination, 'file.txt'));
+ $this->assertSame('retained', $source->get('file.txt'));
+ $this->assertFalse(is_resource($stream));
+ $this->assertSame(0, $this->pools->get('filesystem:transfer')->getBorrowedCount());
+ }
+
public function testReadThroughDisksReleaseSharedClientBorrowsBeforeCopyingAndProcessingListings(): void
{
$definition = new PoolDefinition('filesystem:shared-read-through', 'local-client', 'shared', PoolOptions::fromArray([
diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php
index 9b7fa6bfe7..d33b038459 100644
--- a/tests/Filesystem/FilesystemAdapterTest.php
+++ b/tests/Filesystem/FilesystemAdapterTest.php
@@ -9,6 +9,7 @@
use Hypervel\Container\Container;
use Hypervel\Context\RequestContext;
use Hypervel\Contracts\Debug\ExceptionHandler;
+use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory;
use Hypervel\Coroutine\Coroutine;
use Hypervel\Engine\Coroutine as EngineCoroutine;
use Hypervel\Filesystem\FilesystemAdapter;
@@ -522,6 +523,92 @@ public function testMove()
$this->assertEquals($data, file_get_contents($this->tempDir . '/foo/foo2.txt'));
}
+ public function testCopyToDisk(): void
+ {
+ $this->filesystem->write('file.txt', 'Hello World');
+
+ $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup'));
+
+ Container::getInstance()->instance(FilesystemFactory::class, m::mock(FilesystemFactory::class, [
+ 'disk' => new FilesystemAdapter($backupFilesystem, $backupAdapter),
+ ]));
+
+ $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter);
+ $filesystemAdapter->copyToDisk('backup', 'file.txt');
+
+ $this->assertFileExists($this->tempDir . '/file.txt');
+ $this->assertFileExists($this->tempDir . '/backup/file.txt');
+ }
+
+ public function testMoveToDisk(): void
+ {
+ $this->filesystem->write('file.txt', 'Hello World');
+
+ $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup'));
+
+ Container::getInstance()->instance(FilesystemFactory::class, m::mock(FilesystemFactory::class, [
+ 'disk' => new FilesystemAdapter($backupFilesystem, $backupAdapter),
+ ]));
+
+ $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter);
+ $filesystemAdapter->moveToDisk('backup', 'file.txt', 'copy.txt');
+
+ $this->assertFileDoesNotExist($this->tempDir . '/file.txt');
+ $this->assertFileExists($this->tempDir . '/backup/copy.txt');
+ }
+
+ public function testCopyToDiskRejectsSameDiskAndPath(): void
+ {
+ $this->filesystem->write('file.txt', 'Hello World');
+
+ $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter);
+
+ Container::getInstance()->instance(FilesystemFactory::class, m::mock(FilesystemFactory::class, [
+ 'disk' => $filesystemAdapter,
+ ]));
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $filesystemAdapter->copyToDisk('local', 'file.txt');
+ }
+
+ public function testCopyToDiskWithFilesystem(): void
+ {
+ $this->filesystem->write('file.txt', 'Hello World');
+
+ $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup'));
+
+ $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter);
+ $filesystemAdapter->copyToDisk(new FilesystemAdapter($backupFilesystem, $backupAdapter), 'file.txt');
+
+ $this->assertFileExists($this->tempDir . '/file.txt');
+ $this->assertFileExists($this->tempDir . '/backup/file.txt');
+ }
+
+ public function testMoveToDiskWithFilesystem(): void
+ {
+ $this->filesystem->write('file.txt', 'Hello World');
+
+ $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup'));
+
+ $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter);
+ $filesystemAdapter->moveToDisk(new FilesystemAdapter($backupFilesystem, $backupAdapter), 'file.txt', 'copy.txt');
+
+ $this->assertFileDoesNotExist($this->tempDir . '/file.txt');
+ $this->assertFileExists($this->tempDir . '/backup/copy.txt');
+ }
+
+ public function testCopyToDiskWithFilesystemRejectsSameDiskAndPath(): void
+ {
+ $this->filesystem->write('file.txt', 'Hello World');
+
+ $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter);
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $filesystemAdapter->copyToDisk($filesystemAdapter, 'file.txt');
+ }
+
public function testStream()
{
$this->filesystem->write('file.txt', $original_content = 'Hello World');
diff --git a/tests/Filesystem/FilesystemPoolProxyTest.php b/tests/Filesystem/FilesystemPoolProxyTest.php
index 303605ef5e..144823184f 100644
--- a/tests/Filesystem/FilesystemPoolProxyTest.php
+++ b/tests/Filesystem/FilesystemPoolProxyTest.php
@@ -196,6 +196,27 @@ public function testEveryCallbackSlotIsWrittenOnEveryBorrowAcrossSharedProxies()
$this->assertFalse($second->providesTemporaryUploadUrls());
}
+ public function testCrossDiskTransfersReleaseTheOnlyDriverBeforeWriting(): void
+ {
+ $proxy = new FilesystemPoolProxy(
+ new PoolDefinition('filesystem:transfer', 'custom', 'transfer', PoolOptions::fromArray([
+ 'max_objects' => 1,
+ 'wait_timeout' => 0.02,
+ ])),
+ fn (): FilesystemAdapter => $this->filesystem(),
+ $this->pools,
+ ['driver' => 'custom'],
+ );
+ $this->driver->write('file.txt', 'contents');
+
+ $this->assertTrue($proxy->copyToDisk($proxy, 'file.txt', 'copy.txt'));
+ $this->assertSame('contents', $this->driver->read('copy.txt'));
+ $this->assertTrue($proxy->moveToDisk($proxy, 'file.txt', 'moved.txt'));
+ $this->assertSame('contents', $this->driver->read('moved.txt'));
+ $this->assertFalse($this->driver->fileExists('file.txt'));
+ $this->assertSame(0, $this->pools->get('filesystem:transfer')->getBorrowedCount());
+ }
+
public function testReadStreamKeepsTheWholeDriverBorrowedUntilClose(): void
{
$this->driver->write('file.txt', 'streamed');
diff --git a/tests/Filesystem/ScopedFilesystemProxyTest.php b/tests/Filesystem/ScopedFilesystemProxyTest.php
index c15b9f2829..b0d4829456 100644
--- a/tests/Filesystem/ScopedFilesystemProxyTest.php
+++ b/tests/Filesystem/ScopedFilesystemProxyTest.php
@@ -18,12 +18,14 @@
use Hypervel\Image\ImageException;
use Hypervel\Testbench\TestCase;
use Hypervel\Testing\ParallelTesting;
+use InvalidArgumentException;
use League\Flysystem\CorruptedPathDetected;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\PathTraversalDetected;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
use TypeError;
@@ -55,6 +57,64 @@ protected function tearDown(): void
parent::tearDown();
}
+ #[TestWith(['copyToDisk', false, null])]
+ #[TestWith(['copyToDisk', false, 'copy.txt'])]
+ #[TestWith(['copyToDisk', true, 'copy.txt'])]
+ #[TestWith(['moveToDisk', false, null])]
+ #[TestWith(['moveToDisk', false, 'copy.txt'])]
+ #[TestWith(['moveToDisk', true, 'copy.txt'])]
+ public function testTransfersUseContractMethodsAndResolveSourceOnce(string $method, bool $sameDisk, ?string $to): void
+ {
+ $stream = fopen('php://temp', 'w+b');
+ fwrite($stream, 'contents');
+ rewind($stream);
+ $source = m::mock(FilesystemContract::class);
+ $source->shouldReceive('readStream')->once()->with('tenant/file.txt')->andReturn($stream);
+ $destination = $sameDisk ? $source : m::mock(FilesystemContract::class);
+ $destination->shouldReceive('writeStream')->once()
+ ->with(($sameDisk ? 'tenant/' : '') . ($to ?? 'file.txt'), $stream)->andReturnTrue();
+
+ if ($method === 'moveToDisk') {
+ $source->shouldReceive('delete')->once()->with('tenant/file.txt')->andReturnTrue();
+ }
+
+ $diskCalls = $prefixCalls = 0;
+ $proxy = new ScopedFilesystemProxy(
+ function () use ($source, &$diskCalls): FilesystemContract {
+ ++$diskCalls;
+
+ return $source;
+ },
+ function () use (&$prefixCalls): string {
+ ++$prefixCalls;
+
+ return 'tenant';
+ },
+ );
+
+ $this->assertTrue($proxy->{$method}($sameDisk ? $proxy : $destination, 'file.txt', $to));
+ $this->assertSame(1, $diskCalls);
+ $this->assertSame(1, $prefixCalls);
+ $this->assertFalse(is_resource($stream));
+ }
+
+ #[TestWith(['copyToDisk'])]
+ #[TestWith(['moveToDisk'])]
+ public function testTransfersRejectSameScopedDiskAndPath(string $method): void
+ {
+ $proxy = new ScopedFilesystemProxy($this->disk, static fn (): string => 'tenant');
+ $proxy->put('file.txt', 'contents');
+
+ try {
+ $proxy->{$method}($proxy, 'file.txt');
+ $this->fail('Expected a same-path transfer to be rejected.');
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame('Cannot copy a file to the same disk and path.', $exception->getMessage());
+ }
+
+ $this->assertSame('contents', $proxy->get('file.txt'));
+ }
+
#[DataProvider('mappedMethodProvider')]
public function testEveryMappedMethodPrefixesExactlyOnce(
string $method,
diff --git a/tests/Sentry/Features/StorageIntegrationTest.php b/tests/Sentry/Features/StorageIntegrationTest.php
index e875da3680..113b15f778 100644
--- a/tests/Sentry/Features/StorageIntegrationTest.php
+++ b/tests/Sentry/Features/StorageIntegrationTest.php
@@ -8,19 +8,25 @@
use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\ObjectPool\Factory as PoolFactory;
use Hypervel\Filesystem\AwsS3V3Adapter;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Filesystem\FilesystemAdapter;
use Hypervel\Filesystem\FilesystemManager;
use Hypervel\Filesystem\FilesystemPoolProxy;
+use Hypervel\Filesystem\ScopedFilesystemProxy;
use Hypervel\Sentry\Features\Storage\DecoratedFilesystem;
use Hypervel\Sentry\Features\Storage\Integration;
+use Hypervel\Sentry\Features\Storage\SentryFilesystem;
use Hypervel\Sentry\Features\Storage\SentryFilesystemAdapter;
use Hypervel\Sentry\Features\Storage\SentryS3V3Adapter;
use Hypervel\Support\Facades\Route;
use Hypervel\Support\Facades\Storage;
+use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\Sentry\SentryTestCase;
+use InvalidArgumentException;
use League\Flysystem\Filesystem as Flysystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionClass;
use ReflectionMethod;
@@ -92,6 +98,69 @@ public function testCreatesSpansFor(): void
$this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getData());
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testCrossDiskTransfersPreserveIdentityAndTracing(bool $scoped): void
+ {
+ $root = ParallelTesting::tempDir('SentryCrossDiskTransfers');
+ $files = new Filesystem;
+ $files->deleteDirectory($root);
+ $files->ensureDirectoryExists($root);
+
+ try {
+ $this->resetApplicationWithConfig([
+ 'filesystems.disks' => Integration::configureDisks([
+ 'source' => ['driver' => $scoped ? 'dynamic-scoped' : 'local', 'root' => $root . '/source'],
+ ]),
+ ]);
+ $manager = $this->app->make(FilesystemManager::class);
+ $manager->extend('dynamic-scoped', static function (Container $app, array $config): ScopedFilesystemProxy {
+ $adapter = new LocalFilesystemAdapter($config['root']);
+
+ return new ScopedFilesystemProxy(
+ new FilesystemAdapter(new Flysystem($adapter), $adapter),
+ static fn (): string => 'tenant',
+ );
+ });
+ $source = Storage::disk('source');
+ $this->assertInstanceOf($scoped ? SentryFilesystem::class : SentryFilesystemAdapter::class, $source);
+ $source->put('file.txt', 'contents');
+
+ try {
+ $source->moveToDisk('source', 'file.txt');
+ $this->fail('Expected a same-path move to be rejected.');
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame('Cannot copy a file to the same disk and path.', $exception->getMessage());
+ }
+
+ $this->assertSame('contents', $source->get('file.txt'));
+ $adapter = new LocalFilesystemAdapter($root . '/destination');
+ $destination = new FilesystemAdapter(new Flysystem($adapter), $adapter);
+ $transaction = $this->startTransaction();
+
+ $this->assertTrue($source->copyToDisk($destination, 'file.txt'));
+ $this->assertTrue($source->moveToDisk('source', 'file.txt', 'moved.txt'));
+ $spans = $transaction->getSpanRecorder()->getSpans();
+
+ $this->assertCount(3, $spans);
+ $this->assertSame('file.copyToDisk', $spans[1]->getOp());
+ $this->assertSame('file.moveToDisk', $spans[2]->getOp());
+ $this->assertSame([
+ 'from' => 'file.txt', 'to' => 'file.txt',
+ 'disk' => 'source', 'driver' => $scoped ? 'dynamic-scoped' : 'local',
+ ], $spans[1]->getData());
+ $this->assertSame([
+ 'from' => 'file.txt', 'to' => 'moved.txt', 'destination_disk' => 'source',
+ 'disk' => 'source', 'driver' => $scoped ? 'dynamic-scoped' : 'local',
+ ], $spans[2]->getData());
+ $this->assertSame('contents', $destination->get('file.txt'));
+ $this->assertSame('contents', $source->get('moved.txt'));
+ $this->assertFalse($source->exists('file.txt'));
+ } finally {
+ $files->deleteDirectory($root);
+ }
+ }
+
public function testDoesntCreateSpansWhenDisabled(): void
{
$this->resetApplicationWithConfig([
From 6b110b17425dd63feea526c672d7df495c79c4f5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 02:50:02 +0000
Subject: [PATCH 19/24] Reject equivalent paths before cross-disk file
transfers
Compare normalized paths before opening a source stream when both sides
resolve to the same filesystem. Apply the check in the shared transfer
method so scoped-to-inner destinations receive the same protection.
Keep the original paths for I/O and normalize only for identical filesystem
objects. Include a useful reason when temporary buffering fails. Extend
existing copy and move tests to verify rejected transfers preserve contents.
Follow-up to https://github.com/laravel/framework/pull/61511 and
https://github.com/laravel/framework/pull/61519.
Validated the affected filesystem and Sentry transfer tests, source and
type analysis, and formatting.
---
src/filesystem/src/Concerns/TransfersFiles.php | 15 ++++++++++-----
src/filesystem/src/ScopedFilesystemProxy.php | 5 -----
tests/Filesystem/FilesystemAdapterTest.php | 14 +++++++++++---
tests/Filesystem/ScopedFilesystemProxyTest.php | 11 +++++++----
4 files changed, 28 insertions(+), 17 deletions(-)
diff --git a/src/filesystem/src/Concerns/TransfersFiles.php b/src/filesystem/src/Concerns/TransfersFiles.php
index 53cccaf455..7fbd2e4873 100644
--- a/src/filesystem/src/Concerns/TransfersFiles.php
+++ b/src/filesystem/src/Concerns/TransfersFiles.php
@@ -10,6 +10,7 @@
use Hypervel\Filesystem\LeasedStream;
use InvalidArgumentException;
use League\Flysystem\UnableToReadFile;
+use League\Flysystem\WhitespacePathNormalizer;
use UnitEnum;
trait TransfersFiles
@@ -23,10 +24,6 @@ public function copyToDisk(FilesystemContract|string|UnitEnum $disk, string $fro
? $disk
: Container::getInstance()->make(FilesystemFactory::class)->disk($disk);
- if ($destination === $this && ($to ?? $from) === $from) {
- throw new InvalidArgumentException('Cannot copy a file to the same disk and path.');
- }
-
return $this->transferFile($this, $from, $destination, $to ?? $from);
}
@@ -43,6 +40,14 @@ public function moveToDisk(FilesystemContract|string|UnitEnum $disk, string $fro
*/
protected function transferFile(FilesystemContract $source, string $from, FilesystemContract $destination, string $to): bool
{
+ if ($source === $destination) {
+ $normalizer = new WhitespacePathNormalizer;
+
+ if ($normalizer->normalizePath($from) === $normalizer->normalizePath($to)) {
+ throw new InvalidArgumentException('Cannot copy a file to the same disk and path.');
+ }
+ }
+
$stream = $source->readStream($from);
if (! is_resource($stream)) {
@@ -57,7 +62,7 @@ protected function transferFile(FilesystemContract $source, string $from, Filesy
$temporary = fopen('php://temp', 'w+b');
if ($temporary === false || stream_copy_to_stream($stream, $temporary) === false || ! rewind($temporary)) {
- throw UnableToReadFile::fromLocation($from);
+ throw UnableToReadFile::fromLocation($from, 'Unable to copy the stream to temporary storage.');
}
fclose($stream);
diff --git a/src/filesystem/src/ScopedFilesystemProxy.php b/src/filesystem/src/ScopedFilesystemProxy.php
index 29b9902f78..6d45d675f0 100644
--- a/src/filesystem/src/ScopedFilesystemProxy.php
+++ b/src/filesystem/src/ScopedFilesystemProxy.php
@@ -17,7 +17,6 @@
use Hypervel\Image\Image;
use Hypervel\Image\ImageException;
use Hypervel\Support\Traits\Conditionable;
-use InvalidArgumentException;
use League\Flysystem\PathNormalizer;
use League\Flysystem\WhitespacePathNormalizer;
use RuntimeException;
@@ -498,10 +497,6 @@ protected function transferToDisk(Filesystem|string|UnitEnum $disk, string $from
: Container::getInstance()->make(FilesystemFactory::class)->disk($disk);
$to ??= $from;
- if ($destination === $this && $to === $from) {
- throw new InvalidArgumentException('Cannot copy a file to the same disk and path.');
- }
-
$prefix = $this->prefix();
$source = $this->resolveDisk();
$from = $this->applyPrefix($prefix, $from);
diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php
index d33b038459..098f96c96d 100644
--- a/tests/Filesystem/FilesystemAdapterTest.php
+++ b/tests/Filesystem/FilesystemAdapterTest.php
@@ -598,15 +598,23 @@ public function testMoveToDiskWithFilesystem(): void
$this->assertFileExists($this->tempDir . '/backup/copy.txt');
}
- public function testCopyToDiskWithFilesystemRejectsSameDiskAndPath(): void
+ #[TestWith(['copyToDisk', null])]
+ #[TestWith(['copyToDisk', './file.txt'])]
+ #[TestWith(['moveToDisk', 'dir/../file.txt'])]
+ public function testCopyToDiskWithFilesystemRejectsSameDiskAndPath(string $method, ?string $to): void
{
$this->filesystem->write('file.txt', 'Hello World');
$filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter);
- $this->expectException(InvalidArgumentException::class);
+ try {
+ $filesystemAdapter->{$method}($filesystemAdapter, 'file.txt', $to);
+ $this->fail('Expected a same-path transfer to be rejected.');
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame('Cannot copy a file to the same disk and path.', $exception->getMessage());
+ }
- $filesystemAdapter->copyToDisk($filesystemAdapter, 'file.txt');
+ $this->assertSame('Hello World', $filesystemAdapter->get('file.txt'));
}
public function testStream()
diff --git a/tests/Filesystem/ScopedFilesystemProxyTest.php b/tests/Filesystem/ScopedFilesystemProxyTest.php
index b0d4829456..cd63cd53af 100644
--- a/tests/Filesystem/ScopedFilesystemProxyTest.php
+++ b/tests/Filesystem/ScopedFilesystemProxyTest.php
@@ -98,15 +98,18 @@ function () use (&$prefixCalls): string {
$this->assertFalse(is_resource($stream));
}
- #[TestWith(['copyToDisk'])]
- #[TestWith(['moveToDisk'])]
- public function testTransfersRejectSameScopedDiskAndPath(string $method): void
+ #[TestWith(['copyToDisk', null, false])]
+ #[TestWith(['moveToDisk', null, false])]
+ #[TestWith(['copyToDisk', './file.txt', false])]
+ #[TestWith(['moveToDisk', 'dir/../file.txt', false])]
+ #[TestWith(['moveToDisk', 'tenant/file.txt', true])]
+ public function testTransfersRejectSameScopedDiskAndPath(string $method, ?string $to, bool $innerDestination): void
{
$proxy = new ScopedFilesystemProxy($this->disk, static fn (): string => 'tenant');
$proxy->put('file.txt', 'contents');
try {
- $proxy->{$method}($proxy, 'file.txt');
+ $proxy->{$method}($innerDestination ? $this->disk : $proxy, 'file.txt', $to);
$this->fail('Expected a same-path transfer to be rejected.');
} catch (InvalidArgumentException $exception) {
$this->assertSame('Cannot copy a file to the same disk and path.', $exception->getMessage());
From 4a398ba9f5fb489957b7cdfea81686aa12fb9f73 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 02:50:02 +0000
Subject: [PATCH 20/24] Ignore read defaults when checking multiple cache keys
Resolve associative, mixed and sparse input into the same key list used
by many(), without carrying default values into the existence check. Keep
repository events, enum handling, wrapper stores and cached-null semantics.
Extend the existing array-key test to cover defaults, false values and
cached nulls alongside sparse and mixed keys.
Follow-up to https://github.com/laravel/framework/pull/61466.
Validated CacheRepositoryTest, the affected parallel test selection,
source and type analysis, and formatting.
---
src/cache/src/Repository.php | 8 +++++++-
tests/Cache/CacheRepositoryTest.php | 15 ++++++++++-----
2 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php
index 9ef0aaaedf..bc94150f82 100644
--- a/src/cache/src/Repository.php
+++ b/src/cache/src/Repository.php
@@ -106,7 +106,13 @@ public function __construct(Store $store, array $config = [])
public function has(array|UnitEnum|string $key): bool
{
if (is_array($key)) {
- return ! in_array(null, $this->many($key), true);
+ $keys = [];
+
+ foreach ($key as $name => $value) {
+ $keys[] = is_string($name) ? $name : $value;
+ }
+
+ return ! in_array(null, $this->many($keys), true);
}
return ! is_null($this->get($key));
diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php
index efa73498f3..e336e26b44 100644
--- a/tests/Cache/CacheRepositoryTest.php
+++ b/tests/Cache/CacheRepositoryTest.php
@@ -41,6 +41,7 @@
use InvalidArgumentException;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use stdClass;
use Swoole\Coroutine\CanceledException;
@@ -185,14 +186,18 @@ public function testHasMethod()
$this->assertTrue($repo->has('baz'));
}
- public function testHasMethodWithArray(): void
+ #[TestWith([['foo', TestCacheKey::Bar], ['foo' => 'foo', 'bar' => 'bar'], true])]
+ #[TestWith([['foo', 'baz'], ['foo' => 'foo', 'baz' => null], false])]
+ #[TestWith([['foo' => false, 5 => TestCacheKey::Bar], ['foo' => false, 'bar' => 'bar'], true])]
+ #[TestWith([[5 => 'foo', 9 => TestCacheKey::Bar], ['foo' => 'foo', 'bar' => 'bar'], true])]
+ #[TestWith([['foo' => false], ['foo' => null], false])]
+ #[TestWith([['foo' => false], ['foo' => NullSentinel::VALUE], false])]
+ public function testHasMethodWithArray(array $keys, array $values, bool $expected): void
{
$repo = $this->getRepository();
- $repo->getStore()->expects('many')->with(['foo', 'bar'])->andReturn(['foo' => 'foo', 'bar' => 'bar']);
- $repo->getStore()->expects('many')->with(['foo', 'baz'])->andReturn(['foo' => 'foo', 'baz' => null]);
+ $repo->getStore()->expects('many')->with(array_keys($values))->andReturn($values);
- $this->assertTrue($repo->has(['foo', TestCacheKey::Bar]));
- $this->assertFalse($repo->has(['foo', 'baz']));
+ $this->assertSame($expected, $repo->has($keys));
}
public function testMissingMethod()
From e0edd9f08439366bf30c7d210fcbe72f45c828c7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 02:50:02 +0000
Subject: [PATCH 21/24] Match wildcard JSON assertion paths against their
structure
Walk decoded JSON one path segment at a time instead of flattening it
into dotted strings. A wildcard matches a child at that level, empty keys
and null values remain present, and scalar roots have no children. Stop
at the first match and retain the non-wildcard assertion behavior.
Extend existing tests for literal dotted keys, empty keys, scalar roots
and empty values without adding a second set of duplicate assertions.
Follow-up to https://github.com/laravel/framework/pull/61441.
Validated TestResponseTest, the affected parallel test selection, source
and type analysis, and formatting.
---
src/testing/src/AssertableJsonString.php | 38 +++++++++++++++++++-----
tests/Testing/TestResponseTest.php | 15 ++++++++--
2 files changed, 44 insertions(+), 9 deletions(-)
diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php
index 009936f07c..770a3476ff 100644
--- a/src/testing/src/AssertableJsonString.php
+++ b/src/testing/src/AssertableJsonString.php
@@ -208,20 +208,44 @@ public function assertMissingPath(string $path): static
return $this;
}
- $pattern = '#^' . (new Collection(explode('.', $path)))
- ->map(fn (string $segment): string => $segment === '*' ? '[^.]+' : preg_quote($segment, '#'))
- ->implode('\.') . '(\.|$)#';
-
PHPUnit::assertFalse(
- (new Collection(Arr::dot((array) $this->json())))
- ->keys()
- ->contains(fn (int|string $key): bool => preg_match($pattern, (string) $key) === 1),
+ $this->hasJsonPath($this->json(), explode('.', $path)),
"Found unexpected path [{$path}] within the response JSON."
);
return $this;
}
+ /**
+ * Determine whether a JSON path exists, where each wildcard matches any child at its level.
+ *
+ * @param list $segments
+ */
+ protected function hasJsonPath(mixed $data, array $segments): bool
+ {
+ if ($segments === []) {
+ return true;
+ }
+
+ if (! is_array($data)) {
+ return false;
+ }
+
+ $segment = array_shift($segments);
+
+ if ($segment === '*') {
+ foreach ($data as $value) {
+ if ($this->hasJsonPath($value, $segments)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return array_key_exists($segment, $data) && $this->hasJsonPath($data[$segment], $segments);
+ }
+
/**
* Assert that the expected value and type exists at the given path in the response.
*/
diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php
index 92b8c4a929..603e43e3ec 100644
--- a/tests/Testing/TestResponseTest.php
+++ b/tests/Testing/TestResponseTest.php
@@ -1915,6 +1915,14 @@ public function testAssertJsonMissingPathWithWildcard(): void
// A wildcard matches a single segment, so this must not match "barfoo.*.bar.foo"...
$response->assertJsonMissingPath('barfoo.*.foo');
+
+ $literalKeys = TestResponse::fromBaseResponse(new JsonResponse(['users' => [['password.hash' => 'value']]]));
+ $literalKeys->assertJsonMissingPath('users.*.password');
+ $literalKeys->assertJsonMissingPath('users.*.password.hash');
+ $literalKeys->assertJsonMissingPath('users.0.password.hash');
+
+ TestResponse::fromBaseResponse(new JsonResponse(42))->assertJsonMissingPath('*');
+ TestResponse::fromBaseResponse(new JsonResponse([]))->assertJsonMissingPath('*');
}
public function testAssertJsonMissingPathWithTrailingWildcardCanFail(): void
@@ -1944,11 +1952,14 @@ public function testAssertJsonMissingPathWithWildcardCanFailWhenPresentOnSomeIte
$response->assertJsonMissingPath('barfoo.*.bar.foo');
}
- public function testAssertJsonMissingPathWithWildcardCanFailForTopLevelArray(): void
+ #[TestWith([['present'], '*'])]
+ #[TestWith([['' => ['value' => null]], '*.value'])]
+ #[TestWith([['child' => []], '*'])]
+ public function testAssertJsonMissingPathWithWildcardCanFailForMatchingNodes(array $data, string $path): void
{
$this->expectException(AssertionFailedError::class);
- TestResponse::fromBaseResponse(new JsonResponse(['present']))->assertJsonMissingPath('*');
+ TestResponse::fromBaseResponse(new JsonResponse($data))->assertJsonMissingPath($path);
}
public function testAssertJsonMissingPaths(): void
From df836e022f7f20597d0d402828f55004be98168f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 02:50:02 +0000
Subject: [PATCH 22/24] Use recorded query source aliases for Eloquent
qualification
Delegate the protected table-alias helper to Query Builder getFromAlias()
so explicit expression and subquery aliases qualify columns correctly.
Plain alternate tables use their active source name; opaque raw sources
retain the model-table fallback.
Preserve the protected extension point and extend the existing alias test
with each source form. Update mocks without pinning lookup call counts.
Follow-up to https://github.com/laravel/framework/pull/61456.
Validated Eloquent and soft-delete tests, source and type analysis,
and formatting.
---
src/database/src/Eloquent/Builder.php | 10 ++-------
.../Database/DatabaseEloquentBuilderTest.php | 21 +++++++++++++++----
2 files changed, 19 insertions(+), 12 deletions(-)
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index 148fc6d717..2b3ae82970 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -2043,17 +2043,11 @@ public function qualifyColumns(array $columns): array
}
/**
- * Get the alias given to the query's table, if any.
+ * Get the alias or table name that qualifies the query's source columns.
*/
protected function getTableAlias(): ?string
{
- if (! is_string($this->query->from)) {
- return null;
- }
-
- $segments = preg_split('/\s+as\s+/i', $this->query->from);
-
- return count($segments) > 1 ? array_last($segments) : null;
+ return $this->query->getFromAlias();
}
/**
diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php
index 01151603b2..98734e2fc4 100755
--- a/tests/Database/DatabaseEloquentBuilderTest.php
+++ b/tests/Database/DatabaseEloquentBuilderTest.php
@@ -366,6 +366,7 @@ public function testQualifyColumn(): void
{
$builder = new Builder(m::mock(BaseBuilder::class));
$builder->expects('from')->with('foo_table');
+ $builder->getQuery()->allows('getFromAlias')->andReturnNull();
$builder->setModel(new StubStringPrimaryKey);
@@ -376,6 +377,7 @@ public function testQualifyColumns(): void
{
$builder = new Builder(m::mock(BaseBuilder::class));
$builder->expects('from')->with('foo_table');
+ $builder->getQuery()->allows('getFromAlias')->andReturnNull();
$builder->setModel(new StubStringPrimaryKey);
@@ -384,17 +386,28 @@ public function testQualifyColumns(): void
public function testQualifyColumnWithTableAlias(): void
{
- $query = m::mock(BaseBuilder::class);
- $query->expects('from')->with('foo_table');
- $query->from = 'foo_table as alias';
-
+ $connection = m::mock(Connection::class, ['getTablePrefix' => '']);
+ $query = new BaseBuilder($connection, new Grammar($connection), new Processor);
$builder = new Builder($query);
$builder->setModel(new StubStringPrimaryKey);
+ $builder->from('foo_table as alias');
$this->assertSame('alias.column', $builder->qualifyColumn('column'));
$this->assertSame('alias.column', $builder->qualifyColumn('foo_table.column'));
$this->assertSame('other_table.column', $builder->qualifyColumn('other_table.column'));
$this->assertEquals(['alias.column', 'alias.name'], $builder->qualifyColumns(['column', 'name']));
+
+ $builder->fromSub('select * from foo_table', 'sub');
+ $this->assertSame('sub.column', $builder->qualifyColumn('foo_table.column'));
+
+ $builder->from(new Expression('foo_table'), 'expression_alias');
+ $this->assertSame('expression_alias.column', $builder->qualifyColumn('column'));
+
+ $builder->from('foo_archive');
+ $this->assertSame('foo_archive.column', $builder->qualifyColumn('foo_table.column'));
+
+ $builder->fromRaw('foo_table as opaque');
+ $this->assertSame('foo_table.column', $builder->qualifyColumn('column'));
}
public function testGetMethodLoadsModelsAndHydratesEagerRelations(): void
From fcfbcfac3a0e3a844386bf4cd4dd4dc21a863577 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 02:50:02 +0000
Subject: [PATCH 23/24] Reject a vanished Vite hot file during asset generation
Throw the existing ViteException when the development-server URL becomes
unavailable after hot mode was selected. Do not produce a root-relative
asset URL by concatenating a null server URL.
Extend the existing failure test for both disappearance at the second mode
check and failure while reading the hot file, through the public asset API.
Follow-up to https://github.com/laravel/framework/pull/61465.
Validated FoundationViteTest, the affected parallel test selection, source
and type analysis, and formatting.
---
src/foundation/src/Vite.php | 5 ++++-
tests/Foundation/FoundationViteTest.php | 12 +++++++++---
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/src/foundation/src/Vite.php b/src/foundation/src/Vite.php
index b725ca671a..f3edf171d5 100644
--- a/src/foundation/src/Vite.php
+++ b/src/foundation/src/Vite.php
@@ -766,7 +766,10 @@ public function reactRefresh(): ?HtmlString
*/
protected function hotAsset(string $asset): string
{
- return $this->devServerUrl() . '/' . $asset;
+ $url = $this->devServerUrl()
+ ?? throw new ViteException("Unable to read the Vite hot file at [{$this->hotFile()}].");
+
+ return $url . '/' . $asset;
}
/**
diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php
index baed453d5b..02deff2541 100644
--- a/tests/Foundation/FoundationViteTest.php
+++ b/tests/Foundation/FoundationViteTest.php
@@ -13,6 +13,8 @@
use Hypervel\Testbench\TestCase;
use Hypervel\Testing\ParallelTesting;
use JsonException;
+use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
class FoundationViteTest extends TestCase
{
@@ -671,10 +673,14 @@ public function testItCanGenerateIndividualAssetUrlInHotMode(): void
$this->assertSame('http://localhost:3000/resources/js/app.js', $url);
}
- public function testItThrowsWhenHotFileDisappearsBeforeItCanBeRead(): void
+ #[TestWith([true])]
+ #[TestWith([false])]
+ public function testItThrowsWhenHotFileDisappearsBeforeItCanBeRead(bool $existsBeforeRead): void
{
- $this->withUnreadableViteStream(function (string $path): void {
- $vite = (new Vite)->useHotFile($path . '/hot');
+ $this->withUnreadableViteStream(function (string $path) use ($existsBeforeRead): void {
+ $vite = m::mock(Vite::class . '[isRunningHot]');
+ $vite->useHotFile($path . '/hot');
+ $vite->expects('isRunningHot')->twice()->andReturn(true, $existsBeforeRead);
$this->expectException(ViteException::class);
$this->expectExceptionMessageIsOrContains('Unable to read the Vite hot file');
From c03b895652f15f27cfa4e6b2d726d5bc74d3759d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 28 Sep 2026 02:50:02 +0000
Subject: [PATCH 24/24] Reuse the original message when recording the Resend ID
Reuse the original Message already passed to MessageConverter::toEmail()
when writing the provider ID header. Remove the redundant getter calls
and type guard while retaining the SentMessage identifier update.
Follow-up to https://github.com/laravel/framework/pull/61476.
Validated the existing Resend transport test, source and type analysis,
and formatting.
---
src/mail/src/Transport/ResendTransport.php | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/mail/src/Transport/ResendTransport.php b/src/mail/src/Transport/ResendTransport.php
index 5492f745c3..1d58dd1715 100644
--- a/src/mail/src/Transport/ResendTransport.php
+++ b/src/mail/src/Transport/ResendTransport.php
@@ -134,9 +134,7 @@ protected function doSend(SentMessage $message): void
$message->setMessageId($messageId);
- if ($message->getOriginalMessage() instanceof Message) {
- $message->getOriginalMessage()->getHeaders()->addHeader('X-Resend-Email-ID', $messageId);
- }
+ $originalMessage->getHeaders()->addHeader('X-Resend-Email-ID', $messageId);
}
/**