From 3651245811e3e314f913a41684a4ea2c5eff4fb4 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:22:48 +0000
Subject: [PATCH 01/22] Accept enum disk names in filesystem manager operations
Normalize enum names in set(), forgetDisk() and purge(), including integer-backed values before typed configuration lookup. Preserve the existing shared-pool invalidation behavior and worker-lifetime ownership.
Port Laravel framework #61868 (504980231b) and its facade follow-up 9bc6f565fc from the 13.x target 5d7797528723107cd4f495326838a7d7d67fbb20. Include every upstream enum assertion, unit and integer enum coverage, regenerated Storage annotations and a usage example.
Validated the complete manager test file, affected filesystem and facade suites, formatting and full static analysis.
---
src/docs/filesystem.md | 8 +++
src/filesystem/src/FilesystemManager.php | 16 +++---
src/support/src/Facades/Storage.php | 6 +--
tests/Filesystem/FilesystemManagerTest.php | 61 ++++++++++++++++++++++
4 files changed, 81 insertions(+), 10 deletions(-)
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index f4bf2e193e..66ab9f626a 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -451,6 +451,14 @@ If your application interacts with multiple disks, you may use the `disk` method
Storage::disk('s3')->put('avatars/1', $content);
```
+You may also use enums for disk names. Backed enums use their value, while unit enums use their case name:
+
+```php
+Storage::disk(Disk::S3)->put('avatars/1', $content);
+```
+
+The `set`, `forgetDisk`, and `purge` methods also accept enums. The `forgetDisk` method accepts an array of disk names or enums as well.
+
### On-Demand Disks
diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php
index ee245b0dbb..4cf2d2374a 100644
--- a/src/filesystem/src/FilesystemManager.php
+++ b/src/filesystem/src/FilesystemManager.php
@@ -971,9 +971,9 @@ protected function createFlysystem(FlysystemAdapter $adapter, array $config): Fi
* resolution will return the replacement. Any shared pool remains
* available until its idle TTL expires or purge() invalidates it.
*/
- public function set(string $name, mixed $disk): static
+ public function set(UnitEnum|string $name, mixed $disk): static
{
- $this->disks[$name] = $disk;
+ $this->disks[enum_value($name)] = $disk;
return $this;
}
@@ -1015,11 +1015,13 @@ public function getDefaultDriver(): string
* coroutines may already hold a reference to the disk and next resolution
* will rebuild a wrapper. Shared pools remain available until their idle
* TTL expires or purge() deliberately invalidates them.
+ *
+ * @param array|string|UnitEnum $disk
*/
- public function forgetDisk(array|string $disk): static
+ public function forgetDisk(UnitEnum|array|string $disk): static
{
- foreach ((array) $disk as $diskName) {
- unset($this->disks[$diskName]);
+ foreach (Arr::wrap($disk) as $diskName) {
+ unset($this->disks[enum_value($diskName)]);
}
return $this;
@@ -1032,9 +1034,9 @@ public function forgetDisk(array|string $disk): static
* Closing deliberately invalidates a shared pool; other converged disks
* acquire a fresh pool on their next operation.
*/
- public function purge(?string $name = null): void
+ public function purge(UnitEnum|string|null $name = null): void
{
- $name ??= $this->getDefaultDriver();
+ $name = (string) (enum_value($name) ?? $this->getDefaultDriver());
$disk = $this->disks[$name] ?? null;
unset($this->disks[$name]);
diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php
index 67a919916e..35aea74253 100644
--- a/src/support/src/Facades/Storage.php
+++ b/src/support/src/Facades/Storage.php
@@ -25,13 +25,13 @@
* @method static \Hypervel\Contracts\Filesystem\Filesystem drive(UnitEnum|string|null $name = null)
* @method static \Hypervel\Filesystem\FilesystemManager extend(string $driver, \Closure $callback, bool $poolable = false)
* @method static void flushState()
- * @method static \Hypervel\Filesystem\FilesystemManager forgetDisk(array|string $disk)
+ * @method static \Hypervel\Filesystem\FilesystemManager forgetDisk(array|string|UnitEnum $disk)
* @method static string getDefaultDriver()
* @method static array getPoolableDrivers()
* @method static \Closure|null getReleaseCallback(string $driver)
- * @method static void purge(string|null $name = null)
+ * @method static void purge(UnitEnum|string|null $name = null)
* @method static \Hypervel\Filesystem\FilesystemManager removePoolableDriver(string $driver)
- * @method static \Hypervel\Filesystem\FilesystemManager set(string $name, mixed $disk)
+ * @method static \Hypervel\Filesystem\FilesystemManager set(UnitEnum|string $name, mixed $disk)
* @method static \Hypervel\Filesystem\FilesystemManager setApplication(\Hypervel\Contracts\Container\Container $app)
* @method static \Hypervel\Filesystem\FilesystemManager setPoolableDrivers(array $poolableDrivers)
* @method static \Hypervel\Filesystem\FilesystemManager setReleaseCallback(string $driver, \Closure $callback)
diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php
index 3191023a61..ccbb422864 100644
--- a/tests/Filesystem/FilesystemManagerTest.php
+++ b/tests/Filesystem/FilesystemManagerTest.php
@@ -55,6 +55,7 @@
use RuntimeException;
use stdClass;
use Swoole\Coroutine\CanceledException;
+use UnitEnum;
use function Aws\serialize;
@@ -321,6 +322,46 @@ public function testCanBuildOnDemandDisk(): void
]));
}
+ public function testCanSetDiskUsingEnum(): void
+ {
+ $filesystem = new FilesystemManager($this->getContainer());
+ $filesystem->set(FilesystemManagerTestDisk::Local, $disk = m::mock(Filesystem::class));
+
+ $this->assertSame($disk, $filesystem->disk('local'));
+ }
+
+ public function testCanForgetDiskUsingEnum(): void
+ {
+ $filesystem = $this->filesystemManagerWithLocalDisks();
+ $filesystem->set('local', $local = m::mock(Filesystem::class));
+ $filesystem->set('backup', $backup = m::mock(Filesystem::class));
+
+ $filesystem->forgetDisk(FilesystemManagerTestDisk::Local);
+
+ $this->assertNotSame($local, $filesystem->disk('local'));
+ $this->assertSame($backup, $filesystem->disk('backup'));
+
+ $filesystem->forgetDisk([FilesystemManagerTestDisk::Backup]);
+
+ $this->assertNotSame($backup, $filesystem->disk('backup'));
+ }
+
+ #[TestWith([FilesystemManagerTestDisk::Local, 'local'])]
+ #[TestWith([FilesystemTestUnitDisk::local, 'local'])]
+ #[TestWith([FilesystemTestIntBackedDisk::Local, '1'])]
+ public function testCanPurgeDiskUsingEnum(UnitEnum $name, string $key): void
+ {
+ $filesystem = $this->filesystemManagerWithLocalDisks();
+ $filesystem->set($key, $local = m::mock(Filesystem::class));
+
+ $filesystem->purge($name);
+
+ $this->assertNotSame($local, $filesystem->disk($key));
+
+ $filesystem->forgetDisk($key);
+ $filesystem->purge($name);
+ }
+
public function testCanBuildLocalDisksThatLazilyCreateTheirRootDirectory(): void
{
$filesystem = new FilesystemManager($this->getContainer());
@@ -2662,6 +2703,20 @@ protected function readThroughFilesystemManager(
]));
}
+ /**
+ * Create a filesystem manager with isolated local disks.
+ */
+ protected function filesystemManagerWithLocalDisks(): FilesystemManager
+ {
+ return new FilesystemManager($this->getContainer([
+ 'disks' => [
+ 'local' => ['driver' => 'local', 'root' => $this->tempDir . '/local'],
+ 'backup' => ['driver' => 'local', 'root' => $this->tempDir . '/backup'],
+ '1' => ['driver' => 'local', 'root' => $this->tempDir . '/integer'],
+ ],
+ ]));
+ }
+
protected function getContainer(array $config = []): Container
{
$config = new Repository(['filesystems' => $config]);
@@ -2734,3 +2789,9 @@ public function s3ArgumentNamesForTest(): array
return static::s3ArgumentNames();
}
}
+
+enum FilesystemManagerTestDisk: string
+{
+ case Local = 'local';
+ case Backup = 'backup';
+}
From 2b031f80e5ec06f68371c5e611ae7d77c491fe38 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:22:48 +0000
Subject: [PATCH 02/22] Extract JSON paths before compiling overlap comparisons
Pass the selected JSON value as the first argument to JSON_OVERLAPS instead of emitting an unsupported third path argument. MariaDB inherits the same grammar correction; ordinary column and raw-value handling remain unchanged.
Port Laravel framework #61853 (84e7ca2af5) from the 13.x target 5d7797528723107cd4f495326838a7d7d67fbb20, including all changed SQL and binding assertions and the real database regression.
The SQL assertions reproduce the previous defect. Complete query-builder and database suites, isolated MySQL integration, formatting and full static analysis pass.
---
src/database/src/Query/Grammars/MySqlGrammar.php | 6 +++++-
tests/Database/DatabaseQueryBuilderTest.php | 8 ++++----
.../Database/MySql/DatabaseMySqlConnectionTest.php | 13 +++++++++++++
3 files changed, 22 insertions(+), 5 deletions(-)
diff --git a/src/database/src/Query/Grammars/MySqlGrammar.php b/src/database/src/Query/Grammars/MySqlGrammar.php
index 5a7ac94112..b67c44ade6 100755
--- a/src/database/src/Query/Grammars/MySqlGrammar.php
+++ b/src/database/src/Query/Grammars/MySqlGrammar.php
@@ -265,7 +265,11 @@ protected function compileJsonOverlaps(string $column, string $value): string
{
[$field, $path] = $this->wrapJsonFieldAndPath($column);
- return 'json_overlaps(' . $field . ', ' . $value . $path . ')';
+ if ($path !== '') {
+ $field = 'json_extract(' . $field . $path . ')';
+ }
+
+ return 'json_overlaps(' . $field . ', ' . $value . ')';
}
/**
diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php
index 34be2523c8..7968983056 100755
--- a/tests/Database/DatabaseQueryBuilderTest.php
+++ b/tests/Database/DatabaseQueryBuilderTest.php
@@ -8397,12 +8397,12 @@ public function testWhereJsonOverlapsMySql(): void
$builder = $this->getMySqlBuilder();
$builder->select('*')->from('users')->whereJsonOverlaps('users.options->languages', ['en', 'fr']);
- $this->assertSame('select * from `users` where json_overlaps(`users`.`options`, ?, \'$."languages"\')', $builder->toSql());
+ $this->assertSame('select * from `users` where json_overlaps(json_extract(`users`.`options`, \'$."languages"\'), ?)', $builder->toSql());
$this->assertEquals(['["en","fr"]'], $builder->getBindings());
$builder = $this->getMySqlBuilder();
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonOverlaps('options->languages', new Raw("'[\"en\", \"fr\"]'"));
- $this->assertSame('select * from `users` where `id` = ? or json_overlaps(`options`, \'["en", "fr"]\', \'$."languages"\')', $builder->toSql());
+ $this->assertSame('select * from `users` where `id` = ? or json_overlaps(json_extract(`options`, \'$."languages"\'), \'["en", "fr"]\')', $builder->toSql());
$this->assertEquals([1], $builder->getBindings());
}
@@ -8462,12 +8462,12 @@ public function testWhereJsonDoesntOverlapMySql(): void
{
$builder = $this->getMySqlBuilder();
$builder->select('*')->from('users')->whereJsonDoesntOverlap('options->languages', ['en', 'fr']);
- $this->assertSame('select * from `users` where not json_overlaps(`options`, ?, \'$."languages"\')', $builder->toSql());
+ $this->assertSame('select * from `users` where not json_overlaps(json_extract(`options`, \'$."languages"\'), ?)', $builder->toSql());
$this->assertEquals(['["en","fr"]'], $builder->getBindings());
$builder = $this->getMySqlBuilder();
$builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntOverlap('options->languages', new Raw("'[\"en\", \"fr\"]'"));
- $this->assertSame('select * from `users` where `id` = ? or not json_overlaps(`options`, \'["en", "fr"]\', \'$."languages"\')', $builder->toSql());
+ $this->assertSame('select * from `users` where `id` = ? or not json_overlaps(json_extract(`options`, \'$."languages"\'), \'["en", "fr"]\')', $builder->toSql());
$this->assertEquals([1], $builder->getBindings());
}
diff --git a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php
index 1032ad6281..9a0ac11f10 100644
--- a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php
+++ b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php
@@ -10,6 +10,7 @@
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Facades\Schema;
+use Hypervel\Testbench\Attributes\RequiresDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresOperatingSystem;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
@@ -258,6 +259,18 @@ public static function jsonContainsKeyDataProvider(): array
];
}
+ #[RequiresDatabase('mysql', '>=8.0.17')]
+ public function testWhereJsonOverlapsWithPath(): void
+ {
+ DB::table(self::TABLE)->insert([
+ [self::JSON_COL => '{"languages":["en","de"]}'],
+ [self::JSON_COL => '{"languages":["fr"]}'],
+ ]);
+
+ $this->assertSame(1, DB::table(self::TABLE)->whereJsonOverlaps(self::JSON_COL . '->languages', ['en', 'es'])->count());
+ $this->assertSame(1, DB::table(self::TABLE)->whereJsonDoesntOverlap(self::JSON_COL . '->languages', ['en', 'es'])->count());
+ }
+
public function testLastInsertIdIsPreserved(): void
{
if (! Schema::hasTable('auto_id_table')) {
From aa0ca78ad2f439799c7ba0ab9fb0847ee5d383ce Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:22:49 +0000
Subject: [PATCH 03/22] Load missing nested morph relationships by model type
Group nested related models by their concrete class before recursively loading missing relationships. Mixed morphTo collections no longer attach a same-key post's images to a video. Existing loaded and null relationship handling remains intact.
Port Laravel framework #61845 (bf8c93de76) from target 5d7797528723107cd4f495326838a7d7d67fbb20 with its complete integration case, assertions and typed fixtures.
The regression fails before the correction. Complete loadMissing, automatic-loading and collection checks, formatting and full static analysis pass.
---
src/database/src/Eloquent/Collection.php | 6 +-
.../EloquentCollectionLoadMissingTest.php | 87 +++++++++++++++++++
2 files changed, 92 insertions(+), 1 deletion(-)
diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php
index c8c164a8e3..4144b7266d 100644
--- a/src/database/src/Eloquent/Collection.php
+++ b/src/database/src/Eloquent/Collection.php
@@ -341,7 +341,11 @@ protected function loadMissingRelation(self $models, array $path): void
$models = $models->collapse();
}
- $this->loadMissingRelation(new static($models), $path);
+ $models->groupBy(fn (Model $model): string => $model::class)->each(
+ function (BaseCollection $models) use ($path): void {
+ $this->loadMissingRelation(new static($models), $path);
+ }
+ );
}
/**
diff --git a/tests/Integration/Database/EloquentCollectionLoadMissingTest.php b/tests/Integration/Database/EloquentCollectionLoadMissingTest.php
index 9763bc047e..6475f42e7e 100644
--- a/tests/Integration/Database/EloquentCollectionLoadMissingTest.php
+++ b/tests/Integration/Database/EloquentCollectionLoadMissingTest.php
@@ -5,6 +5,8 @@
namespace Hypervel\Tests\Integration\Database\EloquentCollectionLoadMissingTest;
use Hypervel\Database\Eloquent\Model;
+use Hypervel\Database\Eloquent\Relations\MorphMany;
+use Hypervel\Database\Eloquent\Relations\MorphTo;
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Facades\Schema;
@@ -49,6 +51,21 @@ protected function afterRefreshingDatabase(): void
$table->unsignedInteger('post_sub_relation_id');
});
+ Schema::create('videos', function (Blueprint $table) {
+ $table->increments('id');
+ });
+
+ Schema::create('images', function (Blueprint $table) {
+ $table->increments('id');
+ $table->morphs('imageable');
+ $table->string('name');
+ });
+
+ Schema::create('activities', function (Blueprint $table) {
+ $table->increments('id');
+ $table->morphs('subject');
+ });
+
User::create();
Post::insert([
@@ -221,6 +238,25 @@ public function testLoadMissingWithMultipleNestedArrays(): void
$this->assertInstanceOf(PostSubSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations[0]);
}
+ public function testLoadMissingWithNestedRelationOnMorphToOfDifferentTypes(): void
+ {
+ $post = Post::find(1);
+ $video = Video::create();
+
+ $post->images()->create(['name' => 'post image']);
+ $video->images()->create(['name' => 'video image']);
+
+ Activity::create(['subject_type' => Post::class, 'subject_id' => $post->id]);
+ Activity::create(['subject_type' => Video::class, 'subject_id' => $video->id]);
+
+ $activities = Activity::get();
+
+ $activities->loadMissing('subject.images');
+
+ $this->assertEquals(['post image'], $activities[0]->subject->images->pluck('name')->all());
+ $this->assertEquals(['video image'], $activities[1]->subject->images->pluck('name')->all());
+ }
+
public function testLoadMissingWithMultipleNestedArraysCombinedWithDotNotation(): void
{
$users = User::get();
@@ -280,6 +316,16 @@ public function postRelation()
{
return $this->hasOne(PostRelation::class);
}
+
+ /**
+ * Get the post's images.
+ *
+ * @return MorphMany
+ */
+ public function images(): MorphMany
+ {
+ return $this->morphMany(Image::class, 'imageable');
+ }
}
class PostRelation extends Model
@@ -331,3 +377,44 @@ public function posts()
return $this->hasMany(Post::class);
}
}
+
+class Video extends Model
+{
+ public bool $timestamps = false;
+
+ protected array $guarded = [];
+
+ /**
+ * Get the video's images.
+ *
+ * @return MorphMany
+ */
+ public function images(): MorphMany
+ {
+ return $this->morphMany(Image::class, 'imageable');
+ }
+}
+
+class Image extends Model
+{
+ public bool $timestamps = false;
+
+ protected array $guarded = [];
+}
+
+class Activity extends Model
+{
+ public bool $timestamps = false;
+
+ protected array $guarded = [];
+
+ /**
+ * Get the activity's subject.
+ *
+ * @return MorphTo
+ */
+ public function subject(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
From d27e493a32c56dd2c197c41426099155f627add4 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:23:23 +0000
Subject: [PATCH 04/22] Preserve related model identity in relationship helpers
Make through findOrNew() and relationship chunkMap() use the relationship's own retrieval path, retaining selected model IDs and pivot hydration. Add relationship-owned incrementOrCreate() implementations so foreign keys and pivot attachments are preserved; include extra attributes when creating records.
Qualify eager columns for through and active one-of-many relations. Qualify through lookup attributes while retaining original creation attributes, write-connection fallbacks, closure values and driver capability checks. Document relationship counter creation.
Reconcile Laravel framework #61855 (13bb3aef4b), #61849 (41bca847f6), #61878 (7778ecf12f), #61885 (8d8f4bc4bd), #61997 (9b4b444019) and #62038 (a02558ef15) at target 5d7797528723107cd4f495326838a7d7d67fbb20. These changes share source and test files and are committed together without partial staging.
All applicable upstream assertions and fixtures are included. Regressions reproduce wrong IDs, missing pivots or foreign keys, omitted extras and ambiguous columns before correction. Complete changed files, database suites, affected SQLite integration, formatting and full static analysis pass. Private tenant and ClickHouse creation guards remain effective.
---
src/database/src/Eloquent/Builder.php | 6 +-
.../src/Eloquent/Relations/BelongsToMany.php | 35 ++++++++++
.../src/Eloquent/Relations/HasOneOrMany.php | 14 ++++
.../Relations/HasOneOrManyThrough.php | 65 ++++++++++++++++++-
src/docs/eloquent-relationships.md | 12 ++++
...tabaseEloquentBuilderCreateOrFirstTest.php | 29 +++++++++
...loquentHasManyThroughCreateOrFirstTest.php | 18 ++---
...eEloquentHasManyThroughIntegrationTest.php | 36 ++++++++++
.../DatabaseEloquentIntegrationTest.php | 13 ++++
.../Database/EloquentBelongsToManyTest.php | 33 +++++++++-
.../Database/EloquentHasManyTest.php | 16 +++++
.../Database/EloquentHasManyThroughTest.php | 52 +++++++++++++++
.../Database/EloquentHasOneOfManyTest.php | 12 ++++
13 files changed, 327 insertions(+), 14 deletions(-)
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index fccb71ecea..4d1e98b96d 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -8,12 +8,14 @@
use Closure;
use Exception;
use Hypervel\Contracts\Database\Eloquent\Builder as BuilderContract;
+use Hypervel\Contracts\Database\Eloquent\SupportsPartialRelations;
use Hypervel\Contracts\Database\Query\Expression;
use Hypervel\Contracts\Support\Arrayable;
use Hypervel\Database\BinaryParameter;
use Hypervel\Database\Concerns\BuildsQueries;
use Hypervel\Database\Eloquent\Concerns\QueriesRelationships;
use Hypervel\Database\Eloquent\Relations\BelongsToMany;
+use Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough;
use Hypervel\Database\Eloquent\Relations\Relation;
use Hypervel\Database\MultipleRecordsFoundException;
use Hypervel\Database\Query\Builder as QueryBuilder;
@@ -755,7 +757,7 @@ public function updateOrCreate(array $attributes, Closure|array $values = []): M
*/
public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model
{
- return tap($this->firstOrCreate($attributes, [$column => $default]), function ($instance) use ($column, $step, $extra) {
+ return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function ($instance) use ($column, $step, $extra) {
if (! $instance->wasRecentlyCreated) {
$instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call)
}
@@ -1840,6 +1842,8 @@ protected function createSelectWithConstraint(string $name): array
return [explode(':', $name)[0], static function ($query) use ($name) {
$query->select(array_map(static function ($column) use ($query) {
return $query instanceof BelongsToMany
+ || $query instanceof HasOneOrManyThrough
+ || ($query instanceof SupportsPartialRelations && $query->isOneOfMany())
? $query->getRelated()->qualifyColumn($column)
: $column;
}, explode(',', explode(':', $name)[1])));
diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php
index 5bc99afb19..b3438e19fc 100644
--- a/src/database/src/Eloquent/Relations/BelongsToMany.php
+++ b/src/database/src/Eloquent/Relations/BelongsToMany.php
@@ -733,6 +733,20 @@ public function updateOrCreate(array $attributes, Closure|array $values = [], ar
});
}
+ /**
+ * Create a related record matching the attributes, or increment the existing record.
+ *
+ * @return TRelatedModel
+ */
+ public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model
+ {
+ return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void {
+ if (! $instance->wasRecentlyCreated) {
+ $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call)
+ }
+ });
+ }
+
/**
* Find a related model by its primary key.
*
@@ -1146,6 +1160,27 @@ public function each(callable $callback, int $count = 1000): bool
});
}
+ /**
+ * Run a map over each item while chunking.
+ *
+ * @template TReturn
+ *
+ * @param callable(object{pivot: TPivotModel}&TRelatedModel): TReturn $callback
+ * @return BaseCollection
+ */
+ public function chunkMap(callable $callback, int $count = 1000): BaseCollection
+ {
+ $collection = new BaseCollection;
+
+ $this->chunk($count, function (BaseCollection $items) use ($collection, $callback): void {
+ $items->each(function (mixed $item) use ($collection, $callback): void {
+ $collection->push($callback($item));
+ });
+ });
+
+ return $collection;
+ }
+
/**
* Query lazily, by chunks of the given size.
*
diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php
index 9da47cdef1..e8b1de81e9 100755
--- a/src/database/src/Eloquent/Relations/HasOneOrMany.php
+++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php
@@ -286,6 +286,20 @@ public function updateOrCreate(array $attributes, Closure|array $values = []): M
});
}
+ /**
+ * Create a related record matching the attributes, or increment the existing record.
+ *
+ * @return TRelatedModel
+ */
+ public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model
+ {
+ return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void {
+ if (! $instance->wasRecentlyCreated) {
+ $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call)
+ }
+ });
+ }
+
/**
* Insert new records or update the existing ones.
*/
diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
index 223f9be7ad..0f51aca971 100644
--- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
+++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
@@ -211,6 +211,20 @@ protected function buildDictionary(EloquentCollection $results): array
return $dictionary;
}
+ /**
+ * Find a related model by its primary key or return a new instance of the related model.
+ *
+ * @return ($id is Model ? TRelatedModel : ($id is (array|Arrayable>) ? EloquentCollection : TRelatedModel))
+ */
+ public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection|Model
+ {
+ if (is_null($instance = $this->find($id, $columns))) {
+ $instance = $this->related->newInstance();
+ }
+
+ return $instance;
+ }
+
/**
* Get the first related model record matching the attributes or instantiate it.
*
@@ -219,7 +233,7 @@ protected function buildDictionary(EloquentCollection $results): array
*/
public function firstOrNew(array $attributes = [], Closure|array $values = []): Model
{
- if (! is_null($instance = $this->where($attributes)->first())) {
+ if (! is_null($instance = $this->where($this->qualifyRelatedAttributes($attributes))->first())) {
return $instance;
}
@@ -236,7 +250,7 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = []
{
$this->getQuery()->ensureCanCreateOrFirst();
- if (! is_null($instance = (clone $this)->where($attributes)->first())) {
+ if (! is_null($instance = (clone $this)->where($this->qualifyRelatedAttributes($attributes))->first())) {
return $instance;
}
@@ -258,7 +272,7 @@ public function createOrFirst(array $attributes = [], Closure|array $values = []
try {
return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values))));
} catch (UniqueConstraintViolationException $exception) {
- return $this->useWritePdo()->where($attributes)->first() ?? throw $exception;
+ return $this->useWritePdo()->where($this->qualifyRelatedAttributes($attributes))->first() ?? throw $exception;
}
}
@@ -277,6 +291,30 @@ public function updateOrCreate(array $attributes, Closure|array $values = []): M
});
}
+ /**
+ * Qualify the given attribute keys with the related model's table.
+ */
+ protected function qualifyRelatedAttributes(array $attributes): array
+ {
+ return (new BaseCollection($attributes))
+ ->mapWithKeys(fn (mixed $value, int|string $key): array => [$this->related->qualifyColumn((string) $key) => $value])
+ ->all();
+ }
+
+ /**
+ * Create a related record matching the attributes, or increment the existing record.
+ *
+ * @return TRelatedModel
+ */
+ public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model
+ {
+ return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void {
+ if (! $instance->wasRecentlyCreated) {
+ $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call)
+ }
+ });
+ }
+
/**
* Add a basic where clause to the query, and return the first result.
*
@@ -611,6 +649,27 @@ public function each(callable $callback, int $count = 1000): bool
});
}
+ /**
+ * Run a map over each item while chunking.
+ *
+ * @template TReturn
+ *
+ * @param callable(TRelatedModel): TReturn $callback
+ * @return BaseCollection
+ */
+ public function chunkMap(callable $callback, int $count = 1000): BaseCollection
+ {
+ $collection = new BaseCollection;
+
+ $this->chunk($count, function (BaseCollection $items) use ($collection, $callback): void {
+ $items->each(function (mixed $item) use ($collection, $callback): void {
+ $collection->push($callback($item));
+ });
+ });
+
+ return $collection;
+ }
+
/**
* Query lazily, by chunks of the given size.
*
diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md
index a2f9d5b8a8..c567c17bee 100644
--- a/src/docs/eloquent-relationships.md
+++ b/src/docs/eloquent-relationships.md
@@ -2543,6 +2543,18 @@ $user->posts()->createManyQuietly([
You may also use the `findOrNew`, `firstOrNew`, `firstOrCreate`, and `updateOrCreate` methods to [create and update models on relationships](/docs/{{version}}/eloquent#upserts).
+The `incrementOrCreate` method creates a related record with the given default value, or increments the column if a matching record already exists. Any extra attributes are applied when creating or incrementing the record:
+
+```php
+$post->views()->incrementOrCreate(
+ ['date' => today()->toDateString()],
+ column: 'count',
+ default: 1,
+ step: 1,
+ extra: ['last_viewed_at' => now()],
+);
+```
+
> [!NOTE]
> Before using the `create` method, be sure to review the [mass assignment](/docs/{{version}}/eloquent#mass-assignment) documentation.
diff --git a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php
index 62c28d25a6..5f96d4a8f4 100755
--- a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php
+++ b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php
@@ -415,6 +415,35 @@ public function testIncrementOrCreateMethodIncrementParametersArePassed(): void
], $result->toArray());
}
+ public function testIncrementOrCreateMethodExtraParametersArePassedWhenCreating(): void
+ {
+ $model = new TestModel;
+ $this->mockConnectionForModel($model, 'SQLite', [123]);
+ $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0);
+ $model->getConnection()->shouldReceive('getWritableName')->andReturn('sqlite');
+
+ $model->getConnection()
+ ->expects('select')
+ ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, [])
+ ->andReturn([]);
+
+ $model->getConnection()->expects('insert')->with(
+ 'insert into "table" ("attr", "val", "count", "updated_at", "created_at") values (?, ?, ?, ?, ?)',
+ ['foo', 'baz', '1', '2023-01-01 00:00:00', '2023-01-01 00:00:00'],
+ )->andReturnTrue();
+
+ $result = $model->newQuery()->incrementOrCreate(['attr' => 'foo'], step: 2, extra: ['val' => 'baz']);
+ $this->assertTrue($result->wasRecentlyCreated);
+ $this->assertEquals([
+ 'id' => 123,
+ 'attr' => 'foo',
+ 'val' => 'baz',
+ 'count' => 1,
+ 'created_at' => '2023-01-01T00:00:00.000000Z',
+ 'updated_at' => '2023-01-01T00:00:00.000000Z',
+ ], $result->toArray());
+ }
+
public function testIncrementOrCreateMethodRetrievesRecordCreatedJustNow(): void
{
$model = new TestModel;
diff --git a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php
index 5aa2980ddd..976f223c5c 100644
--- a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php
+++ b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php
@@ -70,7 +70,7 @@ public function testCreateOrFirstMethodRetrievesExistingRecord(): void
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
false,
[],
@@ -110,7 +110,7 @@ public function testFirstOrCreateMethodCreatesNewRecord(): void
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
true,
[],
@@ -145,7 +145,7 @@ public function testFirstOrCreateMethodRetrievesExistingRecord(): void
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
true,
[],
@@ -186,7 +186,7 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(Closure|arr
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
true,
[],
@@ -204,7 +204,7 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(Closure|arr
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
false,
[],
@@ -256,7 +256,7 @@ public function testUpdateOrCreateMethodCreatesNewRecord(Closure|array $values):
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
true,
[],
@@ -295,7 +295,7 @@ public function testUpdateOrCreateMethodUpdatesExistingRecord(Closure|array $val
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
true,
[],
@@ -344,7 +344,7 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(Closure|arra
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
true,
[],
@@ -362,7 +362,7 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(Closure|arra
$parent->getConnection()
->expects('select')
->with(
- 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1',
+ 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1',
[123, 'foo'],
false,
[],
diff --git a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php
index 00d41fc266..fb5115a27b 100644
--- a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php
+++ b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php
@@ -203,6 +203,24 @@ public function testFindOrFailThrowsAnException(bool $model): void
Country::first()->posts()->findOrFail($model ? (new Post)->forceFill(['id' => 1]) : 1);
}
+ public function testFindOrNewReturnsTheRelatedModel(): void
+ {
+ $this->seedData();
+ $this->seedDataExtended();
+ $country = Country::find(2);
+
+ $post = $country->posts()->findOrNew(5);
+
+ $this->assertTrue($post->exists);
+ $this->assertSame(5, $post->id);
+ $this->assertSame('Example2 title1', $post->title);
+
+ $post = $country->posts()->findOrNew(1);
+
+ $this->assertFalse($post->exists);
+ $this->assertInstanceOf(Post::class, $post);
+ }
+
public function testFindOrFailWithManyThrowsAnException(): void
{
$this->expectExceptionObject(new ModelNotFoundException('No query results for model [Hypervel\Tests\Database\DatabaseEloquentHasManyThroughIntegrationTest\Post] 1, 2'));
@@ -393,6 +411,24 @@ public function testChunkReturnsCorrectModels(): void
});
}
+ public function testChunkMapReturnsCorrectModels(): void
+ {
+ $this->seedData();
+ $this->seedDataExtended();
+ $country = Country::find(2);
+
+ $posts = $country->posts()->chunkMap(fn (Post $post): array => [$post->id, $post->title], 2);
+
+ $this->assertEquals([
+ [3, 'Example1 title1'],
+ [4, 'Example1 title2'],
+ [5, 'Example2 title1'],
+ [6, 'Example2 title2'],
+ [7, 'Example3 title1'],
+ [8, 'Example3 title2'],
+ ], $posts->all());
+ }
+
public function testChunkById(): void
{
$this->seedData();
diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php
index bbcafa7749..f42e851e41 100644
--- a/tests/Database/DatabaseEloquentIntegrationTest.php
+++ b/tests/Database/DatabaseEloquentIntegrationTest.php
@@ -1965,6 +1965,19 @@ public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverEachRe
});
}
+ public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverChunkMapRequest(): void
+ {
+ $user = User::create(['email' => 'taylorotwell@gmail.com']);
+ $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']);
+
+ $results = User::first()->friends()->chunkMap(fn (User $result): User => $result, 2);
+
+ $this->assertCount(1, $results);
+ $this->assertSame('abigailotwell@gmail.com', $results->first()->email);
+ $this->assertEquals($user->id, $results->first()->pivot->user_id);
+ $this->assertEquals($friend->id, $results->first()->pivot->friend_id);
+ }
+
public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverCursorRequest(): void
{
$user = User::create(['email' => 'taylorotwell@gmail.com']);
diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php
index 5014ccd577..2ed4991f2b 100644
--- a/tests/Integration/Database/EloquentBelongsToManyTest.php
+++ b/tests/Integration/Database/EloquentBelongsToManyTest.php
@@ -43,6 +43,7 @@ protected function afterRefreshingDatabase(): void
$table->increments('id');
$table->string('name');
$table->string('type')->nullable();
+ $table->integer('count')->default(0);
$table->timestamps();
});
@@ -821,6 +822,36 @@ public function testUpdateOrCreateUnrelatedExisting(): void
$this->assertTrue($tag->is($post->tags()->first()));
}
+ public function testIncrementOrCreateMethod(): void
+ {
+ $post = Post::create(['title' => Str::random()]);
+
+ $tag1 = $post->tags()->incrementOrCreate(['name' => 'wavez']);
+ $tag2 = $post->tags()->incrementOrCreate(['name' => 'wavez']);
+
+ $this->assertTrue($tag1->is($tag2));
+ $this->assertTrue($tag1->wasRecentlyCreated);
+ $this->assertFalse($tag2->wasRecentlyCreated);
+ $this->assertEquals(2, $tag2->fresh()->count);
+ $this->assertTrue($tag1->is($post->tags()->first()));
+ $this->assertCount(1, $post->tags()->get());
+ }
+
+ public function testIncrementOrCreateMethodWithExtra(): void
+ {
+ $post = Post::create(['title' => Str::random()]);
+
+ $tag1 = $post->tags()->incrementOrCreate(['name' => 'wavez'], extra: ['type' => 'featured']);
+
+ $this->assertTrue($tag1->wasRecentlyCreated);
+ $this->assertSame('featured', $tag1->fresh()->type);
+
+ $tag2 = $post->tags()->incrementOrCreate(['name' => 'wavez'], extra: ['type' => 'pinned']);
+
+ $this->assertSame('pinned', $tag2->fresh()->type);
+ $this->assertEquals(2, $tag2->fresh()->count);
+ }
+
public function testUpdateOrCreateMethodCreate(): void
{
$post = Post::create(['title' => Str::random()]);
@@ -2018,7 +2049,7 @@ class Tag extends Model
public bool $timestamps = true;
- protected array $fillable = ['name', 'type'];
+ protected array $fillable = ['name', 'type', 'count'];
public function posts()
{
diff --git a/tests/Integration/Database/EloquentHasManyTest.php b/tests/Integration/Database/EloquentHasManyTest.php
index 450237cc90..0f2ed77622 100644
--- a/tests/Integration/Database/EloquentHasManyTest.php
+++ b/tests/Integration/Database/EloquentHasManyTest.php
@@ -23,6 +23,7 @@ protected function afterRefreshingDatabase(): void
$table->id();
$table->foreignId('eloquent_has_many_test_user_id');
$table->string('title')->unique();
+ $table->integer('views')->default(0);
$table->timestamps();
});
@@ -89,6 +90,21 @@ public function testFirstOrCreateWithinTransaction(): void
$this->assertCount(1, $user->posts()->get());
}
+ public function testIncrementOrCreate(): void
+ {
+ $user = EloquentHasManyTestUser::create();
+
+ $post1 = $user->posts()->incrementOrCreate(['title' => 'foo'], 'views');
+ $post2 = $user->posts()->incrementOrCreate(['title' => 'foo'], 'views');
+
+ $this->assertTrue($post1->is($post2));
+ $this->assertTrue($post1->wasRecentlyCreated);
+ $this->assertFalse($post2->wasRecentlyCreated);
+ $this->assertEquals($user->id, $post2->eloquent_has_many_test_user_id);
+ $this->assertEquals(2, $post2->fresh()->views);
+ $this->assertCount(1, $user->posts()->get());
+ }
+
public function testCreateOrFirst(): void
{
$user = EloquentHasManyTestUser::create();
diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php
index bf452d1b6f..d757380dbd 100644
--- a/tests/Integration/Database/EloquentHasManyThroughTest.php
+++ b/tests/Integration/Database/EloquentHasManyThroughTest.php
@@ -48,10 +48,25 @@ protected function afterRefreshingDatabase(): void
$table->id();
$table->foreignId('user_id');
$table->string('title')->unique();
+ $table->string('slug')->nullable();
+ $table->integer('views')->default(0);
$table->timestamps();
});
}
+ public function testEagerLoadingSpecificColumns(): void
+ {
+ $user = User::create(['name' => Str::random()]);
+ $team = Team::create(['owner_id' => $user->id]);
+ $mate = User::create(['name' => 'John', 'team_id' => $team->id]);
+
+ $user = User::with('teamMates:id,name')->find($user->id);
+
+ $this->assertCount(1, $user->teamMates);
+ $this->assertSame($mate->id, $user->teamMates[0]->id);
+ $this->assertSame('John', $user->teamMates[0]->name);
+ }
+
public function testBasicCreateAndRetrieve(): void
{
$user = User::create(['name' => Str::random()]);
@@ -273,6 +288,26 @@ public function testFirstOrCreateWhenModelExists(): void
$this->assertSame('adam', $mate->slug);
}
+ public function testFirstOrCreateWithColumnThatAlsoExistsOnIntermediateTable(): void
+ {
+ $team = Team::create();
+ $user = $team->members()->create(['name' => 'Taylor', 'slug' => 'taylor']);
+ $article = Article::create(['user_id' => $user->id, 'title' => 'Hypervel', 'slug' => 'hypervel']);
+
+ $this->assertTrue($article->is($team->articles()->firstOrNew(['slug' => 'hypervel'])));
+ $this->assertTrue($article->is($team->articles()->firstOrCreate(['slug' => 'hypervel'])));
+
+ $updated = $team->articles()->updateOrCreate(['slug' => 'hypervel'], ['views' => 5]);
+
+ $this->assertTrue($article->is($updated));
+ $this->assertEquals(5, $article->fresh()->views);
+
+ $created = $team->articles()->firstOrCreate(['slug' => 'eloquent'], ['user_id' => $user->id, 'title' => 'Eloquent']);
+
+ $this->assertTrue($created->wasRecentlyCreated);
+ $this->assertSame('eloquent', $created->fresh()->slug);
+ }
+
public function testFirstOrCreateRegressionIssue(): void
{
$team1 = Team::create();
@@ -426,6 +461,23 @@ public function testUpdateOrCreateAffectingWrongModelsRegression(): void
$this->assertSame('jane-slug', $jane->fresh()->slug);
}
+ public function testIncrementOrCreateIncrementsTheRelatedModel(): void
+ {
+ $team = Team::create();
+
+ $jane = User::create(['name' => 'Jane']);
+ $john = User::create(['name' => 'John', 'team_id' => $team->id]);
+
+ $johnsArticle = Article::create(['user_id' => $john->id, 'title' => 'foo']);
+ $janesArticle = Article::create(['user_id' => $jane->id, 'title' => 'bar']);
+
+ $article = $team->articles()->incrementOrCreate(['title' => 'foo'], 'views');
+
+ $this->assertTrue($article->is($johnsArticle));
+ $this->assertEquals(1, $johnsArticle->fresh()->views);
+ $this->assertEquals(0, $janesArticle->fresh()->views);
+ }
+
public function testCanReplicateModelLoadedThroughHasManyThrough(): void
{
$team = Team::create();
diff --git a/tests/Integration/Database/EloquentHasOneOfManyTest.php b/tests/Integration/Database/EloquentHasOneOfManyTest.php
index a7e4ecfc88..b86bc42857 100644
--- a/tests/Integration/Database/EloquentHasOneOfManyTest.php
+++ b/tests/Integration/Database/EloquentHasOneOfManyTest.php
@@ -55,6 +55,18 @@ public function testItOnlyEagerLoadsRequiredModels(): void
$this->assertSame(2, $this->retrievedLogins);
}
+ public function testItEagerLoadsSpecificColumns(): void
+ {
+ $user = User::create();
+ $user->latest_login()->create();
+ $latestLogin = $user->latest_login()->create();
+
+ $user = User::with('latest_login:id,user_id')->first();
+
+ $this->assertSame($latestLogin->id, $user->latest_login->id);
+ $this->assertSame(['id', 'user_id'], array_keys($user->latest_login->getAttributes()));
+ }
+
public function testItGetsCorrectResultUsingAtLeastTwoAggregatesDistinctFromId(): void
{
$user = User::create();
From 6d29b59b2fd433913bb6405c47460013e598e412 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:23:23 +0000
Subject: [PATCH 05/22] Keep recycled factory relationships on their derived
definitions
Clone relationship definitions when recycling instead of mutating reusable factories. Propagate only newly supplied candidates when definitions are configured, rather than merging the same candidates for every created model. Reset derived explicit-parent caches while preserving shared parents within counted and nested groups, explicit model parents and empty-recycle behavior.
Copy the states collection before prependState() so deriving a factory does not modify the original factory's state ordering.
Port Laravel framework #61884 (9e74d819e3) at target 5d7797528723107cd4f495326838a7d7d67fbb20 and correct the related parent-cache and repeated-candidate defects. Include the upstream regression and focused derived-parent, nested-sharing and prepend-state cases.
The leak regressions fail before correction. Factory and database suites, morph-target and Testbench factory checks, formatting and full static analysis pass. Deterministic candidate counts confirm repeated accumulation is removed.
---
.../Factories/BelongsToManyRelationship.php | 8 +-
.../Factories/BelongsToRelationship.php | 9 +-
.../src/Eloquent/Factories/Factory.php | 75 ++++++++++------
.../src/Eloquent/Factories/Relationship.php | 8 +-
.../Database/DatabaseEloquentFactoryTest.php | 89 +++++++++++++++++++
5 files changed, 151 insertions(+), 38 deletions(-)
diff --git a/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php b/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php
index 67696ed1ac..0aa4131b14 100644
--- a/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php
+++ b/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php
@@ -57,15 +57,15 @@ public function createFor(Model $model): void
/**
* Specify the model instances to always use when creating relationships.
- *
- * @return $this
*/
public function recycle(Collection $recycle): static
{
+ $relationship = clone $this;
+
if ($this->factory instanceof Factory) {
- $this->factory = $this->factory->recycle($recycle);
+ $relationship->factory = $this->factory->recycle($recycle);
}
- return $this;
+ return $relationship;
}
}
diff --git a/src/database/src/Eloquent/Factories/BelongsToRelationship.php b/src/database/src/Eloquent/Factories/BelongsToRelationship.php
index b6cde04da7..7592384152 100644
--- a/src/database/src/Eloquent/Factories/BelongsToRelationship.php
+++ b/src/database/src/Eloquent/Factories/BelongsToRelationship.php
@@ -70,15 +70,16 @@ protected function resolver(?string $key): Closure
/**
* Specify the model instances to always use when creating relationships.
- *
- * @return $this
*/
public function recycle(Collection $recycle): static
{
+ $relationship = clone $this;
+
if ($this->factory instanceof Factory) {
- $this->factory = $this->factory->recycle($recycle);
+ $relationship->factory = $this->factory->recycle($recycle);
+ $relationship->resolved = null;
}
- return $this;
+ return $relationship;
}
}
diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php
index 1e069364a0..3874b019fd 100644
--- a/src/database/src/Eloquent/Factories/Factory.php
+++ b/src/database/src/Eloquent/Factories/Factory.php
@@ -359,7 +359,7 @@ protected function createChildren(Model $model): void
{
Model::unguarded(function () use ($model) {
$this->has->each(function ($has) use ($model) {
- $has->recycle($this->recycle)->createFor($model);
+ $has->createFor($model);
});
});
}
@@ -536,7 +536,7 @@ protected function getRawAttributes(?Model $parent): array
protected function parentResolvers(): array
{
return $this->for
- ->map(fn (BelongsToRelationship $for) => $for->recycle($this->recycle)->attributesFor($this->newModel()))
+ ->map(fn (BelongsToRelationship $for) => $for->attributesFor($this->newModel()))
->collapse()
->all();
}
@@ -598,7 +598,7 @@ public function state(callable|array $state): static
public function prependState(callable|array $state): static
{
return $this->newInstance([
- 'states' => $this->states->prepend(
+ 'states' => (new Collection($this->states))->prepend(
is_callable($state) ? $state : fn () => $state,
),
]);
@@ -641,11 +641,17 @@ public function crossJoinSequence(array ...$sequence): static
*/
public function has(self $factory, ?string $relationship = null): static
{
+ $definition = new Relationship(
+ $factory,
+ $relationship ?? $this->guessRelationship($factory->modelName())
+ );
+
+ if ($this->recycle->isNotEmpty()) {
+ $definition = $definition->recycle($this->recycle);
+ }
+
return $this->newInstance([
- 'has' => $this->has->concat([new Relationship(
- $factory,
- $relationship ?? $this->guessRelationship($factory->modelName())
- )]),
+ 'has' => $this->has->concat([$definition]),
]);
}
@@ -674,17 +680,21 @@ public function hasAttached(self|Collection|Model|array $factory, callable|array
$pivot = new Sequence(...$pivot);
}
- return $this->newInstance([
- 'has' => $this->has->concat([new BelongsToManyRelationship(
- $factory,
- $pivot,
- $relationship ?? StrCache::camel(StrCache::plural(class_basename(
- $factory instanceof Factory
- ? $factory->modelName()
- : Collection::wrap($factory)->first()
- )))
- )]),
- ]);
+ $definition = new BelongsToManyRelationship(
+ $factory,
+ $pivot,
+ $relationship ?? StrCache::camel(StrCache::plural(class_basename(
+ $factory instanceof Factory
+ ? $factory->modelName()
+ : Collection::wrap($factory)->first()
+ )))
+ );
+
+ if ($this->recycle->isNotEmpty()) {
+ $definition = $definition->recycle($this->recycle);
+ }
+
+ return $this->newInstance(['has' => $this->has->concat([$definition])]);
}
/**
@@ -692,12 +702,18 @@ public function hasAttached(self|Collection|Model|array $factory, callable|array
*/
public function for(self|Model $factory, ?string $relationship = null): static
{
- return $this->newInstance(['for' => $this->for->concat([new BelongsToRelationship(
+ $definition = new BelongsToRelationship(
$factory,
$relationship ?? StrCache::camel(class_basename(
$factory instanceof Factory ? $factory->modelName() : $factory
))
- )])]);
+ );
+
+ if ($this->recycle->isNotEmpty()) {
+ $definition = $definition->recycle($this->recycle);
+ }
+
+ return $this->newInstance(['for' => $this->for->concat([$definition])]);
}
/**
@@ -705,14 +721,21 @@ public function for(self|Model $factory, ?string $relationship = null): static
*/
public function recycle(Model|Collection|array $model): static
{
+ $models = Collection::wrap($model instanceof Model ? func_get_args() : $model)->flatten();
+
+ if ($models->isEmpty()) {
+ return $this->newInstance();
+ }
+
// Group provided models by the type and merge them into existing recycle collection
+ $recycle = $this->recycle->flatten()->merge($models)->groupBy(fn ($model) => get_class($model));
+
+ // Recycle relationship copies when configured, not per model: counted groups
+ // share one parent without leaking it or repeatedly merging the same candidates.
return $this->newInstance([
- 'recycle' => $this->recycle
- ->flatten()
- ->merge(
- Collection::wrap($model instanceof Model ? func_get_args() : $model)
- ->flatten()
- )->groupBy(fn ($model) => get_class($model)),
+ 'recycle' => $recycle,
+ 'for' => $this->for->map(fn (BelongsToRelationship $relationship): BelongsToRelationship => $relationship->recycle($models)),
+ 'has' => $this->has->map(fn (BelongsToManyRelationship|Relationship $relationship): BelongsToManyRelationship|Relationship => $relationship->recycle($models)),
]);
}
diff --git a/src/database/src/Eloquent/Factories/Relationship.php b/src/database/src/Eloquent/Factories/Relationship.php
index c336db2cd1..54eb125e81 100644
--- a/src/database/src/Eloquent/Factories/Relationship.php
+++ b/src/database/src/Eloquent/Factories/Relationship.php
@@ -56,13 +56,13 @@ public function createFor(Model $parent): void
/**
* Specify the model instances to always use when creating relationships.
- *
- * @return $this
*/
public function recycle(Collection $recycle): static
{
- $this->factory = $this->factory->recycle($recycle);
+ $relationship = clone $this;
- return $this;
+ $relationship->factory = $this->factory->recycle($recycle);
+
+ return $relationship;
}
}
diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php
index 22c1107928..740dd7d200 100644
--- a/tests/Database/DatabaseEloquentFactoryTest.php
+++ b/tests/Database/DatabaseEloquentFactoryTest.php
@@ -23,6 +23,7 @@
use Hypervel\Database\Eloquent\Factories\HasFactory;
use Hypervel\Database\Eloquent\Factories\Sequence;
use Hypervel\Database\Eloquent\Model as Eloquent;
+use Hypervel\Database\Eloquent\Relations\BelongsTo;
use Hypervel\Database\Eloquent\Relations\Pivot;
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Database\Schema\Blueprint;
@@ -32,6 +33,7 @@
use Hypervel\Tests\Database\Fixtures\Models\Money\Price;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionClass;
class DatabaseEloquentFactoryTest extends TestCase
@@ -361,6 +363,19 @@ public function testWithoutAfterCreatingRemovesConfigureCallbacks(): void
unset($_SERVER['__test.user.making']);
}
+ public function testPrependingStatePreservesExplicitStateAndTheOriginalFactory(): void
+ {
+ $factory = UserFactory::new()->state(['name' => 'explicit']);
+
+ $derived = $factory->prependState(['name' => 'prepended', 'options' => 'derived'])->make();
+ $original = $factory->make();
+
+ $this->assertSame('explicit', $derived->name);
+ $this->assertSame('derived', $derived->options);
+ $this->assertSame('explicit', $original->name);
+ $this->assertNull($original->options);
+ }
+
public function testHasManyRelationship(): void
{
$users = UserFactory::times(10)
@@ -998,6 +1013,70 @@ public function testRecycledModelsCanBeCombinedWithMultipleCalls(): void
$this->assertSame(3, Post::count());
}
+ public function testRecycledModelsDoNotLeakIntoLaterCallsOnTheSameFactory(): void
+ {
+ Factory::guessFactoryNamesUsing(function (string $model): string {
+ return $model . 'Factory';
+ });
+
+ $user = UserFactory::new()->create();
+
+ $factory = PostFactory::new()
+ ->has(CommentFactory::new()->count(2), 'comments');
+
+ $factory->recycle($user)->create();
+ $post = $factory->create();
+
+ $this->assertCount(2, $post->comments);
+ $this->assertNotContains($user->id, $post->comments->pluck('user_id'));
+ }
+
+ public function testRecyclingKeepsParentAndChildDefinitionsOnTheDerivedFactory(): void
+ {
+ $first = UserFactory::new()->create();
+ $second = UserFactory::new()->create();
+ $factory = PostFactory::new()
+ ->for(UserFactory::new(), 'user')
+ ->has(CommentFactory::new()->count(2), 'comments')
+ ->count(2);
+
+ foreach ([$first, $second] as $user) {
+ $posts = $factory->recycle($user)->create();
+
+ $this->assertSame([$user->id, $user->id], $posts->pluck('user_id')->all());
+ foreach ($posts as $post) {
+ $this->assertSame([$user->id, $user->id], $post->comments->pluck('user_id')->all());
+ }
+ }
+
+ $posts = $factory->create();
+ $userIds = $posts->pluck('user_id')
+ ->merge($posts->flatMap(fn (Post $post): Collection => $post->comments)->pluck('user_id'));
+
+ $this->assertNotContains($first->id, $userIds);
+ $this->assertNotContains($second->id, $userIds);
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testNestedFactoriesShareTheirExplicitParentAcrossTheGroup(bool $recycle): void
+ {
+ $factory = PostFactory::new()->count(3)->has(
+ CommentFactory::new()->count(2)->for(UserFactory::new(), 'user'),
+ 'comments'
+ );
+
+ if ($recycle) {
+ $factory = $factory->recycle(RoleFactory::new()->create());
+ }
+
+ $posts = $factory->create();
+ $comments = $posts->flatMap(fn (Post $post): Collection => $post->comments);
+
+ $this->assertCount(6, $comments);
+ $this->assertCount(1, $comments->pluck('user_id')->unique());
+ }
+
public function testNoModelsCanBeProvidedToRecycle(): void
{
Factory::guessFactoryNamesUsing(function ($model) {
@@ -1540,6 +1619,16 @@ public function commentable()
{
return $this->morphTo();
}
+
+ /**
+ * Get the comment's author.
+ *
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
}
class RoleFactory extends Factory
From 409fa04e31fb2409e853dffd0b2c03d80fbc23ea Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:23:23 +0000
Subject: [PATCH 06/22] Replay morph relation constraints in existence queries
Expose replayMacros() and apply the buffered morph relation calls to the concrete belongs-to query before merging constraints. has() and whereHasMorph() now retain withTrashed(), while declared owner-key handling and coroutine-safe callbacks remain unchanged.
Port Laravel framework #61846 (1d71d7e6ef) from target 5d7797528723107cd4f495326838a7d7d67fbb20 with all three integration assertions and typed fixtures.
The regression reproduces omission of the soft-deleted parent before correction. Complete morph integration and builder tests, formatting and full static analysis pass.
---
.../Concerns/QueriesRelationships.php | 2 +
.../src/Eloquent/Relations/MorphTo.php | 2 +-
.../Database/EloquentWhereHasMorphTest.php | 43 +++++++++++++++++++
3 files changed, 46 insertions(+), 1 deletion(-)
diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php
index ca7ab0a1e3..dd7426e492 100644
--- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php
+++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php
@@ -299,6 +299,8 @@ protected function getBelongsToRelation(MorphTo $relation, string $type): Belong
);
});
+ $relation->replayMacros($belongsTo->getQuery());
+
$belongsTo->getQuery()->mergeConstraintsFrom($relation->getQuery());
// @phpstan-ignore return.type (TModel IS TDeclaringModel in this context)
diff --git a/src/database/src/Eloquent/Relations/MorphTo.php b/src/database/src/Eloquent/Relations/MorphTo.php
index 8b5d48c98a..bbcd48827f 100644
--- a/src/database/src/Eloquent/Relations/MorphTo.php
+++ b/src/database/src/Eloquent/Relations/MorphTo.php
@@ -428,7 +428,7 @@ public function onlyTrashed(): static
* @param Builder $query
* @return Builder
*/
- protected function replayMacros(Builder $query): Builder
+ public function replayMacros(Builder $query): Builder
{
foreach ($this->macroBuffer as $macro) {
$query->{$macro['method']}(...$macro['parameters']);
diff --git a/tests/Integration/Database/EloquentWhereHasMorphTest.php b/tests/Integration/Database/EloquentWhereHasMorphTest.php
index a18ce3f370..405c56fe4e 100644
--- a/tests/Integration/Database/EloquentWhereHasMorphTest.php
+++ b/tests/Integration/Database/EloquentWhereHasMorphTest.php
@@ -7,6 +7,7 @@
use Hypervel\Contracts\Database\Query\Expression as ExpressionContract;
use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Model;
+use Hypervel\Database\Eloquent\Relations\MorphTo;
use Hypervel\Database\Eloquent\Relations\Relation;
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Database\Query\Expression;
@@ -38,6 +39,11 @@ protected function afterRefreshingDatabase(): void
$table->softDeletes();
});
+ Schema::create('likes', function (Blueprint $table) {
+ $table->increments('id');
+ $table->morphs('likeable');
+ });
+
$models = [];
$models[] = Post::create(['title' => 'foo']);
@@ -235,6 +241,16 @@ public function testWhereHasMorphWithOwnerKey(): void
$this->assertEquals([1], $comments->pluck('id')->all());
}
+ public function testWhereHasMorphWithTrashed(): void
+ {
+ Like::create(['likeable_type' => Post::class, 'likeable_id' => 1]);
+ Like::create(['likeable_type' => Post::class, 'likeable_id' => 3]);
+
+ $this->assertEquals([1, 2], Like::has('likeableWithTrashed')->orderBy('id')->pluck('id')->all());
+ $this->assertEquals([1, 2], Like::whereHasMorph('likeableWithTrashed', Post::class)->orderBy('id')->pluck('id')->all());
+ $this->assertEquals([1], Like::has('likeable')->pluck('id')->all());
+ }
+
public function testHasMorph(): void
{
$comments = Comment::hasMorph('commentable', Post::class)->orderBy('id')->get();
@@ -372,6 +388,33 @@ public function commentableWithOwnerKey()
}
}
+class Like extends Model
+{
+ public bool $timestamps = false;
+
+ protected array $guarded = [];
+
+ /**
+ * Get the liked model.
+ *
+ * @return MorphTo
+ */
+ public function likeable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ /**
+ * Get the liked model, including soft deleted models.
+ *
+ * @return MorphTo
+ */
+ public function likeableWithTrashed(): MorphTo
+ {
+ return $this->morphTo('likeable')->withTrashed();
+ }
+}
+
class Post extends Model
{
use SoftDeletes;
From 73e09fe796f3067e5ca2600107238650c5b1bfd9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:24:07 +0000
Subject: [PATCH 07/22] Separate compiled Blade views by their active echo
format
Give non-default echo formats their own compiled filenames, reading the existing coroutine-local format override. Key CompilerEngine freshness by the actual compiled file so a fresh default variant cannot hide a stale custom variant. Reuse the resolved path during evaluation and missing-file recovery without adding another cache or filesystem pass.
Port Laravel framework #61822 (6bff072eaf) from target 5d7797528723107cd4f495326838a7d7d67fbb20. Include both upstream cached-view regressions and all applicable mail assertions. Validate parsed MIME attachment names, content IDs, disposition and encoding instead of depending on unfolded headers for the longer filenames. Regenerating Blade for follow-up ac4ce420c7 leaves its existing annotation unchanged.
Complete changed files, affected view/mail suites, formatting and full static analysis pass. The freshness regression reproduces reuse of the wrong variant before correction.
---
src/view/src/Compilers/BladeCompiler.php | 20 ++++-
src/view/src/Engines/CompilerEngine.php | 17 ++--
.../Mail/SendingMarkdownMailTest.php | 38 ++++----
.../Integration/View/BladeCachedViewsTest.php | 89 +++++++++++++++++++
tests/View/ViewCompilerEngineTest.php | 27 +++++-
5 files changed, 163 insertions(+), 28 deletions(-)
create mode 100644 tests/Integration/View/BladeCachedViewsTest.php
diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php
index 9f8282fc74..dbcaa74394 100644
--- a/src/view/src/Compilers/BladeCompiler.php
+++ b/src/view/src/Compilers/BladeCompiler.php
@@ -45,6 +45,8 @@ class BladeCompiler extends Compiler implements CompilerInterface
use Concerns\CompilesUseStatements;
use ReflectsClosures;
+ public const string DEFAULT_ECHO_FORMAT = 'e(%s)';
+
/**
* Temporarily store the raw blocks found in the template.
*/
@@ -122,7 +124,7 @@ class BladeCompiler extends Compiler implements CompilerInterface
/**
* The "regular" / legacy echo string format.
*/
- protected string $echoFormat = 'e(%s)';
+ protected string $echoFormat = self::DEFAULT_ECHO_FORMAT;
/**
* The array of anonymous component paths to search for components in.
@@ -1044,6 +1046,22 @@ public function precompiler(callable $precompiler): void
$this->precompilers[] = $precompiler;
}
+ /**
+ * Get the path to the compiled version of a view.
+ */
+ public function getCompiledPath(string $path): string
+ {
+ $compiled = parent::getCompiledPath($path);
+ $echoFormat = $this->getEchoFormat();
+
+ // Non-default echo formats need their own compiled file so cached views are not reused...
+ if ($echoFormat === self::DEFAULT_ECHO_FORMAT) {
+ return $compiled;
+ }
+
+ return Str::beforeLast($compiled, '.') . '_' . hash('xxh128', $echoFormat) . '.' . $this->compiledExtension;
+ }
+
/**
* Execute the given callback using a custom echo format.
*/
diff --git a/src/view/src/Engines/CompilerEngine.php b/src/view/src/Engines/CompilerEngine.php
index d1a8e2a055..311f415d8a 100755
--- a/src/view/src/Engines/CompilerEngine.php
+++ b/src/view/src/Engines/CompilerEngine.php
@@ -24,7 +24,7 @@ class CompilerEngine extends PhpEngine
public const string COMPILED_PATH_CONTEXT_KEY = '__view.compiled_path';
/**
- * The view paths that were compiled or are not expired, keyed by the path.
+ * The compiled view paths verified as not expired.
*
* @var array
*/
@@ -50,14 +50,17 @@ public function get(string $path, array $data = []): string
$this->pushCompiledPath($path);
try {
+ $compiled = $this->compiler->getCompiledPath($path);
+
// If this given view has expired, which means it has simply been edited since
// it was last compiled, we will re-compile the views so we can evaluate a
// fresh copy of the view. We'll pass the compiler the path of the view.
- if (! isset(static::$compiledOrNotExpired[$path])) {
+ // One source can have separate compiled files for different echo formats.
+ if (! isset(static::$compiledOrNotExpired[$compiled])) {
if ($this->compiler->isExpired($path)) {
$this->compiler->compile($path);
} else {
- static::$compiledOrNotExpired[$path] = true;
+ static::$compiledOrNotExpired[$compiled] = true;
}
}
@@ -65,19 +68,19 @@ public function get(string $path, array $data = []): string
// typical PHP just like any other templates. We also keep a stack of views
// which have been rendered for right exception messages to be generated.
try {
- return $this->evaluatePath($this->compiler->getCompiledPath($path), $data);
+ return $this->evaluatePath($compiled, $data);
} catch (ViewException $e) {
if ((new Stringable($e->getMessage()))->doesntContain(['No such file or directory', 'File does not exist at path'])) {
throw $e;
}
- if (! isset(static::$compiledOrNotExpired[$path])) {
+ if (! isset(static::$compiledOrNotExpired[$compiled])) {
throw $e;
}
$this->compiler->compile($path);
- return $this->evaluatePath($this->compiler->getCompiledPath($path), $data);
+ return $this->evaluatePath($compiled, $data);
}
} finally {
$this->popCompiledPath();
@@ -144,7 +147,7 @@ public function getCompiler(): CompilerInterface
}
/**
- * Clear the cache of views that were compiled or not expired.
+ * Clear the cache of compiled view paths verified as not expired.
*
* Boot or tests only when called directly. Clears the worker-wide compile-check
* cache shared by every coroutine; concurrent compilations may re-check disk
diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php
index f1cb82e5f6..e84a7044aa 100644
--- a/tests/Integration/Mail/SendingMarkdownMailTest.php
+++ b/tests/Integration/Mail/SendingMarkdownMailTest.php
@@ -10,7 +10,6 @@
use Hypervel\Mail\Mailables\Envelope;
use Hypervel\Mail\Markdown;
use Hypervel\Support\Facades\Mail;
-use Hypervel\Support\Stringable;
use Hypervel\Testbench\TestCase;
use Symfony\Component\Mime\Email;
@@ -96,23 +95,28 @@ public function testEmbed(): void
$mailable->assertSeeInText('Embed content: ');
$mailable->assertDontSeeInText('Embed content: cid:');
- $email = $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->toString();
-
- $cid = rtrim(explode(' cid:', (new Stringable($email))->explode("\r\n")
- ->filter(fn (string $line): bool => str_contains($line, ' content: cid:'))
- ->first())[1], '=');
-
- $filename = explode('Embed file: ', (new Stringable($email))->explode("\r\n")
- ->filter(fn (string $line): bool => str_contains($line, ' file:'))
- ->first())[1];
+ /** @var Email $email */
+ $email = $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage();
- $this->assertStringContainsString(<<\r
- EOT, $email);
+ // The longer HTML filename may be folded or encoded in MIME headers.
+ $this->assertSame(1, preg_match('/Embed file: ([^\s<]+)/', $email->getHtmlBody(), $filename));
+ $this->assertSame(1, preg_match('/Embed content: cid:([^\s<]+)/', $email->getHtmlBody(), $contentId));
+ $this->assertSame(1, preg_match('/Embed file: ([^\s<]+)/', $email->getTextBody(), $textFilename));
+ $this->assertCount(1, $attachments = $email->getAttachments());
+ $attachment = $attachments[0];
+
+ $this->assertSame($filename[1], $attachment->getFilename());
+ $this->assertSame($contentId[1], $attachment->getContentId());
+ $this->assertSame('inline', $attachment->getDisposition());
+ $this->assertSame('application', $attachment->getMediaType());
+ $this->assertSame('x-php', $attachment->getMediaSubtype());
+ $this->assertSame('base64', $attachment->getPreparedHeaders()->get('Content-Transfer-Encoding')->getBodyAsString());
+ $this->assertStringStartsWith(pathinfo($textFilename[1], PATHINFO_FILENAME) . '_', $filename[1]);
+
+ $raw = $email->toString();
+ $this->assertStringContainsString('Content-Type: application/x-php;', $raw);
+ $this->assertStringContainsString(pathinfo($textFilename[1], PATHINFO_FILENAME), $raw);
+ $this->assertStringContainsString('Content-ID: <', $raw);
}
public function testEmbedData(): void
diff --git a/tests/Integration/View/BladeCachedViewsTest.php b/tests/Integration/View/BladeCachedViewsTest.php
new file mode 100644
index 0000000000..a12b0fc0c4
--- /dev/null
+++ b/tests/Integration/View/BladeCachedViewsTest.php
@@ -0,0 +1,89 @@
+directory = ParallelTesting::tempDir('BladeCachedViewsTest');
+ (new Filesystem)->deleteDirectory($this->directory);
+ (new Filesystem)->ensureDirectoryExists($this->directory);
+ }
+
+ /**
+ * Clean up the test environment.
+ */
+ protected function tearDown(): void
+ {
+ (new Filesystem)->deleteDirectory($this->directory);
+
+ parent::tearDown();
+ }
+
+ public function testCompiledPathDiffersWhenEchoFormatChanges(): void
+ {
+ $compiler = Blade::getFacadeRoot();
+ $path = $this->directory . '/uses-link.blade.php';
+
+ $default = $compiler->getCompiledPath($path);
+
+ $custom = $compiler->usingEchoFormat(
+ 'new \Hypervel\Support\EncodedHtmlString(%s)',
+ fn (): string => $compiler->getCompiledPath($path)
+ );
+
+ $this->assertNotSame($default, $custom);
+ $this->assertSame($default, $compiler->getCompiledPath($path));
+ $this->assertStringEndsWith('.php', $custom);
+ }
+
+ public function testCachedViewIsNotReusedForADifferentEchoFormat(): void
+ {
+ $compiler = Blade::getFacadeRoot();
+ $path = $this->directory . '/echo-format.blade.php';
+
+ file_put_contents($path, '{{ $value }}');
+
+ try {
+ $value = '[link](https://evil.example)';
+
+ // Compile with the default echo format, as "view:cache" does.
+ $compiler->compile($path);
+ $this->assertFileExists($default = $compiler->getCompiledPath($path));
+
+ $rendered = $compiler->usingEchoFormat(
+ 'new \Hypervel\Support\EncodedHtmlString(%s)',
+ function () use ($compiler, $path, $default, $value): string {
+ EncodedHtmlString::encodeUsing(fn (string $string): string => 'SECURED:' . $string);
+
+ $this->assertNotSame($default, $compiler->getCompiledPath($path));
+
+ return View::file($path, ['value' => $value])->render();
+ }
+ );
+
+ $this->assertSame('SECURED:' . $value, $rendered);
+ } finally {
+ @unlink($path);
+ }
+ }
+}
diff --git a/tests/View/ViewCompilerEngineTest.php b/tests/View/ViewCompilerEngineTest.php
index c10831069f..7624019739 100755
--- a/tests/View/ViewCompilerEngineTest.php
+++ b/tests/View/ViewCompilerEngineTest.php
@@ -131,7 +131,7 @@ public function testViewsAreRecompiledWhenCompiledViewIsMissingViaFileNotFoundEx
$engine->getCompiler()
->expects('getCompiledPath')
- ->times(3)
+ ->twice()
->with($path)
->andReturn($compiled);
@@ -171,7 +171,7 @@ public function testViewsAreRecompiledWhenCompiledViewIsMissingViaRequireExcepti
$engine->getCompiler()
->expects('getCompiledPath')
- ->times(3)
+ ->twice()
->with($path)
->andReturn($compiled);
@@ -213,7 +213,7 @@ public function testViewsAreRecompiledJustOnceWhenCompiledViewIsMissing(): void
$engine->getCompiler()
->expects('getCompiledPath')
- ->times(3)
+ ->twice()
->with($path)
->andReturn($compiled);
@@ -307,6 +307,27 @@ public function testExpiredViewsAreCheckedAndCompiledOnEveryRender(): void
$engine->get($path);
}
+ public function testEachCompiledVariantHasItsOwnFreshnessCheck(): void
+ {
+ $path = '/views/example.blade.php';
+ $files = m::mock(Filesystem::class);
+ $engine = $this->getEngine($files);
+
+ $engine->getCompiler()->expects('getCompiledPath')->twice()->with($path)
+ ->andReturn('/compiled/default.php', '/compiled/custom.php');
+ $engine->getCompiler()->expects('isExpired')->twice()->with($path)->andReturn(false, true);
+ $engine->getCompiler()->expects('compile')->with($path);
+ $files->expects('getRequire')->with('/compiled/default.php', [])->andReturnUsing(static function (): void {
+ echo 'default';
+ });
+ $files->expects('getRequire')->with('/compiled/custom.php', [])->andReturnUsing(static function (): void {
+ echo 'custom';
+ });
+
+ $this->assertSame('default', $engine->get($path));
+ $this->assertSame('custom', $engine->get($path));
+ }
+
public function testCompiledPathIsPoppedAfterSuccessfulRender(): void
{
$path = __DIR__ . '/Fixtures/foo.php';
From 84a1d2b00299b1bbbe5c59ffd9b8d0894268d6a9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:24:07 +0000
Subject: [PATCH 08/22] Recover Blade error sources from compiled file metadata
Read the source path recorded in the compiled file footer after view rendering unwinds. The previous render-stack lookup was already empty at that point and could reconstruct the wrong filename after an echo-format override ended. Remove the obsolete getKnownPaths() helper while retaining the mapper's existing constructor and public lookup API.
Use the configured compiled directory boundary without assuming a .php extension. Preserve fallback behavior when a compiled file or its source disappears. Apply the same extension handling to dump-source detection, and propagate cancellation carried by a source-mapping cleanup error instead of treating it as an ordinary mapping failure.
Actual failing-view regressions cover restored source paths and line numbers after unwind, default and custom formats, custom extensions, missing files and cancellation identity. Complete mapper/dumper files and affected Foundation checks, formatting and full static analysis pass.
---
.../src/Concerns/ResolvesDumpSource.php | 3 +-
.../Renderer/Mappers/BladeMapper.php | 42 ++++--------
tests/Foundation/Console/CliDumperTest.php | 13 ++--
.../Exceptions/RenderBladeFilesTest.php | 66 +++++++++++++++++--
4 files changed, 83 insertions(+), 41 deletions(-)
diff --git a/src/foundation/src/Concerns/ResolvesDumpSource.php b/src/foundation/src/Concerns/ResolvesDumpSource.php
index c181dfacd3..f2de305192 100644
--- a/src/foundation/src/Concerns/ResolvesDumpSource.php
+++ b/src/foundation/src/Concerns/ResolvesDumpSource.php
@@ -101,7 +101,8 @@ protected function isCompiledViewFile(string $file): bool
return false;
}
- return str_starts_with($file, $this->compiledViewPath) && str_ends_with($file, '.php');
+ // The compiler can use an extension other than .php; the footer identifies the source.
+ return str_starts_with($file, rtrim($this->compiledViewPath, '/\\') . DIRECTORY_SEPARATOR);
}
/**
diff --git a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
index b76ac90e6e..6ff6852caa 100644
--- a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
+++ b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
@@ -4,14 +4,12 @@
namespace Hypervel\Foundation\Exceptions\Renderer\Mappers;
-use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Foundation\Application;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
use Hypervel\View\Compilers\BladeCompiler;
-use Hypervel\View\Engines\CompilerEngine;
use Hypervel\View\ViewException;
-use Swoole\Coroutine\CanceledException;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Throwable;
@@ -86,33 +84,19 @@ public function map(FlattenException $exception): FlattenException
*/
public function findCompiledView(string $compiledPath): ?string
{
- return once(fn () => $this->getKnownPaths())[$compiledPath] ?? null;
- }
-
- /**
- * Get the list of known paths from the compiler engine.
- *
- * In Hypervel, compiled paths are stored in coroutine Context (not an instance
- * property) because the CompilerEngine is a process-global singleton in Swoole.
- *
- * @return array
- */
- protected function getKnownPaths(): array
- {
- $lastCompiled = CoroutineContext::get(CompilerEngine::COMPILED_PATH_CONTEXT_KEY, []);
+ if (! str_starts_with($compiledPath, rtrim(config()->string('view.compiled'), '/\\') . DIRECTORY_SEPARATOR)) {
+ return null;
+ }
- $knownPaths = [];
- foreach ($lastCompiled as $lastCompiledPath) {
- $compiledPath = $this->bladeCompiler->getCompiledPath($lastCompiledPath);
- $compiledPath = realpath($compiledPath);
- $originalPath = realpath($lastCompiledPath);
+ // Rendering has unwound by now and the active echo format may differ,
+ // so read the source path recorded in the compiled file's footer.
+ $contents = @file_get_contents($compiledPath);
- if (is_string($compiledPath) && is_string($originalPath)) {
- $knownPaths[$compiledPath] = $originalPath;
- }
+ if ($contents === false || ! preg_match('/\/\*\*PATH\s(.*)\sENDPATH/', $contents, $matches)) {
+ return null;
}
- return $knownPaths;
+ return is_file($matches[1]) ? $matches[1] : null;
}
/**
@@ -161,9 +145,11 @@ protected function compileSourcemap(string $value): string
$value = $this->bladeCompiler->compileString($value);
return $this->trimEmptyLines($value);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $e) {
+ if (Coroutine::causedByCancellation($e)) {
+ throw $e;
+ }
+
report($e);
return $value;
diff --git a/tests/Foundation/Console/CliDumperTest.php b/tests/Foundation/Console/CliDumperTest.php
index 912056a020..14784545a5 100644
--- a/tests/Foundation/Console/CliDumperTest.php
+++ b/tests/Foundation/Console/CliDumperTest.php
@@ -9,6 +9,7 @@
use Hypervel\Foundation\Application;
use Hypervel\Foundation\Console\CliDumper;
use Hypervel\Tests\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionClass;
use RuntimeException;
use stdClass;
@@ -141,10 +142,10 @@ public function testNull(): void
$this->assertSame($expected, $output);
}
- public function testWhenIsFileViewIsNotViewCompiled(): void
+ #[TestWith(['/my-work-directory/routes/console.php'])]
+ #[TestWith(['/my-work-directory/storage/framework/views-other/example.php'])]
+ public function testWhenIsFileViewIsNotViewCompiled(string $file): void
{
- $file = '/my-work-directory/routes/console.php';
-
$output = new BufferedOutput;
$dumper = new CliDumper(
$output,
@@ -159,9 +160,11 @@ public function testWhenIsFileViewIsNotViewCompiled(): void
$this->assertFalse($isCompiledViewFile);
}
- public function testWhenIsFileViewIsViewCompiled(): void
+ #[TestWith(['php'])]
+ #[TestWith(['cache'])]
+ public function testWhenIsFileViewIsViewCompiled(string $extension): void
{
- $file = '/my-work-directory/storage/framework/views/6687c33c38b71a8560.php';
+ $file = '/my-work-directory/storage/framework/views/6687c33c38b71a8560.' . $extension;
$output = new BufferedOutput;
$dumper = new CliDumper(
diff --git a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
index 63d674f01e..6c28c3964d 100644
--- a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
+++ b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
@@ -6,6 +6,7 @@
use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\Exceptions\Renderer\Mappers\BladeMapper;
use Hypervel\Testbench\Attributes\WithConfig;
use Hypervel\Testbench\TestCase;
@@ -13,9 +14,14 @@
use Hypervel\View\Compilers\BladeCompiler;
use Hypervel\View\Engines\CompilerEngine;
use Hypervel\View\Engines\EngineResolver;
+use Hypervel\View\ViewException;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionMethod;
+use RuntimeException;
use Swoole\Coroutine\CanceledException;
+use Symfony\Component\ErrorHandler\Exception\FlattenException;
+use Throwable;
use function Hypervel\Testbench\after_resolving;
use function Hypervel\Testbench\package_path;
@@ -28,6 +34,8 @@ class RenderBladeFilesTest extends TestCase
*/
protected function defineEnvironment(ApplicationContract $app): void
{
+ $app->make('config')->set('view.compiled_extension', 'cache');
+
after_resolving($app, 'view.engine.resolver', function (EngineResolver $resolver): void {
$resolver->resolve('blade')->getCompiler()->withoutComponentTags();
});
@@ -117,13 +125,54 @@ public function testRoutingTooltipRendersMultilineSafely(): void
public function testBladeMapperSkipsMissingOriginalAndCompiledPaths(): void
{
- CoroutineContext::set(CompilerEngine::COMPILED_PATH_CONTEXT_KEY, [
- ParallelTesting::tempDir('MissingBladeSource') . '/missing.blade.php',
- ]);
+ $compiler = $this->app->make('blade.compiler');
+ $path = ParallelTesting::tempDir('MissingBladeSource') . '/missing.blade.php';
+ $compiled = $compiler->getCompiledPath($path);
+ $files = new Filesystem;
+ $files->delete($compiled);
+ $mapper = $this->app->make(BladeMapper::class);
+
+ $this->assertNull($mapper->findCompiledView($compiled));
+
+ try {
+ $files->ensureDirectoryExists(dirname($compiled));
+ $files->put($compiled, '/**PATH ' . $path . ' ENDPATH**/');
+ $this->assertNull($mapper->findCompiledView($compiled));
+ } finally {
+ $files->delete($compiled);
+ }
+ }
- $method = new ReflectionMethod(BladeMapper::class, 'getKnownPaths');
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testBladeMapperFindsTheSourceAfterRenderingHasUnwound(bool $customFormat): void
+ {
+ $files = new Filesystem;
+ $directory = ParallelTesting::tempDir('RenderBladeFilesTest');
+ $files->deleteDirectory($directory);
+ $files->ensureDirectoryExists($directory);
+ $path = $directory . '/failing.blade.php';
+ $files->put($path, "First line\n{{ missing_blade_mapping_test_function() }}");
- $this->assertSame([], $method->invoke($this->app->make(BladeMapper::class)));
+ try {
+ $compiler = $this->app->make('blade.compiler');
+ $render = fn (): string => $this->app->make('view')->file($path)->render();
+
+ try {
+ $customFormat
+ ? $compiler->usingEchoFormat('strtoupper(e(%s))', $render)
+ : $render();
+ $this->fail('The view did not fail.');
+ } catch (ViewException $exception) {
+ $this->assertSame([], CoroutineContext::get(CompilerEngine::COMPILED_PATH_CONTEXT_KEY));
+ $mapped = $this->app->make(BladeMapper::class)->map(FlattenException::createFromThrowable($exception));
+ $frames = array_values(array_filter($mapped->getTrace(), static fn (array $frame): bool => ($frame['file'] ?? null) === $path));
+ $this->assertNotEmpty($frames);
+ $this->assertSame(2, $frames[0]['line']);
+ }
+ } finally {
+ $files->deleteDirectory($directory);
+ }
}
public function testBladeMapperPreservesCompiledLineWhenOriginalDisappears(): void
@@ -137,9 +186,12 @@ public function testBladeMapperPreservesCompiledLineWhenOriginalDisappears(): vo
);
}
- public function testBladeMapperPreservesCancellationDuringSourceMapping(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testBladeMapperPreservesCancellationDuringSourceMapping(bool $throwingCleanup): void
{
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$compiler = m::mock(BladeCompiler::class);
$compiler->shouldReceive('compileString')->once()->andThrow($cancellation);
$method = new ReflectionMethod(BladeMapper::class, 'compileSourcemap');
@@ -148,7 +200,7 @@ public function testBladeMapperPreservesCancellationDuringSourceMapping(): void
$method->invoke(new BladeMapper($compiler), 'plain text');
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
From 93b9ecdd869fc9b85a414a1a1c51b2224e5e0c21 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 08:24:07 +0000
Subject: [PATCH 09/22] Preserve cancellation cleanup failures without
duplicate or lost reports
Recognize cancellation carried by throwing cleanup at exception-rendering, logger-resolution and termination callback boundaries. Preserve the original exception, cancellation precedence and unconditional lifecycle cleanup while preventing later callbacks and emergency fallbacks from treating cancellation as ordinary failure.
Keep direct cancellation excluded from reporting, but report genuine cleanup failures once as they unwind through middleware, kernels and transport boundaries. report() owns duplicate suppression using original exception identity, including when custom mappers create fresh wrappers. The existing weak map records accepted original and mapped exceptions so immediate and deferred Telescope filters do not hide their first report or repeat sampling and rate-limit decisions. Ordinary repeated reports remain allowed unless duplicate suppression is enabled.
Extend existing lifecycle and handler regressions, verify the real kernel/middleware path and both Telescope filter APIs, and restore the upstream watcher's exception-line assertions. Declare the log package's direct coroutine dependency. No new persistent state or changes to the current-report marker are introduced.
The regressions reproduce swallowed cancellation, repeated reports and dropped Telescope entries before correction. Complete changed files, affected Foundation/Routing/HTTP-server/Telescope/fake/OpenTelemetry suites, package-mode Testbench checks, formatting and full static analysis pass.
---
src/foundation/src/Application.php | 5 +-
.../src/Bootstrap/HandleExceptions.php | 13 ++--
src/foundation/src/Console/Kernel.php | 27 ++++---
src/foundation/src/Exceptions/Handler.php | 59 +++++++++------
src/foundation/src/Http/Kernel.php | 45 +++++++-----
src/log/composer.json | 1 +
src/log/src/LogManager.php | 8 ++-
.../Bootstrap/HandleExceptionsTest.php | 14 ++--
.../Console/KernelTerminateTest.php | 16 +++--
.../Foundation/FoundationApplicationTest.php | 9 ++-
.../FoundationExceptionsHandlerTest.php | 71 ++++++++++++-------
tests/Foundation/Http/KernelTest.php | 25 +++++--
.../Foundation/ExceptionHandlerTest.php | 41 +++++++++++
tests/Integration/Log/LogManagerTest.php | 28 ++++++++
.../Watchers/ExceptionWatcherTest.php | 40 +++++++++++
15 files changed, 301 insertions(+), 101 deletions(-)
diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php
index e577397b0e..a7e317258a 100644
--- a/src/foundation/src/Application.php
+++ b/src/foundation/src/Application.php
@@ -17,6 +17,7 @@
use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract;
use Hypervel\Contracts\Http\Kernel as HttpKernelContract;
use Hypervel\Contracts\Translation\Translator as TranslatorContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Events\EventServiceProvider;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\Configuration\ApplicationBuilder;
@@ -33,7 +34,6 @@
use JsonException;
use ReflectionClass;
use RuntimeException;
-use Swoole\Coroutine\CanceledException;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
@@ -1234,8 +1234,7 @@ public function terminate(): void
try {
$this->call($callback);
} catch (Throwable $throwable) {
- // Cancellation is injected asynchronously, so a dedicated catch analyzes as unreachable here.
- if ($throwable instanceof CanceledException) {
+ if (Coroutine::causedByCancellation($throwable)) {
throw $throwable;
}
diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php
index 221515c544..6000490cbb 100644
--- a/src/foundation/src/Bootstrap/HandleExceptions.php
+++ b/src/foundation/src/Bootstrap/HandleExceptions.php
@@ -9,6 +9,7 @@
use Hypervel\Contracts\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Contracts\Foundation\Application;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Foundation\Configuration\ConfigMutationTracker;
use Hypervel\Log\LogManager;
use Hypervel\Support\Env;
@@ -16,7 +17,6 @@
use PHPUnit\Framework\TestCase;
use PHPUnit\Runner\ErrorHandler;
use Psr\Log\LoggerInterface;
-use Swoole\Coroutine\CanceledException;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Throwable;
@@ -135,8 +135,7 @@ public function handleDeprecationError(string $message, string $file, int $line,
try {
$logger = static::$app->make(LogManager::class);
} catch (Throwable $exception) {
- // Cancellation is injected asynchronously, so a dedicated catch analyzes as unreachable here.
- if ($exception instanceof CanceledException) {
+ if (Coroutine::causedByCancellation($exception)) {
throw $exception;
}
@@ -165,9 +164,11 @@ public function handleDeprecationError(string $message, string $file, int $line,
));
}
});
- } catch (CanceledException $exception) {
- throw $exception;
- } catch (Throwable) {
+ } catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return;
}
}
diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php
index 3256541f87..fa22ed920e 100644
--- a/src/foundation/src/Console/Kernel.php
+++ b/src/foundation/src/Console/Kernel.php
@@ -17,6 +17,7 @@
use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Foundation\Application as ContainerContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Foundation\Bootstrap\BootProviders;
use Hypervel\Foundation\Bus\PendingDispatch;
use Hypervel\Foundation\Events\Terminating;
@@ -207,19 +208,23 @@ public function terminate(InputInterface $input, int $status): void
if ($this->events->hasListeners(Terminating::class)) {
$this->events->dispatch(new Terminating);
}
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception = $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception = $throwable;
+ }
}
if ($cancellation === null) {
try {
$this->app->terminate();
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
@@ -242,11 +247,13 @@ public function terminate(InputInterface $input, int $status): void
if ($this->commandStartedAt->diffInMilliseconds($end) > $threshold) {
$handler($this->commandStartedAt, $input, $status);
}
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
-
- break;
} catch (Throwable $throwable) {
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+
+ break;
+ }
+
$exception ??= $throwable;
}
}
diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php
index e127ab7fff..1bfb9caee8 100644
--- a/src/foundation/src/Exceptions/Handler.php
+++ b/src/foundation/src/Exceptions/Handler.php
@@ -477,12 +477,28 @@ public function report(Throwable $e, array $context = [], ?string $level = null)
return;
}
+ // Cancellation unwinds through multiple reporting boundaries. Suppress duplicates
+ // using the original exception because mappers may create new wrappers each time.
+ $original = $e;
+ $deduplicate = $this->withoutDuplicates || Coroutine::causedByCancellation($e);
+
+ if ($deduplicate && $this->hasReportedException($original)) {
+ return;
+ }
+
$e = $this->mapException($e);
if ($this->shouldntReport($e)) {
return;
}
+ // Keep the accepted decision for observers, including deferred Telescope batch filters.
+ $this->reportedException($original);
+
+ if ($e !== $original) {
+ $this->reportedException($e);
+ }
+
$this->reportThrowable($e, $context, $level);
}
@@ -496,10 +512,6 @@ public function report(Throwable $e, array $context = [], ?string $level = null)
*/
protected function reportThrowable(Throwable $e, array $context = [], ?string $level = null): void
{
- if ($this->withoutDuplicates) {
- $this->reportedException($e);
- }
-
if (Reflector::isCallable($reportCallable = [$e, 'report'])
&& $this->container->call($reportCallable) !== false
) {
@@ -518,9 +530,11 @@ protected function reportThrowable(Throwable $e, array $context = [], ?string $l
try {
$logger = $this->newLogger();
- } catch (CanceledException $exception) {
- throw $exception;
- } catch (Exception) {
+ } catch (Exception $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
throw $e;
}
@@ -608,7 +622,8 @@ protected function reportedExceptionMap(): WeakMap
*/
public function shouldReport(Throwable $e): bool
{
- return ! $this->shouldntReport($e);
+ // Log observers must not sample or consume a rate limit again for an admitted report.
+ return $this->hasReportedException($e) || ! $this->shouldntReport($e);
}
/**
@@ -620,10 +635,6 @@ protected function shouldntReport(Throwable $e): bool
return true;
}
- if ($this->withoutDuplicates && $this->hasReportedException($e)) {
- return true;
- }
-
if ($e instanceof ShouldntReport) {
return true;
}
@@ -780,9 +791,11 @@ protected function context(): array
return array_filter([
'userId' => Auth::id(),
]);
- } catch (CanceledException $exception) {
- throw $exception;
- } catch (Throwable) {
+ } catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return [];
}
}
@@ -809,7 +822,7 @@ public function buildContextUsing(Closure $contextCallback): static
*/
public function render(Request $request, Throwable $e): SymfonyResponse
{
- if ($e instanceof CanceledException) {
+ if (Coroutine::causedByCancellation($e)) {
throw $e;
}
@@ -1115,9 +1128,11 @@ protected function renderExceptionContent(Throwable $e): string
}
return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug'));
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $e) {
+ if (Coroutine::causedByCancellation($e)) {
+ throw $e;
+ }
+
return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug'));
}
}
@@ -1160,9 +1175,11 @@ protected function renderHttpException(HttpExceptionInterface $e): SymfonyRespon
$e->getStatusCode(),
$e->getHeaders()
);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $t) {
+ if (Coroutine::causedByCancellation($t)) {
+ throw $t;
+ }
+
config()->boolean('app.debug') && throw $t;
$this->report($t);
@@ -1256,7 +1273,7 @@ protected function convertExceptionToArray(Throwable $e): array
*/
public function renderForConsole(OutputInterface $output, Throwable $e): void
{
- if ($e instanceof CanceledException) {
+ if (Coroutine::causedByCancellation($e)) {
throw $e;
}
diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php
index 47bacaa473..204e058fb7 100644
--- a/src/foundation/src/Http/Kernel.php
+++ b/src/foundation/src/Http/Kernel.php
@@ -12,6 +12,7 @@
use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Contracts\Foundation\Application;
use Hypervel\Contracts\Http\Kernel as KernelContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Foundation\Events\Terminating;
use Hypervel\Foundation\Http\Events\RequestHandled;
use Hypervel\Http\Request;
@@ -260,29 +261,35 @@ public function terminate(Request $request, Response $response): void
if ($events->hasListeners(Terminating::class)) {
$events->dispatch(new Terminating);
}
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception = $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception = $throwable;
+ }
}
if ($cancellation === null) {
try {
$this->terminateMiddleware($request, $response);
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
if ($cancellation === null) {
try {
$this->app->terminate();
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
@@ -304,17 +311,21 @@ public function terminate(Request $request, Response $response): void
if ($requestStartedAt->diffInMilliseconds($end) > $threshold) {
$handler($requestStartedAt, $request, $response);
}
- } catch (CanceledException $throwable) {
- throw $throwable;
} catch (Throwable $throwable) {
+ if (Coroutine::causedByCancellation($throwable)) {
+ throw $throwable;
+ }
+
$exception ??= $throwable;
}
}
}
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
@@ -378,9 +389,11 @@ protected function terminateMiddleware(Request $request, Response $response): vo
if ($terminable) {
$instance->terminate($request, $response);
}
- } catch (CanceledException $throwable) {
- throw $throwable;
} catch (Throwable $throwable) {
+ if (Coroutine::causedByCancellation($throwable)) {
+ throw $throwable;
+ }
+
$exception ??= $throwable;
}
}
diff --git a/src/log/composer.json b/src/log/composer.json
index 61f616ec47..f35a19e8e4 100644
--- a/src/log/composer.json
+++ b/src/log/composer.json
@@ -31,6 +31,7 @@
"hypervel/container": "^0.4",
"hypervel/context": "^0.4",
"hypervel/contracts": "^0.4",
+ "hypervel/coroutine": "^0.4",
"hypervel/macroable": "^0.4",
"hypervel/queue": "^0.4",
"hypervel/support": "^0.4",
diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php
index 9e1f430958..ce50c702d0 100644
--- a/src/log/src/LogManager.php
+++ b/src/log/src/LogManager.php
@@ -10,6 +10,7 @@
use Hypervel\Contracts\Log\ContextLogProcessor;
use Hypervel\Contracts\Support\Arrayable;
use Hypervel\Contracts\Support\Jsonable;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Log\Context\ResolvedContextLogProcessor;
use Hypervel\Log\Handlers\FingersCrossedHandler;
use Hypervel\Log\Handlers\RotatingFileHandler;
@@ -37,7 +38,6 @@
use ReflectionException;
use RuntimeException;
use Stringable;
-use Swoole\Coroutine\CanceledException;
use Throwable;
use UnitEnum;
@@ -169,9 +169,11 @@ protected function createLogger(?string $name, ?array $config = null, bool $cach
}
return $logger;
- } catch (CanceledException $e) {
- throw $e;
} catch (Throwable $e) {
+ if (Coroutine::causedByCancellation($e)) {
+ throw $e;
+ }
+
return tap($this->createEmergencyLogger(), function ($logger) use ($e) {
$logger->emergency('Unable to create configured logger. Using emergency logger.', [
'exception' => $e,
diff --git a/tests/Foundation/Bootstrap/HandleExceptionsTest.php b/tests/Foundation/Bootstrap/HandleExceptionsTest.php
index 2770f5a9df..04f7f6f68c 100644
--- a/tests/Foundation/Bootstrap/HandleExceptionsTest.php
+++ b/tests/Foundation/Bootstrap/HandleExceptionsTest.php
@@ -113,9 +113,12 @@ public function testPhpDeprecations(): void
)));
}
- public function testDeprecationLoggingPreservesCancellationDuringLoggerResolution(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDeprecationLoggingPreservesCancellationDuringLoggerResolution(bool $throwingCleanup): void
{
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$this->app->bind(LogManager::class, fn () => throw $cancellation);
try {
@@ -126,7 +129,7 @@ public function testDeprecationLoggingPreservesCancellationDuringLoggerResolutio
);
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
@@ -554,9 +557,12 @@ public function testIgnoreDeprecationIfLoggingFails(): void
);
}
- public function testDeprecationLoggingPreservesCancellationDuringChannelCreation(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDeprecationLoggingPreservesCancellationDuringChannelCreation(bool $throwingCleanup): void
{
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$logger = new LogManager($this->app);
$logger->extend('canceling', static fn (): never => throw $cancellation);
$this->config->set('logging.channels.deprecations', ['driver' => 'canceling']);
@@ -566,7 +572,7 @@ public function testDeprecationLoggingPreservesCancellationDuringChannelCreation
$this->handleExceptions()->handleDeprecationError('Deprecated behavior', __FILE__, __LINE__);
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
diff --git a/tests/Foundation/Console/KernelTerminateTest.php b/tests/Foundation/Console/KernelTerminateTest.php
index ece0a3e9f4..bf7624c1ab 100644
--- a/tests/Foundation/Console/KernelTerminateTest.php
+++ b/tests/Foundation/Console/KernelTerminateTest.php
@@ -12,10 +12,12 @@
use Hypervel\Support\CarbonImmutable;
use Hypervel\Testbench\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\ConsoleOutput;
+use Throwable;
class KernelTerminateTest extends TestCase
{
@@ -354,10 +356,13 @@ public function testEventFailureDoesNotSkipApplicationOrDurationHandlers(): void
$this->assertNull($kernel->commandStartedAt());
}
- public function testEventCancellationStopsLaterTerminationStagesAndClearsTheCommandStartTime(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testEventCancellationStopsLaterTerminationStagesAndClearsTheCommandStartTime(bool $throwingCleanup): void
{
$calls = [];
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$kernel = $this->app->make(KernelContract::class);
$kernel->command('foo', fn () => null);
$this->app->make(Dispatcher::class)->listen(Terminating::class, function () use (&$calls, $cancellation): void {
@@ -380,7 +385,7 @@ public function testEventCancellationStopsLaterTerminationStagesAndClearsTheComm
try {
$kernel->terminate($input, 0);
$this->fail('Expected console termination to preserve cancellation.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
@@ -388,11 +393,14 @@ public function testEventCancellationStopsLaterTerminationStagesAndClearsTheComm
$this->assertNull($kernel->commandStartedAt());
}
- public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFailure(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFailure(bool $throwingCleanup): void
{
$calls = [];
$eventFailure = new RuntimeException('event failed');
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$kernel = $this->app->make(KernelContract::class);
$kernel->command('foo', fn () => null);
$this->app->make(Dispatcher::class)->listen(Terminating::class, function () use (&$calls, $eventFailure): void {
@@ -417,7 +425,7 @@ public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFa
try {
$kernel->terminate($input, 0);
$this->fail('Expected duration cancellation to supersede the earlier failure.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php
index d055df195c..626bc699cc 100644
--- a/tests/Foundation/FoundationApplicationTest.php
+++ b/tests/Foundation/FoundationApplicationTest.php
@@ -26,11 +26,13 @@
use Hypervel\Tests\TestCase;
use JsonException;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Throwable;
class FoundationApplicationTest extends TestCase
{
@@ -427,11 +429,14 @@ public function testTerminationCallbacksAreExhaustiveAndPreserveTheFirstFailure(
$this->assertSame(['first', 'second'], $called);
}
- public function testTerminationCancellationSupersedesAnEarlierFailureAndStopsLaterCallbacks(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testTerminationCancellationSupersedesAnEarlierFailureAndStopsLaterCallbacks(bool $throwingCleanup): void
{
$app = new Application;
$called = [];
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$app->terminating(function () use (&$called): void {
$called[] = 'first';
@@ -450,7 +455,7 @@ public function testTerminationCancellationSupersedesAnEarlierFailureAndStopsLat
try {
$app->terminate();
$this->fail('Expected application termination to preserve cancellation.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php
index e4d893aea8..c711351fdf 100644
--- a/tests/Foundation/FoundationExceptionsHandlerTest.php
+++ b/tests/Foundation/FoundationExceptionsHandlerTest.php
@@ -50,6 +50,8 @@
use Mockery as m;
use OutOfRangeException;
use PHPUnit\Framework\AssertionFailedError;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerTrait;
use Psr\Log\LogLevel;
@@ -113,11 +115,13 @@ protected function getRealLogger(): LoggerSpy
return $logger;
}
- public function testHandlerReportsExceptionAsContext(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testHandlerReportsExceptionAsContext(bool $throwingCleanup): void
{
$logger = $this->getRealLogger();
- $this->handler->report(new RuntimeException('Exception message'));
+ $this->handler->report(new RuntimeException('Exception message', 0, $throwingCleanup ? new CanceledException : null));
$this->assertCount(1, $logger->logs);
$this->assertSame('error', $logger->logs[0]['level']);
@@ -136,24 +140,24 @@ public function testHandlerNeverReportsCancellation(): void
$this->handler->report($cancellation);
}
- public function testHandlerPreservesCancellationFromLoggerResolution(): void
+ #[DataProvider('cancellations')]
+ public function testHandlerPreservesCancellationFromLoggerResolution(Throwable $cancellation): void
{
$failure = new RuntimeException('failed');
- $cancellation = new CanceledException('canceled');
$this->container->bind(LoggerInterface::class, fn () => throw $cancellation);
try {
$this->handler->report($failure);
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
- public function testHandlerPreservesCancellationFromDefaultContext(): void
+ #[DataProvider('cancellations')]
+ public function testHandlerPreservesCancellationFromDefaultContext(Throwable $cancellation): void
{
- $cancellation = new CanceledException('canceled');
$auth = m::mock();
$auth->expects('id')->andThrow($cancellation);
$this->container->instance('auth', $auth);
@@ -171,7 +175,7 @@ public function defaultContext(): array
$handler->defaultContext();
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
} finally {
Auth::clearResolvedInstance();
@@ -179,23 +183,22 @@ public function defaultContext(): array
}
}
- public function testHandlerPreservesCancellationDuringRendering(): void
+ #[DataProvider('cancellations')]
+ public function testHandlerPreservesCancellationDuringRendering(Throwable $cancellation): void
{
- $cancellation = new CanceledException('canceled');
-
try {
$this->handler->render(Request::create('/'), $cancellation);
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
- public function testHandlerPreservesCancellationFromCustomRenderer(): void
+ #[DataProvider('cancellations')]
+ public function testHandlerPreservesCancellationFromCustomRenderer(Throwable $cancellation): void
{
$failure = new RuntimeException('failed');
- $cancellation = new CanceledException('canceled');
$renderer = m::mock(ExceptionRenderer::class);
$renderer->expects('render')->with($failure)->andThrow($cancellation);
$this->container->instance(ExceptionRenderer::class, $renderer);
@@ -205,20 +208,19 @@ public function testHandlerPreservesCancellationFromCustomRenderer(): void
$this->handler->render(Request::create('/'), $failure);
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
- public function testHandlerPreservesCancellationDuringConsoleRendering(): void
+ #[DataProvider('cancellations')]
+ public function testHandlerPreservesCancellationDuringConsoleRendering(Throwable $cancellation): void
{
- $cancellation = new CanceledException('canceled');
-
try {
$this->handler->renderForConsole(new BufferedOutput, $cancellation);
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
@@ -1014,9 +1016,9 @@ public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugTrue():
$this->executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs(true);
}
- public function testErrorViewCancellationEscapesWithoutReportingOrFallback(): void
+ #[DataProvider('cancellations')]
+ public function testErrorViewCancellationEscapesWithoutReportingOrFallback(Throwable $cancellation): void
{
- $cancellation = new CanceledException('canceled');
$this->viewFactory->expects('exists')->with('errors::404')->andReturnTrue();
$this->viewFactory->expects('make')->andThrow($cancellation);
$handler = new class($this->container) extends Handler {
@@ -1041,13 +1043,24 @@ protected function registerErrorViewPaths(): void
$handler->renderHttpExceptionForTest(new HttpException(404));
$this->fail('The cancellation was not preserved.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
$this->assertFalse($handler->reported);
}
+ /**
+ * Provide direct cancellation and cancellation carried by throwing cleanup.
+ */
+ public static function cancellations(): array
+ {
+ return [
+ 'direct' => [new CanceledException('canceled')],
+ 'cleanup' => [new RuntimeException('cleanup failed', 0, new CanceledException('canceled'))],
+ ];
+ }
+
public function testAssertExceptionIsThrown(): void
{
$this->assertThrows(function () {
@@ -1178,10 +1191,15 @@ public function testItReportsDuplicateExceptions(): void
$this->assertSame($reported, [$one, $one, $two]);
}
- public function testItCanDedupeExceptions(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testItCanDedupeExceptions(bool $mapped): void
{
$reported = [];
- $e = new RuntimeException('foo');
+ if ($mapped) {
+ $this->handler->map(RuntimeException::class, static fn (RuntimeException $exception): Exception => new Exception('mapped', 0, $exception));
+ }
+
$this->handler->reportable(function (Throwable $e) use (&$reported) {
$reported[] = $e;
@@ -1193,7 +1211,10 @@ public function testItCanDedupeExceptions(): void
$this->handler->report($one);
$this->handler->report($two = new RuntimeException('foo'));
- $this->assertSame($reported, [$one, $two]);
+ $this->assertSame([$one, $two], $mapped
+ ? array_map(static fn (Throwable $exception): Throwable => $exception->getPrevious(), $reported)
+ : $reported);
+ $this->assertTrue($this->handler->shouldReport($one));
}
public function testItDedupesByObjectIdentityNotEquality(): void
diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php
index 6725d6ef9d..8dc37d1a63 100644
--- a/tests/Foundation/Http/KernelTest.php
+++ b/tests/Foundation/Http/KernelTest.php
@@ -19,8 +19,10 @@
use Hypervel\Support\CarbonImmutable;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
+use Throwable;
use function Hypervel\Coroutine\parallel;
@@ -584,7 +586,9 @@ public function terminate(Request $request, Response $response): void
$this->assertNull($kernel->requestStartedAt());
}
- public function testTerminationCancellationStopsLaterStagesAndClearsTheRequestStartTime(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testTerminationCancellationStopsLaterStagesAndClearsTheRequestStartTime(bool $throwingCleanup): void
{
$called = [];
$app = new Application;
@@ -598,6 +602,7 @@ public function testTerminationCancellationStopsLaterStagesAndClearsTheRequestSt
$request = Request::create('/');
$response = $kernel->handle($request);
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$events->listen(function (Terminating $event) use (&$called, $cancellation): void {
$called[] = 'event';
@@ -625,7 +630,7 @@ public function terminate(Request $request, Response $response): void
try {
$kernel->terminate($request, $response);
$this->fail('Expected request termination to preserve cancellation.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
@@ -633,7 +638,9 @@ public function terminate(Request $request, Response $response): void
$this->assertNull($kernel->requestStartedAt());
}
- public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTerminationFailure(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTerminationFailure(bool $throwingCleanup): void
{
$called = [];
$app = new Application;
@@ -647,6 +654,7 @@ public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTermination
$request = Request::create('/');
$response = $kernel->handle($request);
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$events->listen(function (Terminating $event) use (&$called): void {
$called[] = 'event';
@@ -654,7 +662,7 @@ public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTermination
throw new RuntimeException('event failed');
});
$app->instance('cancelling-middleware', new class($called, $cancellation) {
- public function __construct(private array &$called, private CanceledException $cancellation)
+ public function __construct(private array &$called, private Throwable $cancellation)
{
}
@@ -683,7 +691,7 @@ public function terminate(Request $request, Response $response): void
try {
$kernel->terminate($request, $response);
$this->fail('Expected middleware cancellation to supersede the earlier failure.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
@@ -691,7 +699,9 @@ public function terminate(Request $request, Response $response): void
$this->assertNull($kernel->requestStartedAt());
}
- public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTerminationFailure(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTerminationFailure(bool $throwingCleanup): void
{
$called = [];
$app = new Application;
@@ -705,6 +715,7 @@ public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTermin
$request = Request::create('/');
$response = $kernel->handle($request);
$cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
$events->listen(function (Terminating $event) use (&$called): void {
$called[] = 'event';
@@ -723,7 +734,7 @@ public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTermin
try {
$kernel->terminate($request, $response);
$this->fail('Expected duration handler cancellation to supersede the earlier failure.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
diff --git a/tests/Integration/Foundation/ExceptionHandlerTest.php b/tests/Integration/Foundation/ExceptionHandlerTest.php
index 325517c706..12e30554cc 100644
--- a/tests/Integration/Foundation/ExceptionHandlerTest.php
+++ b/tests/Integration/Foundation/ExceptionHandlerTest.php
@@ -12,6 +12,8 @@
use Hypervel\Contracts\Http\Kernel as HttpKernel;
use Hypervel\Contracts\Routing\ResponseFactory as ResponseFactoryContract;
use Hypervel\Contracts\Support\Responsable;
+use Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
+use Hypervel\Foundation\Http\Middleware\TrimStrings;
use Hypervel\Http\Client\RequestException;
use Hypervel\Http\JsonResponse;
use Hypervel\Http\Middleware\PrefersJsonResponses;
@@ -24,6 +26,9 @@
use Hypervel\Testbench\TestCase;
use Monolog\Handler\TestHandler;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
+use RuntimeException;
+use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Symfony\Component\Process\PhpProcess;
use Throwable;
@@ -47,6 +52,42 @@ public function testItRendersAuthorizationExceptions(): void
]);
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testCancellationCleanupIsReportedOnceAcrossMiddlewareAndKernel(bool $mapped): void
+ {
+ $failure = new RuntimeException('cleanup failed', 0, new CanceledException('canceled'));
+ $handler = $this->app->make(ExceptionHandler::class);
+ $reported = [];
+
+ if ($mapped) {
+ $handler->map(RuntimeException::class, static fn (RuntimeException $exception): Exception => new Exception('mapped cleanup', 0, $exception));
+ }
+
+ $handler->reportable(function (Throwable $exception) use (&$reported): false {
+ $reported[] = $exception;
+
+ return false;
+ });
+
+ Route::get('canceled', static fn (): never => throw $failure)
+ ->middleware([TrimStrings::class, ConvertEmptyStringsToNull::class]);
+
+ try {
+ $this->app->make(HttpKernel::class)->handle(Request::create('/canceled'));
+ $this->fail('The cancellation was not preserved.');
+ } catch (Throwable $exception) {
+ $this->assertSame($failure, $exception);
+ }
+
+ // The native server also reports the escaping exception before containing it.
+ $handler->report($failure);
+
+ $this->assertCount(1, $reported);
+ $this->assertSame($failure, $mapped ? $reported[0]->getPrevious() : $reported[0]);
+ $this->assertTrue($handler->shouldReport($failure));
+ }
+
public function testItDoesntReportExceptionsWithShouldntReportInterface(): void
{
Config::set('app.debug', true);
diff --git a/tests/Integration/Log/LogManagerTest.php b/tests/Integration/Log/LogManagerTest.php
index 5ec1215272..996aa56c29 100644
--- a/tests/Integration/Log/LogManagerTest.php
+++ b/tests/Integration/Log/LogManagerTest.php
@@ -30,11 +30,14 @@
use Monolog\Processor\PsrLogMessageProcessor;
use Monolog\Processor\UidProcessor;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerTrait;
use ReflectionProperty;
use RuntimeException;
use Stringable;
+use Swoole\Coroutine\CanceledException;
+use Throwable;
class LogManagerTest extends TestCase
{
@@ -387,6 +390,31 @@ protected function createEmergencyLogger(): LoggerInterface
}
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testChannelCreationPreservesCancellationWithoutEmergencyFallback(bool $throwingCleanup): void
+ {
+ $cancellation = new CanceledException('canceled');
+ $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation;
+ $manager = new class($this->app) extends LogManager {
+ protected function createEmergencyLogger(): LoggerInterface
+ {
+ throw new RuntimeException('Emergency logger was created.');
+ }
+ };
+ config(['logging.channels.canceling' => ['driver' => 'canceling']]);
+ $manager->extend('canceling', static fn (): never => throw $cancellation);
+
+ try {
+ $manager->channel('canceling');
+ $this->fail('The cancellation was not preserved.');
+ } catch (Throwable $exception) {
+ $this->assertSame($cancellation, $exception);
+ }
+
+ $this->assertSame([], $manager->getChannels());
+ }
+
public function testLogManagerCreateSingleDriverWithConfiguredFormatter(): void
{
$manager = new LogManager($this->app);
diff --git a/tests/Telescope/Watchers/ExceptionWatcherTest.php b/tests/Telescope/Watchers/ExceptionWatcherTest.php
index 0325607433..43a576dfe2 100644
--- a/tests/Telescope/Watchers/ExceptionWatcherTest.php
+++ b/tests/Telescope/Watchers/ExceptionWatcherTest.php
@@ -10,13 +10,19 @@
use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Log\Context\Repository as ContextRepository;
+use Hypervel\Support\Collection;
+use Hypervel\Support\Lottery;
use Hypervel\Telescope\EntryType;
+use Hypervel\Telescope\IncomingEntry;
+use Hypervel\Telescope\Telescope;
use Hypervel\Telescope\Watchers\ExceptionWatcher;
use Hypervel\Testbench\Attributes\WithConfig;
use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\Telescope\FeatureTestCase;
use LogicException;
use ParseError;
+use PHPUnit\Framework\Attributes\TestWith;
+use Throwable;
#[WithConfig('logging.default', 'null')]
#[WithConfig('telescope.watchers', [
@@ -37,6 +43,7 @@ public function testExceptionWatcherRegisterEntries(): void
$this->assertSame(EntryType::EXCEPTION, $entry->type);
$this->assertSame(BananaException::class, $entry->content['class']);
$this->assertSame(__FILE__, $entry->content['file']);
+ $this->assertSame($exception->getLine(), $entry->content['line']);
$this->assertSame('Something went bananas.', $entry->content['message']);
$this->assertArrayHasKey('trace', $entry->content);
}
@@ -54,6 +61,7 @@ public function testExceptionWatcherRegisterThrowableEntries(): void
$this->assertSame(EntryType::EXCEPTION, $entry->type);
$this->assertSame(BananaError::class, $entry->content['class']);
$this->assertSame(__FILE__, $entry->content['file']);
+ $this->assertSame($exception->getLine(), $entry->content['line']);
$this->assertSame('Something went bananas.', $entry->content['message']);
$this->assertArrayHasKey('trace', $entry->content);
}
@@ -85,6 +93,38 @@ public function testExceptionWatcherRegisterEntriesWhenEvalFailed(): void
$this->assertArrayHasKey('trace', $entry->content);
}
+ #[TestWith(['filter', false], 'individual filter')]
+ #[TestWith(['filterBatch', false], 'batch filter')]
+ #[TestWith(['filter', true], 'individual filter with deduplication')]
+ #[TestWith(['filterBatch', true], 'batch filter with deduplication')]
+ public function testReportableFiltersKeepAdmittedMappedExceptions(string $filterMethod, bool $deduplicate): void
+ {
+ Telescope::{$filterMethod}(static fn (IncomingEntry|Collection $entries): bool => $entries instanceof IncomingEntry
+ ? $entries->isReportableException()
+ : $entries->contains(static fn (IncomingEntry $entry): bool => $entry->isReportableException()));
+
+ $handler = $this->app->make(ExceptionHandler::class);
+ if ($deduplicate) {
+ $handler->dontReportDuplicates();
+ }
+
+ $handler->map(BananaException::class, static fn (BananaException $exception): BananaException => new BananaException($exception->getMessage(), 0, $exception));
+ $evaluations = 0;
+ $handler->throttleUsing(static function (Throwable $exception) use (&$evaluations): Lottery {
+ ++$evaluations;
+
+ return Lottery::odds(1, 2);
+ });
+ Lottery::forceResultWithSequence([true, false], static fn (): bool => false);
+
+ $exception = new BananaException('admitted failure');
+ $handler->report($exception);
+ $handler->report($exception);
+
+ $this->assertCount(1, $this->loadTelescopeEntries());
+ $this->assertSame($deduplicate ? 1 : 2, $evaluations);
+ }
+
public function testExceptionWatcherStoresExtraWhenContextFacadeUsed(): void
{
ContextRepository::getInstance()->add('tenant_id', 42);
From f96642b9e68eb56c3abcf7fb32d9b424c5c49d77 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:10:57 +0000
Subject: [PATCH 10/22] Keep exception reporting decisions consistent across
later checks
Remember rejected cancellation cleanup reports as well as accepted reports in the existing coroutine-local weak map. Later reporting boundaries no longer rerun mappers, sampling or rate limits for the same cleanup failure. shouldReport() reuses a recorded decision so immediate and deferred Telescope filters retain accepted errors without drawing again.
Restore duplicate checking in shouldntReport() and marking in reportThrowable(), preserving the protected handler extension points. Record a distinct original exception separately from its mapped value. Different wrappers mapping to one shared cause now respect duplicate suppression, while ordinary repeated reports retain their existing policy behavior.
Extend the existing kernel regression with deterministic rejected sampling and a public reportability check. Add the shared-mapped-cause regression. Both reproduce their failures before correction. The complete local suite, affected handler and observer checks, formatting, and source/type/skeleton analysis pass.
---
src/foundation/src/Exceptions/Handler.php | 33 +++++++++++++------
.../FoundationExceptionsHandlerTest.php | 18 ++++++++++
.../Foundation/ExceptionHandlerTest.php | 26 +++++++++++----
3 files changed, 61 insertions(+), 16 deletions(-)
diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php
index 1bfb9caee8..1289039c02 100644
--- a/src/foundation/src/Exceptions/Handler.php
+++ b/src/foundation/src/Exceptions/Handler.php
@@ -480,23 +480,26 @@ public function report(Throwable $e, array $context = [], ?string $level = null)
// Cancellation unwinds through multiple reporting boundaries. Suppress duplicates
// using the original exception because mappers may create new wrappers each time.
$original = $e;
- $deduplicate = $this->withoutDuplicates || Coroutine::causedByCancellation($e);
+ $canceled = Coroutine::causedByCancellation($e);
- if ($deduplicate && $this->hasReportedException($original)) {
+ if (($this->withoutDuplicates || $canceled) && $this->reportedExceptionMap()->offsetExists($original)) {
return;
}
$e = $this->mapException($e);
if ($this->shouldntReport($e)) {
+ // Rejected cleanup failures must not redraw sampling or consume limits at each boundary.
+ if ($canceled) {
+ $this->reportedExceptionMap()[$original] = false;
+ }
+
return;
}
- // Keep the accepted decision for observers, including deferred Telescope batch filters.
- $this->reportedException($original);
-
+ // reportThrowable() records the mapped exception; retain a distinct original for later boundaries.
if ($e !== $original) {
- $this->reportedException($e);
+ $this->reportedException($original);
}
$this->reportThrowable($e, $context, $level);
@@ -512,6 +515,8 @@ public function report(Throwable $e, array $context = [], ?string $level = null)
*/
protected function reportThrowable(Throwable $e, array $context = [], ?string $level = null): void
{
+ $this->reportedException($e);
+
if (Reflector::isCallable($reportCallable = [$e, 'report'])
&& $this->container->call($reportCallable) !== false
) {
@@ -599,11 +604,15 @@ protected function reportedException(Throwable $e): void
*/
protected function hasReportedException(Throwable $e): bool
{
- return $this->reportedExceptionMap()->offsetExists($e);
+ return ($this->reportedExceptionMap()[$e] ?? false) === true;
}
/**
- * Get the reported exception map for the current request.
+ * Get the reporting decisions for the current request.
+ *
+ * True records an accepted exception; false records a rejected cancellation chain.
+ *
+ * @return WeakMap
*/
protected function reportedExceptionMap(): WeakMap
{
@@ -622,8 +631,8 @@ protected function reportedExceptionMap(): WeakMap
*/
public function shouldReport(Throwable $e): bool
{
- // Log observers must not sample or consume a rate limit again for an admitted report.
- return $this->hasReportedException($e) || ! $this->shouldntReport($e);
+ // Later checks must not sample or consume a rate limit again after a reporting decision.
+ return $this->reportedExceptionMap()[$e] ?? ! $this->shouldntReport($e);
}
/**
@@ -635,6 +644,10 @@ protected function shouldntReport(Throwable $e): bool
return true;
}
+ if ($this->withoutDuplicates && $this->hasReportedException($e)) {
+ return true;
+ }
+
if ($e instanceof ShouldntReport) {
return true;
}
diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php
index c711351fdf..ac3968980b 100644
--- a/tests/Foundation/FoundationExceptionsHandlerTest.php
+++ b/tests/Foundation/FoundationExceptionsHandlerTest.php
@@ -1217,6 +1217,24 @@ public function testItCanDedupeExceptions(bool $mapped): void
$this->assertTrue($this->handler->shouldReport($one));
}
+ public function testItDedupesExceptionsMappedToTheSameCause(): void
+ {
+ $reported = [];
+ $this->handler->map(RuntimeException::class, static fn (RuntimeException $exception): Throwable => $exception->getPrevious());
+ $this->handler->reportable(function (Throwable $exception) use (&$reported): false {
+ $reported[] = $exception;
+
+ return false;
+ });
+ $this->handler->dontReportDuplicates();
+ $cause = new Exception('shared cause');
+
+ $this->handler->report(new RuntimeException('first wrapper', 0, $cause));
+ $this->handler->report(new RuntimeException('second wrapper', 0, $cause));
+
+ $this->assertSame([$cause], $reported);
+ }
+
public function testItDedupesByObjectIdentityNotEquality(): void
{
$reported = [];
diff --git a/tests/Integration/Foundation/ExceptionHandlerTest.php b/tests/Integration/Foundation/ExceptionHandlerTest.php
index 12e30554cc..21ce657abc 100644
--- a/tests/Integration/Foundation/ExceptionHandlerTest.php
+++ b/tests/Integration/Foundation/ExceptionHandlerTest.php
@@ -23,6 +23,7 @@
use Hypervel\Support\Facades\Http;
use Hypervel\Support\Facades\Log;
use Hypervel\Support\Facades\Route;
+use Hypervel\Support\Lottery;
use Hypervel\Testbench\TestCase;
use Monolog\Handler\TestHandler;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -52,13 +53,22 @@ public function testItRendersAuthorizationExceptions(): void
]);
}
- #[TestWith([false])]
- #[TestWith([true])]
- public function testCancellationCleanupIsReportedOnceAcrossMiddlewareAndKernel(bool $mapped): void
+ #[TestWith([false, true], 'accepted')]
+ #[TestWith([true, true], 'accepted and mapped')]
+ #[TestWith([true, false], 'rejected and mapped')]
+ public function testCancellationCleanupIsReportedOnceAcrossMiddlewareAndKernel(bool $mapped, bool $accepted): void
{
$failure = new RuntimeException('cleanup failed', 0, new CanceledException('canceled'));
$handler = $this->app->make(ExceptionHandler::class);
$reported = [];
+ $policyEvaluations = 0;
+
+ Lottery::forceResultWithSequence([$accepted, ! $accepted], static fn (): bool => ! $accepted);
+ $handler->throttleUsing(function (Throwable $exception) use (&$policyEvaluations): Lottery {
+ ++$policyEvaluations;
+
+ return Lottery::odds(1, 1000);
+ });
if ($mapped) {
$handler->map(RuntimeException::class, static fn (RuntimeException $exception): Exception => new Exception('mapped cleanup', 0, $exception));
@@ -83,9 +93,13 @@ public function testCancellationCleanupIsReportedOnceAcrossMiddlewareAndKernel(b
// The native server also reports the escaping exception before containing it.
$handler->report($failure);
- $this->assertCount(1, $reported);
- $this->assertSame($failure, $mapped ? $reported[0]->getPrevious() : $reported[0]);
- $this->assertTrue($handler->shouldReport($failure));
+ $this->assertSame($accepted, $handler->shouldReport($failure));
+ $this->assertSame(1, $policyEvaluations);
+ $this->assertCount((int) $accepted, $reported);
+
+ if ($accepted) {
+ $this->assertSame($failure, $mapped ? $reported[0]->getPrevious() : $reported[0]);
+ }
}
public function testItDoesntReportExceptionsWithShouldntReportInterface(): void
From ea12fa22b2ab86a551e141bbc9706267173eb24d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:10:57 +0000
Subject: [PATCH 11/22] Keep original source paths in exception and view
diagnostics
Resolve configured compiled-view directories before matching PHP stack frames, which contain real paths when the cache directory is a symlink. Make Sentry use that configured absolute directory instead of assuming storage/framework/views. Preserve footer-based source lookup, custom extensions and missing-file handling.
Retain the original display path for external views after mapping compiled files. Match complete application and vendor directories for frame classification and editor links, and remove only a leading application prefix from display paths. Telescope keeps external view paths intact. These changes prevent neighboring directories and repeated path segments from producing misleading source labels or links.
Exercise real Blade rendering through the registered dumper, with an isolated temporary application root. Extend existing mapper, Sentry, editor-link, frame and watcher regressions without new fixture classes. The failures reproduce before correction; affected tests, the complete local suite, formatting and static analysis pass.
---
.../src/Concerns/ResolvesDumpSource.php | 7 +--
.../src/Concerns/ResolvesSourceHref.php | 7 ++-
.../src/Exceptions/Renderer/Frame.php | 8 +--
.../Renderer/Mappers/BladeMapper.php | 6 ++-
.../Providers/FoundationServiceProvider.php | 2 +
src/sentry/src/Tracing/BacktraceHelper.php | 11 ++--
src/telescope/src/Watchers/ViewWatcher.php | 2 +-
.../Exceptions/Renderer/FrameTest.php | 15 ++++++
tests/Foundation/Http/HtmlDumperTest.php | 11 ++++
.../FoundationServiceProviderTest.php | 51 +++++++++++++++++++
.../Exceptions/RenderBladeFilesTest.php | 15 ++++--
tests/Sentry/Tracing/BacktraceHelperTest.php | 34 +++++++++++++
tests/Telescope/Watchers/ViewWatcherTest.php | 18 +++++++
13 files changed, 171 insertions(+), 16 deletions(-)
diff --git a/src/foundation/src/Concerns/ResolvesDumpSource.php b/src/foundation/src/Concerns/ResolvesDumpSource.php
index f2de305192..be4c34d686 100644
--- a/src/foundation/src/Concerns/ResolvesDumpSource.php
+++ b/src/foundation/src/Concerns/ResolvesDumpSource.php
@@ -78,14 +78,15 @@ public function resolveDumpSource(): ?array
return null;
}
- $relativeFile = $file;
-
if ($this->isCompiledViewFile($file)) {
$file = $this->getOriginalFileForCompiledView($file);
$line = null;
}
- if (str_starts_with($file, $this->basePath)) {
+ // External views keep their original path rather than the compiled filename.
+ $relativeFile = $file;
+
+ if (str_starts_with($file, $this->basePath . DIRECTORY_SEPARATOR)) {
$relativeFile = substr($file, strlen($this->basePath) + 1);
}
diff --git a/src/foundation/src/Concerns/ResolvesSourceHref.php b/src/foundation/src/Concerns/ResolvesSourceHref.php
index 9a5549f20a..fa79715526 100644
--- a/src/foundation/src/Concerns/ResolvesSourceHref.php
+++ b/src/foundation/src/Concerns/ResolvesSourceHref.php
@@ -68,7 +68,12 @@ protected function resolveSourceHref(string $file, ?int $line): ?string
$basePath = $editor['base_path'] ?? false;
if ($basePath !== false) {
- $file = Str::replaceStart($this->basePath, $basePath, $file);
+ // Remap application files without rewriting neighboring directories that share its name.
+ $file = Str::replaceStart(
+ $this->basePath . DIRECTORY_SEPARATOR,
+ rtrim($basePath, '/\\') . DIRECTORY_SEPARATOR,
+ $file,
+ );
}
return str_replace(
diff --git a/src/foundation/src/Exceptions/Renderer/Frame.php b/src/foundation/src/Exceptions/Renderer/Frame.php
index 378cf4e6dc..51257286c7 100644
--- a/src/foundation/src/Exceptions/Renderer/Frame.php
+++ b/src/foundation/src/Exceptions/Renderer/Frame.php
@@ -6,6 +6,7 @@
use Hypervel\Di\Bootstrap\GenerateProxies;
use Hypervel\Foundation\Concerns\ResolvesSourceHref;
+use Hypervel\Support\Str;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use function Hypervel\Filesystem\join_paths;
@@ -76,7 +77,7 @@ public function file(): string
return match (true) {
! isset($this->frame['file']) => '[internal function]',
! is_string($this->frame['file']) => '[unknown file]',
- default => str_replace($this->basePath . DIRECTORY_SEPARATOR, '', $this->frame['file']),
+ default => Str::replaceStart($this->basePath . DIRECTORY_SEPARATOR, '', $this->frame['file']),
};
}
@@ -160,8 +161,9 @@ public function isFromVendor(): bool
// A generated AOP proxy runs its source file's code, so the source decides where the frame comes from.
$file = GenerateProxies::proxySources()[$this->frame['file']] ?? $this->frame['file'];
- return ! str_starts_with($file, $this->basePath)
- || str_starts_with($file, join_paths($this->basePath, 'vendor'));
+ // Match complete directories so sibling names do not change a frame's classification.
+ return ! str_starts_with($file, $this->basePath . DIRECTORY_SEPARATOR)
+ || str_starts_with($file, join_paths($this->basePath, 'vendor') . DIRECTORY_SEPARATOR);
}
/**
diff --git a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
index 6ff6852caa..0e0ce704d1 100644
--- a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
+++ b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
@@ -84,7 +84,11 @@ public function map(FlattenException $exception): FlattenException
*/
public function findCompiledView(string $compiledPath): ?string
{
- if (! str_starts_with($compiledPath, rtrim(config()->string('view.compiled'), '/\\') . DIRECTORY_SEPARATOR)) {
+ // Frames contain resolved paths, even when the configured cache directory is a symlink.
+ $directory = config()->string('view.compiled');
+ $directory = realpath($directory) ?: $directory;
+
+ if (! str_starts_with($compiledPath, rtrim($directory, '/\\') . DIRECTORY_SEPARATOR)) {
return null;
}
diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php
index c9dc179852..59b7e052b7 100644
--- a/src/foundation/src/Providers/FoundationServiceProvider.php
+++ b/src/foundation/src/Providers/FoundationServiceProvider.php
@@ -267,7 +267,9 @@ public function registerDumper(): void
$basePath = $this->app->basePath();
+ // Frames contain resolved paths, even when the configured cache directory is a symlink.
$compiledViewPath = $this->config->string('view.compiled');
+ $compiledViewPath = realpath($compiledViewPath) ?: $compiledViewPath;
$formatExists = array_key_exists('VAR_DUMPER_FORMAT', $_SERVER);
$format = $_SERVER['VAR_DUMPER_FORMAT'] ?? null;
diff --git a/src/sentry/src/Tracing/BacktraceHelper.php b/src/sentry/src/Tracing/BacktraceHelper.php
index 98d1fa2be0..b2cf8ff8f7 100644
--- a/src/sentry/src/Tracing/BacktraceHelper.php
+++ b/src/sentry/src/Tracing/BacktraceHelper.php
@@ -71,14 +71,17 @@ public function findFirstInAppFrameForBacktrace(array $backtrace): ?Frame
*/
public function getOriginalViewPathForFrameOfCompiledViewPath(Frame $frame): ?string
{
- // Check if we are dealing with a frame for a cached view path
- if (! Str::startsWith($frame->getFile(), '/storage/framework/views/')) {
+ $absoluteFilePath = $frame->getAbsoluteFilePath();
+
+ if ($absoluteFilePath === null) {
return null;
}
- $absoluteFilePath = $frame->getAbsoluteFilePath();
+ // Frames contain resolved paths, even when the configured cache directory is a symlink.
+ $compiledDirectory = config()->string('view.compiled');
+ $compiledDirectory = realpath($compiledDirectory) ?: $compiledDirectory;
- if ($absoluteFilePath === null) {
+ if (! str_starts_with($absoluteFilePath, rtrim($compiledDirectory, '/\\') . DIRECTORY_SEPARATOR)) {
return null;
}
diff --git a/src/telescope/src/Watchers/ViewWatcher.php b/src/telescope/src/Watchers/ViewWatcher.php
index 37e886fec0..e83f028173 100644
--- a/src/telescope/src/Watchers/ViewWatcher.php
+++ b/src/telescope/src/Watchers/ViewWatcher.php
@@ -60,7 +60,7 @@ protected function extractPath(ViewContract $view): string
{
$path = $view->getPath();
- if (Str::startsWith($path, base_path())) {
+ if (Str::startsWith($path, base_path() . DIRECTORY_SEPARATOR)) {
$path = substr($path, strlen(base_path()));
}
diff --git a/tests/Foundation/Exceptions/Renderer/FrameTest.php b/tests/Foundation/Exceptions/Renderer/FrameTest.php
index 5ed0e4bbb5..8ad33849a9 100644
--- a/tests/Foundation/Exceptions/Renderer/FrameTest.php
+++ b/tests/Foundation/Exceptions/Renderer/FrameTest.php
@@ -74,6 +74,11 @@ public static function unixFileDataProvider(): iterable
'/path/to/your-app',
'/other/path/app/Http/Controllers/UserController.php',
];
+ yield 'base directory repeated inside the path' => [
+ ['file' => '/app/modules/app/File.php', 'line' => 10],
+ '/app',
+ 'modules/app/File.php',
+ ];
}
// REMOVED: test_it_normalizes_file_path_on_windows - Swoole doesn't run on Windows
@@ -106,6 +111,16 @@ public static function unixIsFromVendorDataProvider(): iterable
'/path/to/your-app',
true,
];
+ yield 'outside base path with matching prefix' => [
+ ['file' => '/path/to/your-app-other/file.php', 'line' => 10],
+ '/path/to/your-app',
+ true,
+ ];
+ yield 'application directory with vendor prefix' => [
+ ['file' => '/path/to/your-app/vendor-tools/file.php', 'line' => 10],
+ '/path/to/your-app',
+ false,
+ ];
yield 'vendor in filename' => [
['file' => '/path/to/your-app/app/vendorfile.php', 'line' => 10],
'/path/to/your-app',
diff --git a/tests/Foundation/Http/HtmlDumperTest.php b/tests/Foundation/Http/HtmlDumperTest.php
index 9d35406002..6031136a63 100644
--- a/tests/Foundation/Http/HtmlDumperTest.php
+++ b/tests/Foundation/Http/HtmlDumperTest.php
@@ -253,6 +253,17 @@ public function testHref(): void
$resolveSourceHref()
);
+ $this->assertSame(
+ 'phpstorm://open?file=/my-work-directory-shared/example.blade.php&line=10',
+ (fn () => $this->resolveSourceHref('/my-work-directory-shared/example.blade.php', 10))->call($dumper),
+ );
+
+ $this->config->set('app.editor', ['name' => 'phpstorm', 'base_path' => '/my-docker-work-directory/']);
+ $this->assertSame(
+ 'phpstorm://open?file=/my-docker-work-directory/app/my-file&line=10',
+ $resolveSourceHref()
+ );
+
// A literal false disables base-path replacement.
$this->config->set('app.editor', ['name' => 'phpstorm', 'base_path' => false]);
$this->assertSame(
diff --git a/tests/Foundation/Providers/FoundationServiceProviderTest.php b/tests/Foundation/Providers/FoundationServiceProviderTest.php
index 7bbe34ef1a..536a50b0f4 100644
--- a/tests/Foundation/Providers/FoundationServiceProviderTest.php
+++ b/tests/Foundation/Providers/FoundationServiceProviderTest.php
@@ -7,6 +7,7 @@
use Hypervel\Console\Scheduling\Schedule;
use Hypervel\Contracts\Console\Kernel;
use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\ArrayMaintenanceMode;
use Hypervel\Foundation\Console\CliDumper;
use Hypervel\Foundation\DevCommands;
@@ -19,7 +20,9 @@
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Facades\Date;
use Hypervel\Testbench\TestCase;
+use Hypervel\Testing\ParallelTesting;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use Psr\Clock\ClockInterface;
use ReflectionClass;
use ReflectionFunction;
@@ -198,4 +201,52 @@ public static function dumperFormatValues(): array
'unknown' => ['unknown', CliDumper::class],
];
}
+
+ #[TestWith([false], 'view inside the application')]
+ #[TestWith([true], 'view outside the application with a matching path prefix')]
+ public function testDumperResolvesBladeSourceFromSymlinkedCompiledDirectory(bool $outsideApplication): void
+ {
+ $filesystem = new Filesystem;
+ $root = ParallelTesting::tempDir('FoundationDumperSource');
+ $originalBasePath = $this->app->basePath();
+ $directory = $outsideApplication
+ ? $root . '/app-dump-sources'
+ : $root . '/app/storage/framework/dump-sources';
+ $filesystem->deleteDirectory($root);
+ $originalFormatExists = array_key_exists('VAR_DUMPER_FORMAT', $_SERVER);
+ $originalFormat = $_SERVER['VAR_DUMPER_FORMAT'] ?? null;
+ unset($_SERVER['VAR_DUMPER_FORMAT']);
+ $originalHandler = VarDumper::setHandler(null);
+
+ try {
+ $this->app->setBasePath($root . '/app');
+ $filesystem->ensureDirectoryExists($directory . '/compiled');
+ symlink($directory . '/compiled', $directory . '/linked');
+ config(['view.compiled' => $directory . '/linked']);
+ $this->app->forgetInstance('blade.compiler');
+ $this->app->make('view.engine.resolver')->forget('blade');
+
+ $_SERVER['VAR_DUMPER_FORMAT'] = 'html';
+ (new FoundationServiceProvider($this->app))->registerDumper();
+ $path = $directory . '/example.blade.php';
+ $filesystem->put($path, "@dump('example value')");
+
+ $output = $this->app->make('view')->file($path)->render();
+
+ $this->assertStringContainsString(
+ $outsideApplication ? $path : 'storage/framework/dump-sources/example.blade.php',
+ $output,
+ );
+ } finally {
+ unset($_SERVER['VAR_DUMPER_FORMAT']);
+ VarDumper::setHandler($originalHandler);
+
+ if ($originalFormatExists) {
+ $_SERVER['VAR_DUMPER_FORMAT'] = $originalFormat;
+ }
+
+ $this->app->setBasePath($originalBasePath);
+ $filesystem->deleteDirectory($root);
+ }
+ }
}
diff --git a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
index 6c28c3964d..5d752062ec 100644
--- a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
+++ b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
@@ -143,9 +143,10 @@ public function testBladeMapperSkipsMissingOriginalAndCompiledPaths(): void
}
}
- #[TestWith([false])]
- #[TestWith([true])]
- public function testBladeMapperFindsTheSourceAfterRenderingHasUnwound(bool $customFormat): void
+ #[TestWith([false, false], 'default echo format')]
+ #[TestWith([true, false], 'custom echo format')]
+ #[TestWith([true, true], 'custom echo format with symlinked cache')]
+ public function testBladeMapperFindsTheSourceAfterRenderingHasUnwound(bool $customFormat, bool $symlinkedCache): void
{
$files = new Filesystem;
$directory = ParallelTesting::tempDir('RenderBladeFilesTest');
@@ -155,6 +156,14 @@ public function testBladeMapperFindsTheSourceAfterRenderingHasUnwound(bool $cust
$files->put($path, "First line\n{{ missing_blade_mapping_test_function() }}");
try {
+ if ($symlinkedCache) {
+ $files->ensureDirectoryExists($directory . '/compiled');
+ symlink($directory . '/compiled', $directory . '/linked');
+ config(['view.compiled' => $directory . '/linked']);
+ $this->app->forgetInstance('blade.compiler');
+ $this->app->make('view.engine.resolver')->forget('blade');
+ }
+
$compiler = $this->app->make('blade.compiler');
$render = fn (): string => $this->app->make('view')->file($path)->render();
diff --git a/tests/Sentry/Tracing/BacktraceHelperTest.php b/tests/Sentry/Tracing/BacktraceHelperTest.php
index 86cf46aade..5fd358ae9d 100644
--- a/tests/Sentry/Tracing/BacktraceHelperTest.php
+++ b/tests/Sentry/Tracing/BacktraceHelperTest.php
@@ -4,10 +4,13 @@
namespace Hypervel\Tests\Sentry\Tracing;
+use Hypervel\Config\Repository;
+use Hypervel\Container\Container;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Sentry\Tracing\BacktraceHelper;
use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
use Sentry\Frame;
use Sentry\Options;
use Sentry\Serializer\RepresentationSerializer;
@@ -26,6 +29,7 @@ public function testUnreadableCompiledViewReturnsNoOriginalPath(): void
$this->assertIsResource($socket);
try {
+ Container::getInstance()->instance('config', new Repository(['view' => ['compiled' => $directory]]));
$options = new Options(['dsn' => null]);
$helper = new BacktraceHelper($options, new RepresentationSerializer($options));
$frame = new Frame(null, '/storage/framework/views/compiled.php', 1, absoluteFilePath: $socketPath);
@@ -36,4 +40,34 @@ public function testUnreadableCompiledViewReturnsNoOriginalPath(): void
$filesystem->deleteDirectory($directory);
}
}
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testCustomCompiledViewPathResolvesOriginalSource(bool $symlinked): void
+ {
+ $directory = ParallelTesting::tempDir('SentryBacktraceHelperTest');
+ $filesystem = new Filesystem;
+ $filesystem->deleteDirectory($directory);
+ $filesystem->ensureDirectoryExists($directory . '/compiled');
+
+ try {
+ $compiledDirectory = $directory . '/compiled';
+
+ if ($symlinked) {
+ symlink($compiledDirectory, $directory . '/linked');
+ $compiledDirectory = $directory . '/linked';
+ }
+
+ Container::getInstance()->instance('config', new Repository(['view' => ['compiled' => $compiledDirectory]]));
+ $compiledPath = $directory . '/compiled/example.cache';
+ $filesystem->put($compiledPath, '/**PATH ' . $directory . '/resources/views/example.blade.php ENDPATH**/');
+ $options = new Options(['dsn' => null, 'prefixes' => [$directory]]);
+ $helper = new BacktraceHelper($options, new RepresentationSerializer($options));
+ $frame = new Frame(null, '/compiled/example.cache', 1, absoluteFilePath: $compiledPath);
+
+ $this->assertSame('/resources/views/example.blade.php', $helper->getOriginalViewPathForFrameOfCompiledViewPath($frame));
+ } finally {
+ $filesystem->deleteDirectory($directory);
+ }
+ }
}
diff --git a/tests/Telescope/Watchers/ViewWatcherTest.php b/tests/Telescope/Watchers/ViewWatcherTest.php
index 5339e68043..125c0332aa 100644
--- a/tests/Telescope/Watchers/ViewWatcherTest.php
+++ b/tests/Telescope/Watchers/ViewWatcherTest.php
@@ -55,6 +55,24 @@ public function testViewWatcherRegistersViews(): void
$this->assertSame(['name'], $entry->content['data']);
}
+ public function testViewWatcherKeepsExternalPathsWithMatchingApplicationPrefix(): void
+ {
+ $files = new Filesystem;
+ $files->ensureDirectoryExists($this->viewDir . '/app-other');
+ $path = $this->viewDir . '/app-other/example.blade.php';
+ $files->put($path, 'External view');
+ $originalBasePath = $this->app->basePath();
+
+ try {
+ $this->app->setBasePath($this->viewDir . '/app');
+ $this->app->make(Factory::class)->file($path)->render();
+ } finally {
+ $this->app->setBasePath($originalBasePath);
+ }
+
+ $this->assertSame($path, $this->loadTelescopeEntries()->first()->content['path']);
+ }
+
public function testViewWatcherCapturesViewsWithoutComposers(): void
{
file_put_contents($this->viewDir . '/simple.blade.php', 'No composers here');
From 3ced2b7b8e02d96ca9a256a2b2c4db04fd675682 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:10:57 +0000
Subject: [PATCH 12/22] Preserve external view identities in relative compiled
caches
When relative view hashing is enabled, remove only a leading application directory, including its separator. Keep external view paths absolute instead of removing an application-path occurrence embedded farther into them. Distinct templates can no longer share a compiled file merely because their paths contain the same suffix.
Preserve relocatable in-application identities, accept a trailing separator on the configured base, and retain the existing cache marker and default absolute-path behavior. The regression checks relocation and distinct embedded-prefix and root-level external paths; a real cached-render reproduction demonstrated the wrong template before correction.
The complete compiler file, affected view tests, full local suite, formatting and static analysis pass.
---
src/view/src/Compilers/Compiler.php | 7 ++++++-
tests/View/ViewBladeCompilerTest.php | 11 +++++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/view/src/Compilers/Compiler.php b/src/view/src/Compilers/Compiler.php
index 3a8e596e76..b854485181 100755
--- a/src/view/src/Compilers/Compiler.php
+++ b/src/view/src/Compilers/Compiler.php
@@ -31,7 +31,12 @@ public function __construct(
*/
public function getCompiledPath(string $path): string
{
- return $this->cachePath . '/' . hash('xxh128', 'v3' . Str::after($path, $this->basePath)) . '.' . $this->compiledExtension;
+ // Only a leading base directory is relative; external views retain their full identity.
+ $identity = $this->basePath === ''
+ ? $path
+ : Str::replaceStart(rtrim($this->basePath, '/\\') . DIRECTORY_SEPARATOR, '', $path);
+
+ return $this->cachePath . '/' . hash('xxh128', 'v3' . $identity) . '.' . $this->compiledExtension;
}
/**
diff --git a/tests/View/ViewBladeCompilerTest.php b/tests/View/ViewBladeCompilerTest.php
index 9cb5ae05f4..6a424faa6c 100644
--- a/tests/View/ViewBladeCompilerTest.php
+++ b/tests/View/ViewBladeCompilerTest.php
@@ -104,6 +104,17 @@ public function testCompilePathIsProperlyCreated(): void
$this->assertEquals(__DIR__ . '/' . hash('xxh128', 'v3foo') . '.php', $compiler->getCompiledPath('foo'));
}
+ public function testRelativeCompiledPathsPreserveExternalViewIdentity(): void
+ {
+ $compiler = new BladeCompiler($this->getFiles(), __DIR__, '/app');
+ $relocated = new BladeCompiler($this->getFiles(), __DIR__, '/another/app/');
+ $internal = $compiler->getCompiledPath('/app/views/page.blade.php');
+
+ $this->assertSame($internal, $relocated->getCompiledPath('/another/app/views/page.blade.php'));
+ $this->assertNotSame($internal, $compiler->getCompiledPath('/shared/app/views/page.blade.php'));
+ $this->assertNotSame($internal, $compiler->getCompiledPath('/views/page.blade.php'));
+ }
+
public function testCompileCompilesFileAndReturnsContents(): void
{
$compiler = new BladeCompiler($files = $this->getFiles(), __DIR__);
From 9bf05e37c28277482ec2df55cbf85ae3fafc6df9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:10:57 +0000
Subject: [PATCH 13/22] Preserve paths and URLs in console output
Strip only the leading application directory from route, event, schedule and vendor-publish labels. Match the complete vendor directory when filtering routes or shortening controller actions, so application directories with similar names are not treated as vendor code.
Make the shared console mutator shorten standalone path tokens rather than replacing matching text inside relative paths, URLs or file URIs. Use one separator-boundary lookbehind. Event-list labels are genuinely relative, avoiding a second interpretation as absolute paths when formatted for the CLI.
Add focused regressions using real closure files and existing command providers. Cover JSON and CLI event output, route filtering, scheduled callback locations, vendor copy/existing/missing messages, caller markup, Unicode paths and URL values. All new failures reproduce before correction. Affected console checks, the full local suite, formatting and static analysis pass.
---
.../src/Commands/ScheduleListCommand.php | 3 +-
.../Mutators/EnsureRelativePaths.php | 3 +-
.../src/Console/EventListCommand.php | 8 ++++-
.../src/Console/RouteListCommand.php | 6 ++--
.../src/Console/VendorPublishCommand.php | 8 ++---
tests/Console/View/ComponentsTest.php | 27 +++++++++++++++
.../Console/RouteListCommandTest.php | 31 +++++++++++++++++
.../Console/VendorPublishCommandTest.php | 30 +++++++++++++++++
.../Console/Events/EventListCommandTest.php | 33 +++++++++++++++++++
.../Scheduling/ScheduleListCommandTest.php | 29 ++++++++++++++++
10 files changed, 168 insertions(+), 10 deletions(-)
diff --git a/src/console/src/Commands/ScheduleListCommand.php b/src/console/src/Commands/ScheduleListCommand.php
index 2f3760bca2..7e48234acc 100644
--- a/src/console/src/Commands/ScheduleListCommand.php
+++ b/src/console/src/Commands/ScheduleListCommand.php
@@ -15,6 +15,7 @@
use Hypervel\Support\Arr;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Collection;
+use Hypervel\Support\Str;
use ReflectionClass;
use ReflectionFunction;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -333,7 +334,7 @@ private function getClosureLocation(CallbackEvent $event): string
return sprintf(
'%s:%s',
- str_replace($this->hypervel->basePath() . DIRECTORY_SEPARATOR, '', $function->getFileName() ?: ''),
+ Str::replaceStart($this->hypervel->basePath() . DIRECTORY_SEPARATOR, '', $function->getFileName() ?: ''),
$function->getStartLine()
);
}
diff --git a/src/console/src/View/Components/Mutators/EnsureRelativePaths.php b/src/console/src/View/Components/Mutators/EnsureRelativePaths.php
index 20cc55f627..17e595504d 100644
--- a/src/console/src/View/Components/Mutators/EnsureRelativePaths.php
+++ b/src/console/src/View/Components/Mutators/EnsureRelativePaths.php
@@ -12,7 +12,8 @@ class EnsureRelativePaths
public function __invoke(string $string): string
{
if (function_exists('app') && app()->has('path.base')) {
- $string = str_replace(base_path() . '/', '', $string);
+ // Shorten standalone paths without removing part of another path or URL.
+ $string = (string) preg_replace('#(?\'"])' . preg_quote(base_path() . '/', '#') . '#', '', $string);
}
return $string;
diff --git a/src/foundation/src/Console/EventListCommand.php b/src/foundation/src/Console/EventListCommand.php
index b082c68024..1070cb43a3 100644
--- a/src/foundation/src/Console/EventListCommand.php
+++ b/src/foundation/src/Console/EventListCommand.php
@@ -11,6 +11,7 @@
use Hypervel\Contracts\Queue\ShouldQueue;
use Hypervel\Events\Dispatcher;
use Hypervel\Support\Collection;
+use Hypervel\Support\Str;
use ReflectionFunction;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -175,7 +176,12 @@ protected function stringifyClosure(Closure $rawListener): string
{
$reflection = new ReflectionFunction($rawListener);
- $path = str_replace([base_path(), DIRECTORY_SEPARATOR], ['', '/'], $reflection->getFileName() ?: '');
+ // Relative labels must not look absolute when console components normalize their output.
+ $path = str_replace(
+ DIRECTORY_SEPARATOR,
+ '/',
+ Str::replaceStart(base_path() . DIRECTORY_SEPARATOR, '', $reflection->getFileName() ?: '')
+ );
return 'Closure at: ' . $path . ':' . $reflection->getStartLine();
}
diff --git a/src/foundation/src/Console/RouteListCommand.php b/src/foundation/src/Console/RouteListCommand.php
index 5ca1991267..9cec3d0168 100644
--- a/src/foundation/src/Console/RouteListCommand.php
+++ b/src/foundation/src/Console/RouteListCommand.php
@@ -242,7 +242,7 @@ protected function getClosurePath(Route $route): ?string
return str_replace(
'\\',
'/',
- ltrim(Str::after($path, base_path()), DIRECTORY_SEPARATOR)
+ Str::replaceStart(base_path() . DIRECTORY_SEPARATOR, '', $path)
) . ':' . $reflection->getStartLine();
}
@@ -268,7 +268,7 @@ protected function isVendorRoute(Route $route): bool
return false;
}
- return is_string($path) && str_starts_with($path, base_path('vendor'));
+ return is_string($path) && str_starts_with($path, base_path('vendor') . DIRECTORY_SEPARATOR);
}
/**
@@ -457,7 +457,7 @@ protected function formatActionForCli(array $route): ?string
? (new ReflectionClass($actionClass))->getFilename()
: false;
- if (is_string($path) && str_starts_with($path, base_path('vendor'))) {
+ if (is_string($path) && str_starts_with($path, base_path('vendor') . DIRECTORY_SEPARATOR)) {
$actionCollection = (new Stringable($action))->explode('\\');
return $name . $actionCollection->take(2)->implode('\\') . ' ' . $actionCollection->last();
diff --git a/src/foundation/src/Console/VendorPublishCommand.php b/src/foundation/src/Console/VendorPublishCommand.php
index 7ff84feedd..81ee3228a4 100644
--- a/src/foundation/src/Console/VendorPublishCommand.php
+++ b/src/foundation/src/Console/VendorPublishCommand.php
@@ -254,14 +254,14 @@ protected function publishFile(string $from, string $to): void
if ($this->option('existing')) {
$this->components->twoColumnDetail(sprintf(
'File [%s] does not exist',
- str_replace(base_path() . '/', '', $to),
+ Str::replaceStart(base_path() . '/', '', $to),
), 'SKIPPED>');
} else {
$resolvedTo = realpath($to);
$this->components->twoColumnDetail(sprintf(
'File [%s] already exists',
- str_replace(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to),
+ Str::replaceStart(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to),
), 'SKIPPED>');
}
}
@@ -444,8 +444,8 @@ protected function status(string $from, string $to, string $type): void
$resolvedFrom = realpath($from);
$resolvedTo = realpath($to);
- $from = str_replace(base_path() . '/', '', is_string($resolvedFrom) ? $resolvedFrom : $from);
- $to = str_replace(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to);
+ $from = Str::replaceStart(base_path() . '/', '', is_string($resolvedFrom) ? $resolvedFrom : $from);
+ $to = Str::replaceStart(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to);
$this->components->task(sprintf(
'Copying %s [%s] to [%s]',
diff --git a/tests/Console/View/ComponentsTest.php b/tests/Console/View/ComponentsTest.php
index 5f1facffad..a74e981a91 100644
--- a/tests/Console/View/ComponentsTest.php
+++ b/tests/Console/View/ComponentsTest.php
@@ -13,14 +13,17 @@
use Hypervel\Console\View\Components\Confirm;
use Hypervel\Console\View\Components\Error;
use Hypervel\Console\View\Components\Info;
+use Hypervel\Console\View\Components\Mutators\EnsureRelativePaths;
use Hypervel\Console\View\Components\Success;
use Hypervel\Console\View\Components\Task;
use Hypervel\Console\View\Components\TwoColumnDetail;
use Hypervel\Console\View\Components\Warn;
use Hypervel\Console\View\TaskResult;
+use Hypervel\Foundation\Application;
use Hypervel\Tests\Console\Concerns\CreatesAnsweredOutputStyles;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Question\Question;
@@ -82,6 +85,30 @@ public function testInfo(): void
$this->assertStringContainsString('INFO The application is in the [production] environment.', $output->fetch());
}
+ #[DataProvider('relativePathMessages')]
+ public function testRelativePathsPreserveOtherPathsAndUrls(string $message, string $expected): void
+ {
+ new Application('/app');
+
+ $this->assertSame($expected, (new EnsureRelativePaths)($message));
+ }
+
+ public static function relativePathMessages(): array
+ {
+ return [
+ 'bracketed absolute path' => ['File [/app/config/app.php]', 'File [config/app.php]'],
+ 'caller-written markup' => ['/app/x.php>', 'x.php>'],
+ 'nested app directory' => ['/app/app/Models/User.php', 'app/Models/User.php'],
+ 'already relative path' => ['modules/app/config.php', 'modules/app/config.php'],
+ 'Unicode parent directory' => ['/café/app/config.php', '/café/app/config.php'],
+ 'service URL' => ['http://app/login', 'http://app/login'],
+ 'URL path' => ['https://example.com/app/dashboard', 'https://example.com/app/dashboard'],
+ 'editor URL query' => ['phpstorm://open?file=/app/Models/User.php&line=1', 'phpstorm://open?file=/app/Models/User.php&line=1'],
+ 'file URI' => ['file:/app/x.php', 'file:/app/x.php'],
+ 'no application path' => ['Finished successfully', 'Finished successfully'],
+ ];
+ }
+
public function testConfirm(): void
{
$result = (new Confirm($this->outputStyleWithAnswer('')))->render('Question?');
diff --git a/tests/Foundation/Console/RouteListCommandTest.php b/tests/Foundation/Console/RouteListCommandTest.php
index c64133c5dd..8dcbd05e0f 100644
--- a/tests/Foundation/Console/RouteListCommandTest.php
+++ b/tests/Foundation/Console/RouteListCommandTest.php
@@ -6,10 +6,12 @@
use Hypervel\Console\Application;
use Hypervel\Events\Dispatcher;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\Application as FoundationApplication;
use Hypervel\Foundation\Console\RouteListCommand;
use Hypervel\Foundation\Http\Kernel;
use Hypervel\Routing\Router;
+use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Console\Output\BufferedOutput;
@@ -307,6 +309,35 @@ public function testClosureRouteShowsPathInCli(): void
RouteListCommand::resolveTerminalWidthUsing(null);
}
+ public function testRoutePathsRespectApplicationAndVendorDirectoryBoundaries(): void
+ {
+ $root = ParallelTesting::tempDir('RouteListPathBoundaries');
+ $files = new Filesystem;
+ $files->deleteDirectory($root);
+ $app = FoundationApplication::getInstance();
+ $originalBasePath = $app->basePath();
+
+ try {
+ $app->setBasePath($root . '/app');
+ $files->ensureDirectoryExists($root . '/app/vendor-tools');
+ $files->ensureDirectoryExists($root . '/app-other');
+ $files->put($root . '/app/vendor-tools/routes.php', ' "ok";');
+ $files->put($root . '/app-other/routes.php', ' "ok";');
+ $this->router->get('/vendor-prefix', require $root . '/app/vendor-tools/routes.php');
+ $this->router->get('/external', require $root . '/app-other/routes.php');
+
+ $this->consoleApp->call('route:list', ['--json' => true, '--except-vendor' => true]);
+ $routes = array_column(json_decode($this->consoleApp->output(), true, flags: JSON_THROW_ON_ERROR), null, 'uri');
+
+ $this->assertArrayHasKey('vendor-prefix', $routes);
+ $this->assertSame('vendor-tools/routes.php:1', $routes['vendor-prefix']['path']);
+ $this->assertSame($root . '/app-other/routes.php:1', $routes['external']['path']);
+ } finally {
+ $app->setBasePath($originalBasePath);
+ $files->deleteDirectory($root);
+ }
+ }
+
public function testControllerRoutePathIsNull(): void
{
$hypervel = new FoundationApplication(__DIR__);
diff --git a/tests/Foundation/Console/VendorPublishCommandTest.php b/tests/Foundation/Console/VendorPublishCommandTest.php
index 745c2de389..fbcddf4536 100644
--- a/tests/Foundation/Console/VendorPublishCommandTest.php
+++ b/tests/Foundation/Console/VendorPublishCommandTest.php
@@ -64,6 +64,36 @@ public function testPublishesFile(): void
$this->assertFileEquals($source, $dest);
}
+ public function testPublishedAndSkippedPathsRemoveOnlyTheLeadingApplicationDirectory(): void
+ {
+ $source = $this->sourceDir . '/nested' . $this->sourceDir . '/config.php';
+ $destination = $this->destDir . '/nested' . $this->sourceDir . '/config.php';
+ $this->filesystem->ensureDirectoryExists(dirname($source));
+ $this->filesystem->put($source, ' $destination];
+ $originalBasePath = $this->app->basePath();
+
+ try {
+ $this->app->setBasePath($this->sourceDir);
+ $tester = $this->commandTester($this->filesystem);
+
+ $this->assertSame(0, $tester->execute(['--provider' => TestPublishProvider::class]));
+ $this->assertStringContainsString(
+ 'Copying file [nested' . $this->sourceDir . '/config.php] to [' . $destination . ']',
+ $tester->getDisplay(),
+ );
+
+ $this->assertSame(0, $tester->execute(['--provider' => TestPublishProvider::class]));
+ $this->assertStringContainsString('File [' . $destination . '] already exists', $tester->getDisplay());
+
+ $this->filesystem->delete($destination);
+ $this->assertSame(0, $tester->execute(['--provider' => TestPublishProvider::class, '--existing' => true]));
+ $this->assertStringContainsString('File [' . $destination . '] does not exist', $tester->getDisplay());
+ } finally {
+ $this->app->setBasePath($originalBasePath);
+ }
+ }
+
public function testPublishesDirectory(): void
{
$sourceSubDir = $this->sourceDir . '/views';
diff --git a/tests/Integration/Console/Events/EventListCommandTest.php b/tests/Integration/Console/Events/EventListCommandTest.php
index f33d2c98ae..241f714712 100644
--- a/tests/Integration/Console/Events/EventListCommandTest.php
+++ b/tests/Integration/Console/Events/EventListCommandTest.php
@@ -7,8 +7,10 @@
use Hypervel\Contracts\Broadcasting\ShouldBroadcast;
use Hypervel\Contracts\Queue\ShouldQueue;
use Hypervel\Events\Dispatcher;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\Console\EventListCommand;
use Hypervel\Support\Facades\Artisan;
+use Hypervel\Testing\ParallelTesting;
class EventListCommandTest extends \Hypervel\Testbench\TestCase
{
@@ -49,6 +51,37 @@ public function testDisplayEvents(): void
->expectsOutputToContain('⇂ Closure at: ' . $unixFilePath . ':' . $closureLineNumber);
}
+ public function testClosurePathRemovesOnlyTheLeadingApplicationDirectory(): void
+ {
+ $root = ParallelTesting::tempDir('EventListPaths');
+ $base = $root . '/app';
+ $path = $base . '/nested' . $base . '/listener.php';
+ $files = new Filesystem;
+ $files->deleteDirectory($root);
+ $originalBasePath = $this->app->basePath();
+
+ try {
+ $files->ensureDirectoryExists(dirname($path));
+ $files->put($path, ' null;');
+ $this->dispatcher->listen('example.event', require $path);
+ $this->app->setBasePath($base);
+
+ $this->withoutMockingConsoleOutput()->artisan(EventListCommand::class, ['--json' => true]);
+
+ $expected = 'Closure at: nested' . $base . '/listener.php:1';
+ $this->assertSame(
+ $expected,
+ json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR)[0]['listeners'][0],
+ );
+
+ $this->withoutMockingConsoleOutput()->artisan(EventListCommand::class);
+ $this->assertStringContainsString($expected, Artisan::output());
+ } finally {
+ $this->app->setBasePath($originalBasePath);
+ $files->deleteDirectory($root);
+ }
+ }
+
public function testDisplayFilteredEvent(): void
{
$this->dispatcher->subscribe(ExampleSubscriber::class);
diff --git a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php
index f49481520c..a7ae9d162e 100644
--- a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php
+++ b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php
@@ -7,10 +7,12 @@
use Hypervel\Console\Command;
use Hypervel\Console\Commands\ScheduleListCommand;
use Hypervel\Console\Scheduling\Schedule;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Facades\Artisan;
use Hypervel\Support\ProcessUtils;
use Hypervel\Testbench\TestCase;
+use Hypervel\Testing\ParallelTesting;
class ScheduleListCommandTest extends TestCase
{
@@ -128,6 +130,33 @@ public function testDisplayScheduleAsJson(): void
$this->assertSame('echo hello', $data[9]['command']);
}
+ public function testClosureLocationRemovesOnlyTheLeadingApplicationDirectory(): void
+ {
+ $root = ParallelTesting::tempDir('ScheduleListPaths');
+ $base = $root . '/app';
+ $path = $base . '/nested' . $base . '/task.php';
+ $files = new Filesystem;
+ $files->deleteDirectory($root);
+ $originalBasePath = $this->app->basePath();
+
+ try {
+ $files->ensureDirectoryExists(dirname($path));
+ $files->put($path, ' null;');
+ $this->schedule->call(require $path)->everyMinute();
+ $this->app->setBasePath($base);
+
+ $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, ['--json' => true]);
+
+ $this->assertSame(
+ 'Closure at: nested' . $base . '/task.php:1',
+ json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR)[0]['command'],
+ );
+ } finally {
+ $this->app->setBasePath($originalBasePath);
+ $files->deleteDirectory($root);
+ }
+ }
+
public function testDisplayScheduleAsJsonPreservesShellCommands(): void
{
$this->schedule->exec('grep -c "\" storage/logs/hypervel.log')->everyMinute();
From 70940f79919e71bea3a700a2da9dad68b2be4dc8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:10:58 +0000
Subject: [PATCH 14/22] Ignore form metadata when rejecting unknown request
fields
Exclude _token and _method from FormRequest's unknown-field check while continuing to reject other unexpected input. Keep the filtering at the HTTP request boundary so the shared UnknownFields validator retains its existing behavior for other callers.
Port Laravel framework #61891, commit 998623efd5c37ace066350d5d53a8fbe28652251, from the recorded 13.x source revision 5d7797528723107cd4f495326838a7d7d67fbb20. Preserve both upstream regression cases and every assertion.
Both cases fail before the source correction. The complete FormRequest tests, full local suite, formatting and static analysis pass.
---
src/foundation/src/Http/FormRequest.php | 3 ++
.../Foundation/FoundationFormRequestTest.php | 32 +++++++++++++++++++
2 files changed, 35 insertions(+)
diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php
index a471440cba..5fdec02d3e 100644
--- a/src/foundation/src/Http/FormRequest.php
+++ b/src/foundation/src/Http/FormRequest.php
@@ -19,6 +19,7 @@
use Hypervel\Foundation\Http\Concerns\HasCasts;
use Hypervel\Http\Request;
use Hypervel\Routing\Redirector;
+use Hypervel\Support\Arr;
use Hypervel\Support\ValidatedInput;
use Hypervel\Validation\UnknownFields;
use Hypervel\Validation\ValidatesWhenResolvedTrait;
@@ -267,6 +268,8 @@ protected function validateNoUnknownFields(Validator $validator): void
{
$input = $this->isJson() ? $this->json()->all() : $this->request->all();
+ $input = Arr::except($input, ['_token', '_method']);
+
UnknownFields::validate($validator, $input, $this->unfilteredValidationRules);
}
diff --git a/tests/Foundation/FoundationFormRequestTest.php b/tests/Foundation/FoundationFormRequestTest.php
index da2a857230..78008b3b69 100644
--- a/tests/Foundation/FoundationFormRequestTest.php
+++ b/tests/Foundation/FoundationFormRequestTest.php
@@ -660,6 +660,38 @@ public function testFailOnUnknownFieldsRejectsExtraJsonInput(): void
$this->assertTrue($exception->validator->errors()->has('unexpected'));
}
+ public function testFailOnUnknownFieldsIgnoresCsrfTokenAndMethodFields(): void
+ {
+ FormRequest::failOnUnknownFields();
+
+ $request = $this->createRequest(
+ ['_token' => 'token', '_method' => 'PUT', 'name' => 'Taylor'],
+ FoundationTestFormRequestStub::class,
+ 'POST'
+ );
+
+ $request->validateResolved();
+
+ $this->assertEquals(['name' => 'Taylor'], $request->validated());
+ }
+
+ public function testFailOnUnknownFieldsStillRejectsUnknownFieldsAlongsideCsrfTokenAndMethodFields(): void
+ {
+ FormRequest::failOnUnknownFields();
+
+ $request = $this->createRequest(
+ ['_token' => 'token', '_method' => 'PUT', 'name' => 'Taylor', 'unexpected' => 'value'],
+ FoundationTestFormRequestStub::class,
+ 'POST'
+ );
+
+ $exception = $this->catchException(ValidationException::class, function () use ($request): void {
+ $request->validateResolved();
+ });
+
+ $this->assertSame(['unexpected'], $exception->validator->errors()->keys());
+ }
+
public function testFailOnUnknownFieldsAllowsConfirmationFieldsWhenBaseFieldIsConfirmed(): void
{
FormRequest::failOnUnknownFields();
From 610eb7c5403fae93340f37711e2ecf5b432d11a3 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:10:58 +0000
Subject: [PATCH 15/22] Clarify the uncached enum disk purge regression
Explain why the enum purge test repeats the call after forgetting the cached disk: an integer-backed name must also pass through the string-only configuration lookup without throwing.
The test continues to exercise the existing behavior and assertions. The full filesystem manager file and complete local suite pass.
---
tests/Filesystem/FilesystemManagerTest.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php
index ccbb422864..dcce03bbd9 100644
--- a/tests/Filesystem/FilesystemManagerTest.php
+++ b/tests/Filesystem/FilesystemManagerTest.php
@@ -358,6 +358,7 @@ public function testCanPurgeDiskUsingEnum(UnitEnum $name, string $key): void
$this->assertNotSame($local, $filesystem->disk($key));
+ // An uncached integer-backed name must also reach the string-only config lookup.
$filesystem->forgetDisk($key);
$filesystem->purge($name);
}
From 5061c92a0e83d601736d73fd851fa606a18d8342 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:59:59 +0000
Subject: [PATCH 16/22] Keep relative Blade paths distinct in compiled caches
With relative hashing enabled, stripping the application directory made an absolute application view and a different relative file share the same compiled filename. Rendering one could return the cached contents of the other.
Replace the application prefix with a null-byte marker, which cannot occur in a source path. Application views remain relocatable, while external paths and default hashing retain their existing identities. Extend the existing cache identity regression with the relative-file case.
Validated with compiler and engine tests, the affected parallel suite, formatting and static analysis.
---
src/view/src/Compilers/Compiler.php | 5 +++--
tests/View/ViewBladeCompilerTest.php | 1 +
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/view/src/Compilers/Compiler.php b/src/view/src/Compilers/Compiler.php
index b854485181..f56590a02b 100755
--- a/src/view/src/Compilers/Compiler.php
+++ b/src/view/src/Compilers/Compiler.php
@@ -31,10 +31,11 @@ public function __construct(
*/
public function getCompiledPath(string $path): string
{
- // Only a leading base directory is relative; external views retain their full identity.
+ // A null byte cannot occur in a real path, so relative in-app identities
+ // cannot collide with external views, including relative file paths.
$identity = $this->basePath === ''
? $path
- : Str::replaceStart(rtrim($this->basePath, '/\\') . DIRECTORY_SEPARATOR, '', $path);
+ : Str::replaceStart(rtrim($this->basePath, '/\\') . DIRECTORY_SEPARATOR, "\0", $path);
return $this->cachePath . '/' . hash('xxh128', 'v3' . $identity) . '.' . $this->compiledExtension;
}
diff --git a/tests/View/ViewBladeCompilerTest.php b/tests/View/ViewBladeCompilerTest.php
index 6a424faa6c..0e9d64b903 100644
--- a/tests/View/ViewBladeCompilerTest.php
+++ b/tests/View/ViewBladeCompilerTest.php
@@ -113,6 +113,7 @@ public function testRelativeCompiledPathsPreserveExternalViewIdentity(): void
$this->assertSame($internal, $relocated->getCompiledPath('/another/app/views/page.blade.php'));
$this->assertNotSame($internal, $compiler->getCompiledPath('/shared/app/views/page.blade.php'));
$this->assertNotSame($internal, $compiler->getCompiledPath('/views/page.blade.php'));
+ $this->assertNotSame($internal, $compiler->getCompiledPath('views/page.blade.php'));
}
public function testCompileCompilesFileAndReturnsContents(): void
From 0d2092b994d4c3deea7c248dc5b22d939db4eff2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:00:29 +0000
Subject: [PATCH 17/22] Read Blade source metadata from the final compiler
footer
Templates containing literal PATH markers could make exception pages, dump links and Sentry identify the wrong source file. Select the last compiler marker, which is appended after the template, and require the full marker prefix when reading Sentry frames.
Resolve the dump cache directory when examining a frame instead of during provider registration. This handles a symlinked parent whose cache directory is created later by the compiler and keeps frame paths comparable to the configured directory.
Extend existing rendering and source-mapping regressions with literal markers, a filename containing PATH, and a cache directory created after registration. Validated with affected tests, formatting and static analysis.
---
src/foundation/src/Concerns/ResolvesDumpSource.php | 8 ++++++--
.../src/Exceptions/Renderer/Mappers/BladeMapper.php | 3 ++-
.../src/Providers/FoundationServiceProvider.php | 2 --
src/sentry/src/Tracing/BacktraceHelper.php | 3 ++-
.../Providers/FoundationServiceProviderTest.php | 8 ++++----
.../Foundation/Exceptions/RenderBladeFilesTest.php | 3 ++-
tests/Sentry/Tracing/BacktraceHelperTest.php | 4 ++--
7 files changed, 18 insertions(+), 13 deletions(-)
diff --git a/src/foundation/src/Concerns/ResolvesDumpSource.php b/src/foundation/src/Concerns/ResolvesDumpSource.php
index be4c34d686..4338ad17d1 100644
--- a/src/foundation/src/Concerns/ResolvesDumpSource.php
+++ b/src/foundation/src/Concerns/ResolvesDumpSource.php
@@ -103,7 +103,10 @@ protected function isCompiledViewFile(string $file): bool
}
// The compiler can use an extension other than .php; the footer identifies the source.
- return str_starts_with($file, rtrim($this->compiledViewPath, '/\\') . DIRECTORY_SEPARATOR);
+ // Frames contain resolved paths, and the compiler may create a symlinked directory after boot.
+ $directory = realpath($this->compiledViewPath) ?: $this->compiledViewPath;
+
+ return str_starts_with($file, rtrim($directory, '/\\') . DIRECTORY_SEPARATOR);
}
/**
@@ -117,7 +120,8 @@ protected function getOriginalFileForCompiledView(string $file): string
return $file;
}
- preg_match('/\/\*\*PATH\s(.*)\sENDPATH/', $contents, $matches);
+ // The compiler writes the footer last; a template can contain the same text.
+ preg_match('/.*\/\*\*PATH\s(.*)\sENDPATH/s', $contents, $matches);
if (isset($matches[1])) {
$file = $matches[1];
diff --git a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
index 0e0ce704d1..17cdd0134b 100644
--- a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
+++ b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php
@@ -96,7 +96,8 @@ public function findCompiledView(string $compiledPath): ?string
// so read the source path recorded in the compiled file's footer.
$contents = @file_get_contents($compiledPath);
- if ($contents === false || ! preg_match('/\/\*\*PATH\s(.*)\sENDPATH/', $contents, $matches)) {
+ // The compiler writes the footer last; a template can contain the same text.
+ if ($contents === false || ! preg_match('/.*\/\*\*PATH\s(.*)\sENDPATH/s', $contents, $matches)) {
return null;
}
diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php
index 59b7e052b7..c9dc179852 100644
--- a/src/foundation/src/Providers/FoundationServiceProvider.php
+++ b/src/foundation/src/Providers/FoundationServiceProvider.php
@@ -267,9 +267,7 @@ public function registerDumper(): void
$basePath = $this->app->basePath();
- // Frames contain resolved paths, even when the configured cache directory is a symlink.
$compiledViewPath = $this->config->string('view.compiled');
- $compiledViewPath = realpath($compiledViewPath) ?: $compiledViewPath;
$formatExists = array_key_exists('VAR_DUMPER_FORMAT', $_SERVER);
$format = $_SERVER['VAR_DUMPER_FORMAT'] ?? null;
diff --git a/src/sentry/src/Tracing/BacktraceHelper.php b/src/sentry/src/Tracing/BacktraceHelper.php
index b2cf8ff8f7..5ab81d817a 100644
--- a/src/sentry/src/Tracing/BacktraceHelper.php
+++ b/src/sentry/src/Tracing/BacktraceHelper.php
@@ -92,7 +92,8 @@ public function getOriginalViewPathForFrameOfCompiledViewPath(Frame $frame): ?st
return null;
}
- preg_match('/PATH (?.*?) ENDPATH/', $viewFileContents, $matches);
+ // The compiler writes the footer last; a template can contain the same text.
+ preg_match('/.*\/\*\*PATH (?.*) ENDPATH/s', $viewFileContents, $matches);
// No path comment found in the file
if (empty($matches['originalPath'])) {
diff --git a/tests/Foundation/Providers/FoundationServiceProviderTest.php b/tests/Foundation/Providers/FoundationServiceProviderTest.php
index 536a50b0f4..2e621d2289 100644
--- a/tests/Foundation/Providers/FoundationServiceProviderTest.php
+++ b/tests/Foundation/Providers/FoundationServiceProviderTest.php
@@ -220,16 +220,16 @@ public function testDumperResolvesBladeSourceFromSymlinkedCompiledDirectory(bool
try {
$this->app->setBasePath($root . '/app');
- $filesystem->ensureDirectoryExists($directory . '/compiled');
- symlink($directory . '/compiled', $directory . '/linked');
- config(['view.compiled' => $directory . '/linked']);
+ $filesystem->ensureDirectoryExists($directory);
+ symlink($directory, $directory . '/linked');
+ config(['view.compiled' => $directory . '/linked/compiled']);
$this->app->forgetInstance('blade.compiler');
$this->app->make('view.engine.resolver')->forget('blade');
$_SERVER['VAR_DUMPER_FORMAT'] = 'html';
(new FoundationServiceProvider($this->app))->registerDumper();
$path = $directory . '/example.blade.php';
- $filesystem->put($path, "@dump('example value')");
+ $filesystem->put($path, "/**PATH /another/view.blade.php ENDPATH**/ @dump('example value')");
$output = $this->app->make('view')->file($path)->render();
diff --git a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
index 5d752062ec..c01472673b 100644
--- a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
+++ b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php
@@ -153,7 +153,8 @@ public function testBladeMapperFindsTheSourceAfterRenderingHasUnwound(bool $cust
$files->deleteDirectory($directory);
$files->ensureDirectoryExists($directory);
$path = $directory . '/failing.blade.php';
- $files->put($path, "First line\n{{ missing_blade_mapping_test_function() }}");
+ $files->put($directory . '/other.blade.php', 'Another view');
+ $files->put($path, "/**PATH {$directory}/other.blade.php ENDPATH**/\n{{ missing_blade_mapping_test_function() }}");
try {
if ($symlinkedCache) {
diff --git a/tests/Sentry/Tracing/BacktraceHelperTest.php b/tests/Sentry/Tracing/BacktraceHelperTest.php
index 5fd358ae9d..8097cb5c49 100644
--- a/tests/Sentry/Tracing/BacktraceHelperTest.php
+++ b/tests/Sentry/Tracing/BacktraceHelperTest.php
@@ -60,12 +60,12 @@ public function testCustomCompiledViewPathResolvesOriginalSource(bool $symlinked
Container::getInstance()->instance('config', new Repository(['view' => ['compiled' => $compiledDirectory]]));
$compiledPath = $directory . '/compiled/example.cache';
- $filesystem->put($compiledPath, '/**PATH ' . $directory . '/resources/views/example.blade.php ENDPATH**/');
+ $filesystem->put($compiledPath, '/**PATH /another/view.blade.php ENDPATH**/ /**PATH ' . $directory . '/resources/views/MY PATH example.blade.php ENDPATH**/');
$options = new Options(['dsn' => null, 'prefixes' => [$directory]]);
$helper = new BacktraceHelper($options, new RepresentationSerializer($options));
$frame = new Frame(null, '/compiled/example.cache', 1, absoluteFilePath: $compiledPath);
- $this->assertSame('/resources/views/example.blade.php', $helper->getOriginalViewPathForFrameOfCompiledViewPath($frame));
+ $this->assertSame('/resources/views/MY PATH example.blade.php', $helper->getOriginalViewPathForFrameOfCompiledViewPath($frame));
} finally {
$filesystem->deleteDirectory($directory);
}
From cc3eb7441a9c40cd1b338a946428334fe550b164 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:00:29 +0000
Subject: [PATCH 18/22] Preserve cancellation during console timezone
resolution
Resolving configuration can invoke application callbacks even when the config instance already exists. If their cleanup wrapped cancellation after an earlier termination error, the timezone block discarded the cancellation and continued running duration handlers.
Classify cancellation chains at that boundary, preserving their precedence and skipping later callbacks. Remove unreachable catches around the HTTP kernel timestamp removal, which invokes no user callbacks.
Add a regression armed after application termination so it exercises the timezone lookup specifically. It fails with the old catch and passes with the correction. Console and HTTP kernel tests, affected parallel tests, formatting and static analysis pass.
---
src/foundation/src/Console/Kernel.php | 9 ++--
src/foundation/src/Http/Kernel.php | 8 +---
.../Console/KernelTerminateTest.php | 42 +++++++++++++++++++
3 files changed, 49 insertions(+), 10 deletions(-)
diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php
index fa22ed920e..54c7271700 100644
--- a/src/foundation/src/Console/Kernel.php
+++ b/src/foundation/src/Console/Kernel.php
@@ -230,13 +230,16 @@ public function terminate(InputInterface $input, int $status): void
if ($cancellation === null && $this->commandStartedAt !== null) {
try {
+ // Resolving config runs application callbacks, whose cleanup can wrap cancellation.
$this->commandStartedAt = $this->commandStartedAt->setTimezone(
$this->app->make('config')->string('app.timezone')
);
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
if ($cancellation === null) {
diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php
index 204e058fb7..99c6c22991 100644
--- a/src/foundation/src/Http/Kernel.php
+++ b/src/foundation/src/Http/Kernel.php
@@ -329,13 +329,7 @@ public function terminate(Request $request, Response $response): void
}
}
- try {
- CoroutineContext::forget(self::REQUEST_STARTED_AT_CONTEXT_KEY);
- } catch (CanceledException $throwable) {
- $cancellation ??= $throwable;
- } catch (Throwable $throwable) {
- $exception ??= $throwable;
- }
+ CoroutineContext::forget(self::REQUEST_STARTED_AT_CONTEXT_KEY);
if ($cancellation !== null) {
throw $cancellation;
diff --git a/tests/Foundation/Console/KernelTerminateTest.php b/tests/Foundation/Console/KernelTerminateTest.php
index bf7624c1ab..77c4cdefd2 100644
--- a/tests/Foundation/Console/KernelTerminateTest.php
+++ b/tests/Foundation/Console/KernelTerminateTest.php
@@ -433,6 +433,48 @@ public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFa
$this->assertNull($kernel->commandStartedAt());
}
+ public function testTimezoneResolutionCancellationSupersedesAnEarlierTerminationFailure(): void
+ {
+ $cancellation = new RuntimeException('cleanup failed', 0, new CanceledException('canceled'));
+ $kernel = $this->app->make(KernelContract::class);
+ $kernel->command('foo', fn () => null);
+ $this->app->make(Dispatcher::class)->listen(Terminating::class, function (): void {
+ throw new RuntimeException('event failed');
+ });
+ $called = false;
+ $kernel->whenCommandLifecycleIsLongerThan(0, function () use (&$called): void {
+ $called = true;
+ });
+
+ CarbonImmutable::setTestNow(CarbonImmutable::now());
+ $input = new StringInput('foo');
+ $kernel->handle($input, new ConsoleOutput);
+ CarbonImmutable::setTestNow(CarbonImmutable::now()->addSecond());
+
+ // Earlier termination stages also resolve config; target the timezone lookup.
+ $armed = false;
+ $this->app->terminating(function () use (&$armed): void {
+ $armed = true;
+ });
+ $this->app->beforeResolving('config', function () use (&$armed, $cancellation): void {
+ if ($armed) {
+ $armed = false;
+
+ throw $cancellation;
+ }
+ });
+
+ try {
+ $kernel->terminate($input, 0);
+ $this->fail('Expected timezone resolution cancellation to supersede the earlier failure.');
+ } catch (Throwable $exception) {
+ $this->assertSame($cancellation, $exception);
+ }
+
+ $this->assertFalse($called);
+ $this->assertNull($kernel->commandStartedAt());
+ }
+
public function testApplicationFailurePrecedesDurationHandlerFailure(): void
{
$calls = [];
From cc247974cf4c35d26996776a4ad065cd48bb3aa9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:00:48 +0000
Subject: [PATCH 19/22] Purge the default filesystem for an empty disk name
An empty disk name selects the default filesystem when resolving a disk, but purge previously treated it as a separate empty cache key. That left the default disk and any associated pool alive.
Normalize an empty name to the default before looking up or invalidating the disk. Preserve integer-backed zero as a named disk. Keep set and forgetDisk as explicit-name operations.
Extend the existing cached and uncached enum purge coverage with empty and zero names. Filesystem tests, the affected parallel suite, formatting and static analysis pass.
---
src/filesystem/src/FilesystemManager.php | 5 ++++-
tests/Filesystem/FilesystemManagerTest.php | 6 ++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php
index 4cf2d2374a..5d48a71bb3 100644
--- a/src/filesystem/src/FilesystemManager.php
+++ b/src/filesystem/src/FilesystemManager.php
@@ -1036,7 +1036,10 @@ public function forgetDisk(UnitEnum|array|string $disk): static
*/
public function purge(UnitEnum|string|null $name = null): void
{
- $name = (string) (enum_value($name) ?? $this->getDefaultDriver());
+ $name = enum_value($name);
+ $name = $name === null || $name === ''
+ ? $this->getDefaultDriver()
+ : (string) $name;
$disk = $this->disks[$name] ?? null;
unset($this->disks[$name]);
diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php
index dcce03bbd9..0923f8f87d 100644
--- a/tests/Filesystem/FilesystemManagerTest.php
+++ b/tests/Filesystem/FilesystemManagerTest.php
@@ -62,11 +62,13 @@
enum FilesystemTestStringBackedDisk: string
{
case Local = 'local';
+ case Default = '';
}
enum FilesystemTestIntBackedDisk: int
{
case Local = 1;
+ case Zero = 0;
}
enum FilesystemTestUnitDisk
@@ -349,6 +351,8 @@ public function testCanForgetDiskUsingEnum(): void
#[TestWith([FilesystemManagerTestDisk::Local, 'local'])]
#[TestWith([FilesystemTestUnitDisk::local, 'local'])]
#[TestWith([FilesystemTestIntBackedDisk::Local, '1'])]
+ #[TestWith([FilesystemTestStringBackedDisk::Default, 'local'])]
+ #[TestWith([FilesystemTestIntBackedDisk::Zero, '0'])]
public function testCanPurgeDiskUsingEnum(UnitEnum $name, string $key): void
{
$filesystem = $this->filesystemManagerWithLocalDisks();
@@ -2710,10 +2714,12 @@ protected function readThroughFilesystemManager(
protected function filesystemManagerWithLocalDisks(): FilesystemManager
{
return new FilesystemManager($this->getContainer([
+ 'default' => 'local',
'disks' => [
'local' => ['driver' => 'local', 'root' => $this->tempDir . '/local'],
'backup' => ['driver' => 'local', 'root' => $this->tempDir . '/backup'],
'1' => ['driver' => 'local', 'root' => $this->tempDir . '/integer'],
+ '0' => ['driver' => 'local', 'root' => $this->tempDir . '/zero'],
],
]));
}
From bfb011c1429cddcba7d4e15668e8354d85f4edf6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:00:48 +0000
Subject: [PATCH 20/22] Describe the console relative-path data provider
Add the required method title to the provider of console messages and expected relative paths. This documents the provider without adding commentary to individual test methods or changing coverage.
Validated with the console component tests and repository formatting.
---
tests/Console/View/ComponentsTest.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/Console/View/ComponentsTest.php b/tests/Console/View/ComponentsTest.php
index a74e981a91..1e97e45cee 100644
--- a/tests/Console/View/ComponentsTest.php
+++ b/tests/Console/View/ComponentsTest.php
@@ -93,6 +93,9 @@ public function testRelativePathsPreserveOtherPathsAndUrls(string $message, stri
$this->assertSame($expected, (new EnsureRelativePaths)($message));
}
+ /**
+ * Return messages and their expected relative paths.
+ */
public static function relativePathMessages(): array
{
return [
From 375c67e220b499be64a4a8e48bb2d0f6db300f04 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:00:48 +0000
Subject: [PATCH 21/22] Check the serialized inline attachment identifier
The Markdown mail regression compared the HTML reference with the attachment object but only checked that the serialized message contained some Content-ID header.
Require the serialized header to contain the same identifier referenced by the HTML. This strengthens the existing assertion without adding another test or depending on MIME filename folding.
Validated with the Markdown mail tests and the affected parallel suite.
---
tests/Integration/Mail/SendingMarkdownMailTest.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php
index e84a7044aa..ce36cb040f 100644
--- a/tests/Integration/Mail/SendingMarkdownMailTest.php
+++ b/tests/Integration/Mail/SendingMarkdownMailTest.php
@@ -116,7 +116,7 @@ public function testEmbed(): void
$raw = $email->toString();
$this->assertStringContainsString('Content-Type: application/x-php;', $raw);
$this->assertStringContainsString(pathinfo($textFilename[1], PATHINFO_FILENAME), $raw);
- $this->assertStringContainsString('Content-ID: <', $raw);
+ $this->assertStringContainsString("Content-ID: <{$contentId[1]}>", $raw);
}
public function testEmbedData(): void
From 212cd9b680455342f4ae255aeaae92f6da82d75b Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:10:07 +0000
Subject: [PATCH 22/22] Preserve the explicit empty-name filesystem purge
contract
Restore the upstream distinction between default selection and an explicit empty name. The purge API selects the default only for null; matching disk resolution was not sufficient reason to change that behavior.
Remove the empty-name normalization and its test case, while retaining coverage that integer-backed zero targets its own cached and uncached disk. The source now matches the previously published implementation.
Validated with the complete FilesystemManager test file, targeted formatting and diff checks.
---
src/filesystem/src/FilesystemManager.php | 5 +----
tests/Filesystem/FilesystemManagerTest.php | 3 ---
2 files changed, 1 insertion(+), 7 deletions(-)
diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php
index 5d48a71bb3..4cf2d2374a 100644
--- a/src/filesystem/src/FilesystemManager.php
+++ b/src/filesystem/src/FilesystemManager.php
@@ -1036,10 +1036,7 @@ public function forgetDisk(UnitEnum|array|string $disk): static
*/
public function purge(UnitEnum|string|null $name = null): void
{
- $name = enum_value($name);
- $name = $name === null || $name === ''
- ? $this->getDefaultDriver()
- : (string) $name;
+ $name = (string) (enum_value($name) ?? $this->getDefaultDriver());
$disk = $this->disks[$name] ?? null;
unset($this->disks[$name]);
diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php
index 0923f8f87d..181398b211 100644
--- a/tests/Filesystem/FilesystemManagerTest.php
+++ b/tests/Filesystem/FilesystemManagerTest.php
@@ -62,7 +62,6 @@
enum FilesystemTestStringBackedDisk: string
{
case Local = 'local';
- case Default = '';
}
enum FilesystemTestIntBackedDisk: int
@@ -351,7 +350,6 @@ public function testCanForgetDiskUsingEnum(): void
#[TestWith([FilesystemManagerTestDisk::Local, 'local'])]
#[TestWith([FilesystemTestUnitDisk::local, 'local'])]
#[TestWith([FilesystemTestIntBackedDisk::Local, '1'])]
- #[TestWith([FilesystemTestStringBackedDisk::Default, 'local'])]
#[TestWith([FilesystemTestIntBackedDisk::Zero, '0'])]
public function testCanPurgeDiskUsingEnum(UnitEnum $name, string $key): void
{
@@ -2714,7 +2712,6 @@ protected function readThroughFilesystemManager(
protected function filesystemManagerWithLocalDisks(): FilesystemManager
{
return new FilesystemManager($this->getContainer([
- 'default' => 'local',
'disks' => [
'local' => ['driver' => 'local', 'root' => $this->tempDir . '/local'],
'backup' => ['driver' => 'local', 'root' => $this->tempDir . '/backup'],