From 8990777b39721a3f2fd4e575f7fd63da58de09a5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 5 Sep 2026 15:46:14 +0000
Subject: [PATCH 01/22] Clarify class imports for new and ported code
Require imported short class names for fully and partially qualified references, with aliases for collisions and no redundant same-namespace imports. Retain the exception for clearer config-style identifier lists.
List the class-import convention explicitly among approved porting adaptations so upstream style preservation does not override it.
---
AGENTS.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index c91c1205c..29e758ab8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -175,7 +175,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan
- **Contract signature dependencies are lazy** — contract signatures may natively reference types from optional split packages without a reverse Composer dependency. Do not remove these types or add cyclic dependencies solely for split-package isolation.
- **Newly written classes use dependency injection** — inject contracts (e.g. `Repository $config`, `CacheRepository $cache`) via constructor or method injection rather than helpers, facades, or `new` for framework services. Dependencies become explicit in signatures and tests swap them in directly, without facade-mocking machinery. Fall back to `Container::getInstance()->make(...)` only where injection isn't possible — static contexts and traits, like the testing package's Concerns. Helpers (`config()`, `cache()`) are fine in non-class contexts such as route and config files.
- **Never convert ported code to dependency injection** — ported code keeps its upstream facade, helper, and instantiation style. Converting it restructures classes and breaks 1:1 upstream mergeability.
-- **Import classes, don't use FQCNs** — always add a `use` statement and reference the short name. The only exceptions are places where FQCNs genuinely make more sense, such as middleware arrays and similar config-style identifier lists.
+- **Use imported short class names** — applies to new and ported code. Replace fully or partially qualified class references with imported short names; use aliases for naming collisions. Classes in the current namespace need no import. Keep fully qualified names where genuinely clearer, such as middleware arrays and similar config-style identifier lists.
- **Group traits in `Concerns/`** — follow the package's existing convention if it already has a `Concerns/` or `Traits/` directory; never mix both in one package. New Hypervel-original packages always use `Concerns/`; a newly ported package keeps its upstream directory name.
- **Use Laravel observer conventions** — place Eloquent observers in a top-level `Observers/` directory. Register model-specific observers with `#[ObservedBy(...)]`; use `observe()` only for dynamic registration or observers supplied automatically by a reusable concern.
- **Use attributed local scopes** — define local Eloquent query scopes as protected methods marked with `#[Scope]`, rather than legacy public `scopeFoo()` methods. Use separate scope classes only for genuine global scopes.
@@ -756,6 +756,7 @@ Run full PHPStan checks with `composer analyse`. During implementation, use targ
When porting Laravel packages, whether first-party or third-party, keep them as close to 1:1 with upstream as possible so future changes are easy to merge. The exceptions are:
- Modernizing PHP types, including native parameter, return, property, and class-constant types, plus other appropriate PHP 8.4+ features, strict types, and strict comparisons
+- Applying the class-import convention, including its exceptions
- Converting mutable Laravel date construction to Hypervel's immutable date conventions, typing configurable factory output as `CarbonInterface`, and capturing date-modifier return values
- Converting container array access (`$app['events']`) and dynamic service-property access (`$app->events`) in ported code to named container methods, and applying the Configuration rules to ported config and its consumers
- Adding Laravel-style title docblocks to methods (not classes — see Development Conventions)
From 29e238d95bbc7ce639e34702452daf9bec73a495 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 5 Sep 2026 16:08:17 +0000
Subject: [PATCH 02/22] Document database query listener callback types and
lifetime
Port Laravel framework PR #57633 using the current 13.x implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2:
https://github.com/laravel/framework/pull/57633
Describe the QueryExecuted argument supplied to Connection::listen callbacks. Complete the upstream callable annotation with an explicit mixed return type, which PHPStan requires and which preserves arbitrary callback results, including false to stop event propagation.
Document that registration persists on the worker-global dispatcher and belongs at boot. Use the existing QueryExecuted import as required by the porting convention. Native signatures and runtime behavior remain unchanged.
Validation: scoped PHP-CS-Fixer, full source and type-fixture PHPStan analysis, and scoped ParaTest for DatabaseConnectionTest and DatabaseIntegrationTest passed (97 tests, 419 assertions). The upstream PR changes no tests. Independently reviewed and signed off by claude-laravel-parity.
---
src/database/src/Connection.php | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php
index 167c6c5ee..e4ca35ec4 100755
--- a/src/database/src/Connection.php
+++ b/src/database/src/Connection.php
@@ -1027,10 +1027,15 @@ public function getErrorCount(): int
/**
* Register a database query listener with the connection.
+ *
+ * Boot-only. Registers a listener on the worker-global event dispatcher;
+ * per-request registration persists and affects subsequent requests.
+ *
+ * @param Closure(QueryExecuted): mixed $callback
*/
public function listen(Closure $callback): void
{
- $this->events?->listen(Events\QueryExecuted::class, $callback);
+ $this->events?->listen(QueryExecuted::class, $callback);
}
/**
From 732500a627a0f0c17f03d393eced101147bc1a8e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 5 Sep 2026 16:59:14 +0000
Subject: [PATCH 03/22] Exempt test methods from required title docblocks
Clarify the approved exception for test-prefixed methods in test classes. Fixture and helper methods remain covered by the existing method documentation rule.
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 29e758ab8..6d02e28b0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -179,7 +179,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan
- **Group traits in `Concerns/`** — follow the package's existing convention if it already has a `Concerns/` or `Traits/` directory; never mix both in one package. New Hypervel-original packages always use `Concerns/`; a newly ported package keeps its upstream directory name.
- **Use Laravel observer conventions** — place Eloquent observers in a top-level `Observers/` directory. Register model-specific observers with `#[ObservedBy(...)]`; use `observe()` only for dynamic registration or observers supplied automatically by a reusable concern.
- **Use attributed local scopes** — define local Eloquent query scopes as protected methods marked with `#[Scope]`, rather than legacy public `scopeFoo()` methods. Use separate scope classes only for genuine global scopes.
-- **No class docblocks unless warranted** — only add a class-level docblock if something unusual or complex needs explanation: purpose, architectural role, usage patterns. Never write one that inventories the class — trait lists, method summaries, "registers X, configures Y" — that duplicates the members' own docblocks and goes stale. Method docblocks (title only, Laravel-style, imperative mood: "Return", not "Returns") are always added. A body can accompany the title for complex methods that need further explanation.
+- **No class docblocks unless warranted** — only add a class-level docblock if something unusual or complex needs explanation: purpose, architectural role, usage patterns. Never write one that inventories the class — trait lists, method summaries, "registers X, configures Y" — that duplicates the members' own docblocks and goes stale. Method docblocks (title only, Laravel-style, imperative mood: "Return", not "Returns") are always added, except on `test*` methods in test classes. A body can accompany the title for complex methods that need further explanation.
- **Add comments only where they're genuinely useful** — a short WHY for logic that isn't obvious from reading the code, the reason behind a bug fix, or logic that's coupled to code in other files and hard to understand in isolation. Avoid unnecessary code comments; don't comment what the code does, and don't annotate framework divergences, routine casts, or type normalizations.
- **Don't make classes final by default** — keep classes open; add `final` only when it protects a real invariant or avoids a concrete framework/API problem, e.g. immutability, coroutine-safety, or a security guarantee.
- **Place methods logically, not at the end** — group new methods with related ones (getters with getters, setters with setters). Two exceptions: preserve upstream order when merging ported code (see Porting rules), and `flushState()` has its own placement rule (see Static state and test cleanup).
From 77aff7f7287898e12306ac911a4419034d410ad8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 5 Sep 2026 17:00:02 +0000
Subject: [PATCH 04/22] Complete queued notification routing test parity
Hypervel already preserves notification connection and queue defaults when channel maps omit an entry, as required by Laravel #57625. Complete parity with current 13.x by requiring exactly two container accesses in each of its three two-channel cases, incorporating those later assertions from #61117 without claiming the rest of that PR is ported.
Retain all six channel, connection, and queue payload predicates. Apply native SendQueuedNotifications callback types, bool returns, and precise fixture signatures with method-title documentation. Preserve Hypervel event-listener registration at provider boot. This changes tests only and adds no production runtime overhead.
Porting source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Upstream: https://github.com/laravel/framework/pull/57625
Partially incorporated: https://github.com/laravel/framework/pull/61117
Validation: NotificationSenderTest passes with 26 tests and 76 assertions; scoped formatting and git diff --check pass. Full source analysis was already clean at the unchanged source revision. Reviewed and signed off by claude-laravel-parity.
---
.../Notifications/NotificationSenderTest.php | 51 +++++++++++++------
1 file changed, 36 insertions(+), 15 deletions(-)
diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php
index 6fc4bc349..7676d99be 100644
--- a/tests/Notifications/NotificationSenderTest.php
+++ b/tests/Notifications/NotificationSenderTest.php
@@ -19,6 +19,7 @@
use Hypervel\Notifications\Notifiable;
use Hypervel\Notifications\Notification;
use Hypervel\Notifications\NotificationSender;
+use Hypervel\Notifications\SendQueuedNotifications;
use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\Attributes\Queue;
use Hypervel\Tests\TestCase;
@@ -230,16 +231,16 @@ public function testItCanSendQueuedNotificationsWithAnArrayVia(): void
{
$notifiable = m::mock(Notifiable::class);
$manager = m::mock(ChannelManager::class);
- $manager->shouldReceive('getContainer')->andReturn(app());
+ $manager->shouldReceive('getContainer')->twice()->andReturn(app());
$bus = m::mock(BusDispatcherContract::class);
$bus->shouldReceive('dispatch')
->once()
- ->withArgs(function ($job) {
+ ->withArgs(function (SendQueuedNotifications $job): bool {
return $job->queue === 'dummy' && $job->channels === ['database'] && $job->connection === 'redis';
});
$bus->shouldReceive('dispatch')
->once()
- ->withArgs(function ($job) {
+ ->withArgs(function (SendQueuedNotifications $job): bool {
return $job->queue === 'dummy' && $job->channels === ['mail'] && $job->connection === 'redis';
});
@@ -331,16 +332,16 @@ public function testItCanSendQueuedWithViaConnectionsNotifications(): void
{
$notifiable = new AnonymousNotifiable;
$manager = m::mock(ChannelManager::class);
- $manager->shouldReceive('getContainer')->andReturn(app());
+ $manager->shouldReceive('getContainer')->twice()->andReturn(app());
$bus = m::mock(BusDispatcherContract::class);
$bus->shouldReceive('dispatch')
->once()
- ->withArgs(function ($job) {
+ ->withArgs(function (SendQueuedNotifications $job): bool {
return $job->connection === 'sync' && $job->channels === ['database'] && $job->queue === 'dummy';
});
$bus->shouldReceive('dispatch')
->once()
- ->withArgs(function ($job) {
+ ->withArgs(function (SendQueuedNotifications $job): bool {
return $job->connection === 'redis' && $job->channels === ['mail'] && $job->queue === 'dummy';
});
@@ -355,16 +356,16 @@ public function testItCanSendQueuedWithViaQueuesNotifications(): void
{
$notifiable = new AnonymousNotifiable;
$manager = m::mock(ChannelManager::class);
- $manager->shouldReceive('getContainer')->andReturn(app());
+ $manager->shouldReceive('getContainer')->twice()->andReturn(app());
$bus = m::mock(BusDispatcherContract::class);
$bus->shouldReceive('dispatch')
->once()
- ->withArgs(function ($job) {
+ ->withArgs(function (SendQueuedNotifications $job): bool {
return $job->queue === 'dummy' && $job->channels === ['database'] && $job->connection === 'redis';
});
$bus->shouldReceive('dispatch')
->once()
- ->withArgs(function ($job) {
+ ->withArgs(function (SendQueuedNotifications $job): bool {
return $job->queue === 'admin_notifications' && $job->channels === ['mail'] && $job->connection === 'redis';
});
@@ -709,6 +710,9 @@ class DummyQueuedNotificationWithArrayVia extends Notification implements Should
{
use Queueable;
+ /**
+ * Create a new notification instance.
+ */
public function __construct()
{
$this->connection = 'redis';
@@ -717,9 +721,8 @@ public function __construct()
/**
* Get the notification channels.
- * @param mixed $notifiable
*/
- public function via($notifiable)
+ public function via(mixed $notifiable): array
{
return ['mail', 'database'];
}
@@ -777,18 +780,27 @@ class DummyNotificationWithViaConnections extends Notification implements Should
{
use Queueable;
+ /**
+ * Create a new notification instance.
+ */
public function __construct()
{
$this->connection = 'redis';
$this->queue = 'dummy';
}
- public function via($notifiable)
+ /**
+ * Get the notification channels.
+ */
+ public function via(mixed $notifiable): array
{
return ['mail', 'database'];
}
- public function viaConnections()
+ /**
+ * Determine which connections should be used for each notification channel.
+ */
+ public function viaConnections(): array
{
return [
'database' => 'sync',
@@ -800,18 +812,27 @@ class DummyNotificationWithViaQueues extends Notification implements ShouldQueue
{
use Queueable;
+ /**
+ * Create a new notification instance.
+ */
public function __construct()
{
$this->connection = 'redis';
$this->queue = 'dummy';
}
- public function via($notifiable)
+ /**
+ * Get the notification channels.
+ */
+ public function via(mixed $notifiable): array
{
return ['mail', 'database'];
}
- public function viaQueues()
+ /**
+ * Determine which queues should be used for each notification channel.
+ */
+ public function viaQueues(): array
{
return [
'mail' => 'admin_notifications',
From 494aeff6a9b3885810ce8142767b37048942cfb1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 5 Sep 2026 18:19:22 +0000
Subject: [PATCH 05/22] Run complete Queue and Cache integration suites against
Redis
Port the remaining CI coverage from Laravel framework PR #57641. The
asynchronous chaining assertions and conditional Redis test lifecycle were
already present, but four queue suites forced the database driver and the
workflow selected only two driver-neutral files.
Honor QUEUE_CONNECTION in the debounce, listener, unique-job, and worker
test environments while retaining database for unconfigured local runs.
Restore applicable current upstream debounce driver conditions and assert
chain emptiness through the selected queue, including delayed and reserved
Redis jobs, instead of inspecting the database jobs table.
Run the entire Queue directory with Redis selected on Redis 8, Redis
Cluster, and Valkey 9. Also adopt the current Laravel workflow's whole
Cache directory selection, as requested, with CACHE_STORE=redis. Preserve
Hypervel's capped standalone parallelism and serial Cluster isolation;
the nested Redis test directories are discovered once. No production
source or public API changes are needed.
Upstream: https://github.com/laravel/framework/pull/57641
Current debounce test conditions: https://github.com/laravel/framework/pull/59507
Redis workflow pattern: https://github.com/laravel/framework/pull/57710
Porting source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Later PR references identify incorporated behavior, not full dispositions.
Validation: each changed test file passes database, Redis, and applicable
sync cases. Complete Queue suite passes standalone and Cluster with 277
tests and 1,254 assertions each; Cache passes with 569 tests and 2,603 /
2,614 assertions. Scoped formatting, YAML parsing, shell syntax, and diff
checks pass. Local runs use PHP 8.4 and Redis 8.8; Valkey and PHP 8.5 remain
covered by CI configuration rather than these local runs.
Reviewed and signed off by claude-laravel-parity.
---
.github/workflows/redis.yml | 18 ++++------
tests/Integration/Queue/DebouncedJobTest.php | 33 +++++++++++++++++--
.../Queue/DebouncedListenerTest.php | 5 ++-
tests/Integration/Queue/UniqueJobTest.php | 5 ++-
tests/Integration/Queue/WorkCommandTest.php | 5 ++-
5 files changed, 49 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml
index 067c60d0a..3f0f19060 100644
--- a/.github/workflows/redis.yml
+++ b/.github/workflows/redis.yml
@@ -69,13 +69,11 @@ jobs:
run: |
vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis
vendor/bin/paratest --max-processes=15 tests/Integration/Broadcasting/Redis
- vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis
+ CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache
vendor/bin/paratest --max-processes=15 tests/Integration/Horizon
vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis
vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis
- vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis
- QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php
- QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php
+ QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue
vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis
vendor/bin/paratest --max-processes=15 tests/Integration/Redis
vendor/bin/paratest --max-processes=15 tests/Integration/Session/Redis
@@ -225,13 +223,11 @@ jobs:
run: |
vendor/bin/phpunit --no-progress tests/Integration/Auth/Redis
vendor/bin/phpunit --no-progress tests/Integration/Broadcasting/Redis
- vendor/bin/phpunit --no-progress tests/Integration/Cache/Redis
+ CACHE_STORE=redis vendor/bin/phpunit --no-progress tests/Integration/Cache
vendor/bin/phpunit --no-progress tests/Integration/Horizon
vendor/bin/phpunit --no-progress tests/Integration/Http/Redis
vendor/bin/phpunit --no-progress tests/Integration/OpenTelemetry/Redis
- vendor/bin/phpunit --no-progress tests/Integration/Queue/Redis
- QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php
- QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php
+ QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue
vendor/bin/phpunit --no-progress tests/Integration/RateLimiter/Redis
vendor/bin/phpunit --no-progress tests/Integration/Session/Redis
# This explicit list contains the topology-neutral Redis tests; the remaining files require standalone-only behavior.
@@ -306,13 +302,11 @@ jobs:
run: |
vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis
vendor/bin/paratest --max-processes=15 tests/Integration/Broadcasting/Redis
- vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis
+ CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache
vendor/bin/paratest --max-processes=15 tests/Integration/Horizon
vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis
vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis
- vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis
- QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php
- QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php
+ QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue
vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis
vendor/bin/paratest --max-processes=15 tests/Integration/Redis
vendor/bin/phpunit --no-progress tests/Integration/Reverb/ClearStateCommandTest.php
diff --git a/tests/Integration/Queue/DebouncedJobTest.php b/tests/Integration/Queue/DebouncedJobTest.php
index ecfa1b2a7..f5601f22a 100644
--- a/tests/Integration/Queue/DebouncedJobTest.php
+++ b/tests/Integration/Queue/DebouncedJobTest.php
@@ -32,17 +32,22 @@
#[WithMigration('queue')]
class DebouncedJobTest extends QueueTestCase
{
+ /**
+ * Define the test environment.
+ */
protected function defineEnvironment(ApplicationContract $app): void
{
parent::defineEnvironment($app);
$config = $app->make('config');
$config->set('cache.default', 'database');
- $config->set('queue.default', 'database');
+ $config->set('queue.default', env('QUEUE_CONNECTION', 'database'));
}
public function testDebouncedJobDispatchesAndExecutes(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']);
+
DebouncedTestJob::resetState();
dispatch(new DebouncedTestJob('entity-1'));
@@ -54,6 +59,8 @@ public function testDebouncedJobDispatchesAndExecutes(): void
public function testSupersededDebouncedJobIsSkipped(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']);
+
DebouncedTestJob::resetState();
dispatch(new DebouncedTestJob('entity-1'));
@@ -67,6 +74,8 @@ public function testSupersededDebouncedJobIsSkipped(): void
public function testTokenPersistsAfterSuccessfulExecution(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']);
+
DebouncedTestJob::resetState();
dispatch($job = new DebouncedTestJob('entity-1'));
@@ -92,6 +101,8 @@ public function testFailedDebouncedJobStillCallsHandler(): void
public function testJobDebouncedEventFiresForSupersededJob(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']);
+
$firedCount = 0;
Event::listen(JobDebounced::class, function () use (&$firedCount): void {
@@ -148,6 +159,8 @@ public function testDebounceOwnerSurvivesSerialization(): void
public function testDifferentDebounceIdsDoNotInterfere(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']);
+
DebouncedTestJob::resetState();
dispatch(new DebouncedTestJob('entity-1'));
@@ -195,6 +208,8 @@ public function testBusFakeRetainsDebounceMaximumWaitState(): void
public function testJobExecutesWhenCacheTokenIsEvicted(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']);
+
DebouncedTestJob::resetState();
dispatch($job = new DebouncedTestJob('entity-1'));
@@ -246,6 +261,8 @@ public function testReleaseClearsMaxWaitTimestamp(): void
public function testSupersededDebouncedJobDoesNotDispatchChain(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']);
+
DebouncedTestJob::resetState();
ChainReceiverJob::resetState();
@@ -257,11 +274,13 @@ public function testSupersededDebouncedJobDoesNotDispatchChain(): void
$this->assertSame(1, DebouncedTestJob::$handleCount);
$this->assertFalse(ChainReceiverJob::$handled);
- $this->assertDatabaseCount('jobs', 0);
+ $this->assertSame(0, Queue::size());
}
public function testDebounceViaUsesCustomCacheStore(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']);
+
DebouncedWithCustomCacheJob::resetState();
dispatch(new DebouncedWithCustomCacheJob('entity-1'));
@@ -274,6 +293,8 @@ public function testDebounceViaUsesCustomCacheStore(): void
public function testMaxDebounceWaitForcesImmediateExecution(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']);
+
DebouncedWithMaxWaitJob::resetState();
dispatch(new DebouncedWithMaxWaitJob('entity-1'));
@@ -291,6 +312,8 @@ public function testMaxDebounceWaitForcesImmediateExecution(): void
public function testMaxDebounceWaitStartsOverAfterTheDebouncedJobRuns(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']);
+
DebouncedWithMaxWaitJob::resetState();
dispatch(new DebouncedWithMaxWaitJob('entity-1'));
@@ -311,6 +334,8 @@ public function testMaxDebounceWaitStartsOverAfterTheDebouncedJobRuns(): void
public function testMaxDebounceWaitIsNotReleasedWhenMiddlewareReleasesTheJob(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']);
+
dispatch(new DebouncedWithReleasingMiddlewareJob('entity-1'));
$this->travelDebounceTo(CarbonImmutable::now()->addSeconds(31));
@@ -327,6 +352,8 @@ public function testMaxDebounceWaitIsNotReleasedWhenMiddlewareReleasesTheJob():
public function testDebounceWithoutMaxWaitAllowsIndefiniteDelay(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']);
+
$job1 = new DebouncedTestJob('entity-1');
$pending = dispatch($job1);
unset($pending);
@@ -350,6 +377,8 @@ public function testDebounceLockReadsMaxWaitFromAttribute(): void
public function testChildDebouncedJobInheritsFromParent(): void
{
+ $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']);
+
ChildOfDebouncedTestJob::resetState();
dispatch(new ChildOfDebouncedTestJob('entity-1'));
diff --git a/tests/Integration/Queue/DebouncedListenerTest.php b/tests/Integration/Queue/DebouncedListenerTest.php
index 3ba4ec013..96ed9a97e 100644
--- a/tests/Integration/Queue/DebouncedListenerTest.php
+++ b/tests/Integration/Queue/DebouncedListenerTest.php
@@ -17,13 +17,16 @@
#[WithMigration('queue')]
class DebouncedListenerTest extends QueueTestCase
{
+ /**
+ * Define the test environment.
+ */
protected function defineEnvironment(ApplicationContract $app): void
{
parent::defineEnvironment($app);
$config = $app->make('config');
$config->set('cache.default', 'database');
- $config->set('queue.default', 'database');
+ $config->set('queue.default', env('QUEUE_CONNECTION', 'database'));
}
public function testSupersededDebouncedListenerIsSkipped(): void
diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php
index 8c3df090f..6e0e9931a 100644
--- a/tests/Integration/Queue/UniqueJobTest.php
+++ b/tests/Integration/Queue/UniqueJobTest.php
@@ -29,13 +29,16 @@
#[WithMigration('queue')]
class UniqueJobTest extends QueueTestCase
{
+ /**
+ * Define the test environment.
+ */
protected function defineEnvironment(ApplicationContract $app): void
{
parent::defineEnvironment($app);
$config = $app->make('config');
$config->set('cache.default', 'database');
- $config->set('queue.default', 'database');
+ $config->set('queue.default', env('QUEUE_CONNECTION', 'database'));
}
public function testUniqueJobsAreNotDispatched()
diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php
index 4edbbbf5d..1135a54e3 100644
--- a/tests/Integration/Queue/WorkCommandTest.php
+++ b/tests/Integration/Queue/WorkCommandTest.php
@@ -28,11 +28,14 @@ class WorkCommandTest extends QueueTestCase
{
use DatabaseMigrations;
+ /**
+ * Define the test environment.
+ */
protected function defineEnvironment(ApplicationContract $app): void
{
parent::defineEnvironment($app);
- $app->make('config')->set('queue.default', 'database');
+ $app->make('config')->set('queue.default', env('QUEUE_CONNECTION', 'database'));
}
protected function setUp(): void
From e21a4104bbedb011830375d7e58e4f2376f3b002 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 5 Sep 2026 19:22:36 +0000
Subject: [PATCH 06/22] Complete Laravel file validation parity for Symfony
files and uploads
Port the current Laravel 13.x behavior associated with:
https://github.com/laravel/framework/pull/57656
Source revision: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2
The custom File-rule message guard already existed, but its file-type
classification recognized only Hypervel uploads. Symfony files and uploads
therefore selected string-size messages or the generic File-rule error.
Recognize Symfony File consistently across message selection, required
checks, size calculation, and file validation.
Complete the earlier Hyperf-to-HttpFoundation migration: Symfony uploads
must receive the same failed-upload checks and PHP client-filename checks
as Hypervel's subclass. Update both compiled and delegated validation while
preserving the optimized size-comparison and presence-preflight paths.
Restore upstream File fixtures and all intended SVG File/upload and MIME
cases. Correct upstream SVG constructor arguments and ineffective MIME
fixtures, retain existing assertions, and add focused coverage for the
reproduced message, invalid-upload, and PHP-filename failures. Incorporate
the current failed-upload test call counts from part of:
https://github.com/laravel/framework/pull/61117
The remainder of that PR is still tracked for its own parity review.
Correct the maximum-image-dimensions example from "at least" to "at most";
the same wording defect is present in Laravel docs at
2914ba0b06c6be40c2f1f992555853f6266707d6.
Validation: all four edited test files pass individually. Validation unit
ParaTest passes 1682 tests / 5812 assertions. Integration validation with
SQLite passes 263 cases / 271 assertions, with 167 other-driver skips.
Full source/type PHPStan, scoped PHP-CS-Fixer, and diff checks pass.
Peer review: claude-laravel-parity signed off the complete nine-file diff.
---
src/docs/validation.md | 2 +-
.../src/Concerns/FormatsMessages.php | 8 +-
.../src/Concerns/ValidatesAttributes.php | 9 +-
src/validation/src/PlanExecutor.php | 6 +-
src/validation/src/Validator.php | 2 +-
.../Validation/Rules/FileValidationTest.php | 16 +-
.../ValidationCompiledExecutionTest.php | 28 ++-
.../Validation/ValidationPlanExecutorTest.php | 7 +-
tests/Validation/ValidationValidatorTest.php | 200 ++++++++++++------
9 files changed, 186 insertions(+), 92 deletions(-)
diff --git a/src/docs/validation.md b/src/docs/validation.md
index 34f293fa9..9addbe387 100644
--- a/src/docs/validation.md
+++ b/src/docs/validation.md
@@ -3156,7 +3156,7 @@ Validator::validate($input, [
#### Validating Image Dimensions
-You may also validate the dimensions of an image. For example, to validate that an uploaded image is at least 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule:
+You may also validate the dimensions of an image. For example, to validate that an uploaded image is at most 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule:
```php
use Hypervel\Validation\Rule;
diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php
index 5be34cb2e..b1926bfaf 100644
--- a/src/validation/src/Concerns/FormatsMessages.php
+++ b/src/validation/src/Concerns/FormatsMessages.php
@@ -6,10 +6,10 @@
use Closure;
use Hypervel\Contracts\Validation\Validator;
-use Hypervel\Http\UploadedFile;
use Hypervel\Support\Arr;
use Hypervel\Support\Number;
use Hypervel\Support\Str;
+use Symfony\Component\HttpFoundation\File\File;
trait FormatsMessages
{
@@ -212,13 +212,11 @@ protected function getSizeMessage(string $attribute, string $rule): string
*/
protected function getAttributeType(string $attribute): string
{
- // We assume that the attributes present in the file array are files so that
- // means that if the attribute does not have a numeric rule and the files
- // list doesn't have it we'll just consider it a string by elimination.
+ // Keep file-message selection consistent with sizeOf().
return match (true) {
$this->hasRule($attribute, $this->numericRules) => 'numeric',
$this->hasRule($attribute, ['Array', 'List']) => 'array',
- $this->getValue($attribute) instanceof UploadedFile => 'file',
+ $this->getValue($attribute) instanceof File => 'file',
default => 'string',
};
}
diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php
index 77a7b7fc4..e2b968e00 100644
--- a/src/validation/src/Concerns/ValidatesAttributes.php
+++ b/src/validation/src/Concerns/ValidatesAttributes.php
@@ -19,7 +19,6 @@
use Exception;
use Hypervel\Container\Container;
use Hypervel\Database\Eloquent\Model;
-use Hypervel\Http\UploadedFile;
use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
use Hypervel\Support\Exceptions\MathException;
@@ -32,9 +31,9 @@
use Hypervel\Validation\ValidationData;
use Hypervel\Validation\ValidationRuleParser;
use InvalidArgumentException;
-use SplFileInfo;
use Stringable;
use Symfony\Component\HttpFoundation\File\File;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
use ValueError;
use function with;
@@ -2013,7 +2012,7 @@ public function validateRequired(string $attribute, mixed $value): bool
if (is_countable($value) && count($value) < 1) {
return false;
}
- if ($value instanceof SplFileInfo) {
+ if ($value instanceof File) {
return (string) $value->getPath() !== '';
}
@@ -2565,7 +2564,7 @@ protected function sizeOf(string $attribute, mixed $value, bool $numeric): float
return count($value);
}
- if ($value instanceof SplFileInfo) {
+ if ($value instanceof File) {
return $value->getSize() / 1024;
}
@@ -2581,7 +2580,7 @@ public function isValidFileInstance(mixed $value): bool
return false;
}
- return $value instanceof SplFileInfo;
+ return $value instanceof File;
}
/**
diff --git a/src/validation/src/PlanExecutor.php b/src/validation/src/PlanExecutor.php
index 47d12e122..a635677f1 100644
--- a/src/validation/src/PlanExecutor.php
+++ b/src/validation/src/PlanExecutor.php
@@ -6,14 +6,14 @@
use Brick\Math\BigNumber;
use Brick\Math\Exception\MathException as BrickMathException;
-use Hypervel\Http\UploadedFile;
use Hypervel\Support\Arr;
use Hypervel\Support\Exceptions\MathException;
use Hypervel\Support\Str;
use Hypervel\Validation\Enums\CheckType;
use InvalidArgumentException;
-use SplFileInfo;
use Stringable;
+use Symfony\Component\HttpFoundation\File\File;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Execute compiled AttributePlans against validation data.
@@ -433,7 +433,7 @@ private function compareSizeBetween(
*/
private function usesNativeSizeComparison(mixed $value, bool $numeric): bool
{
- return ! ($numeric && is_numeric($value)) && ! $value instanceof SplFileInfo;
+ return ! ($numeric && is_numeric($value)) && ! $value instanceof File;
}
/**
diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php
index a2ac37e98..85d168e38 100644
--- a/src/validation/src/Validator.php
+++ b/src/validation/src/Validator.php
@@ -14,7 +14,6 @@
use Hypervel\Contracts\Validation\Rule as RuleContract;
use Hypervel\Contracts\Validation\Validator as ValidatorContract;
use Hypervel\Contracts\Validation\ValidatorAwareRule;
-use Hypervel\Http\UploadedFile;
use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
use Hypervel\Support\Fluent;
@@ -26,6 +25,7 @@
use LogicException;
use RuntimeException;
use stdClass;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
use Throwable;
use ValueError;
diff --git a/tests/Integration/Validation/Rules/FileValidationTest.php b/tests/Integration/Validation/Rules/FileValidationTest.php
index 8092a82d0..a30ac6b88 100644
--- a/tests/Integration/Validation/Rules/FileValidationTest.php
+++ b/tests/Integration/Validation/Rules/FileValidationTest.php
@@ -10,6 +10,8 @@
use Hypervel\Validation\Rule;
use Hypervel\Validation\Rules\File;
use PHPUnit\Framework\Attributes\TestWith;
+use Symfony\Component\HttpFoundation\File\File as SymfonyFile;
+use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;
class FileValidationTest extends TestCase
{
@@ -55,11 +57,21 @@ public function testItCanValidateAttributeAsArrayWhenValidationShouldFails(strin
], $validator->messages()->all());
}
- public function testFileCustomValidationMessages()
+ #[TestWith([UploadedFile::class])]
+ #[TestWith([SymfonyUploadedFile::class])]
+ #[TestWith([SymfonyFile::class])]
+ public function testFileCustomValidationMessages(string $fileClass): void
{
+ $upload = UploadedFile::fake()->createWithContent('photo', str_repeat('x', 1000 * 1024));
+ $file = match ($fileClass) {
+ UploadedFile::class => $upload,
+ SymfonyUploadedFile::class => new SymfonyUploadedFile($upload->getPathname(), 'photo', test: true),
+ SymfonyFile::class => new SymfonyFile($upload->getPathname()),
+ };
+
$validator = Validator::make(
[
- 'one' => UploadedFile::fake()->create('photo', 1000),
+ 'one' => $file,
'two' => 'not-a-file',
],
[
diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php
index 7158ca991..7d27fc242 100644
--- a/tests/Validation/ValidationCompiledExecutionTest.php
+++ b/tests/Validation/ValidationCompiledExecutionTest.php
@@ -16,10 +16,12 @@
use Hypervel\Validation\Rule;
use Hypervel\Validation\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionProperty;
-use SplFileInfo;
use stdClass;
use Stringable;
+use Symfony\Component\HttpFoundation\File\File;
+use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;
class ValidationCompiledExecutionTest extends TestCase
{
@@ -73,7 +75,7 @@ public function testRuntimeDispatchedSizeRulesMatchCompiledAndDelegatedExecution
[['value' => '2.00'], ['value' => 'max:3|decimal:2'], true],
[['value' => '2.00'], ['value' => 'string|numeric|max:3'], true],
[['value' => [1, 2, 3]], ['value' => 'between:2,3'], true],
- [['value' => new SplFileInfo(__FILE__)], ['value' => 'file|min:0|max:1000'], true],
+ [['value' => new File(__FILE__)], ['value' => 'file|min:0|max:1000'], true],
[['value' => 'abc'], ['value' => 'size:3.0000000000000000001'], false],
];
@@ -691,20 +693,28 @@ public function getMultiCount(string $collection, string $column, array $values,
$this->assertTrue($v->passes());
}
- public function testInvalidUploadedFileProducesUploadedError()
+ #[TestWith([UploadedFile::class, UPLOAD_ERR_INI_SIZE, ''])]
+ #[TestWith([SymfonyUploadedFile::class, UPLOAD_ERR_INI_SIZE, ''])]
+ #[TestWith([UploadedFile::class, UPLOAD_ERR_PARTIAL, __DIR__ . '/Fixtures/image.png'])]
+ #[TestWith([SymfonyUploadedFile::class, UPLOAD_ERR_PARTIAL, __DIR__ . '/Fixtures/image.png'])]
+ public function testInvalidUploadedFileProducesUploadedError(string $fileClass, int $error, string $path): void
{
- $file = new UploadedFile(
- path: '',
+ $file = new $fileClass(
+ path: $path,
originalName: 'test.jpg',
mimeType: 'image/jpeg',
- error: UPLOAD_ERR_INI_SIZE,
+ error: $error,
test: true,
);
- $v = $this->makeValidator(['file' => $file], ['file' => 'required|image']);
- $v->passes();
+ foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) {
+ foreach (['required|image', 'max:1000'] as $rules) {
+ $v = $this->makeValidator(['file' => $file], ['file' => $rules], validatorClass: $validatorClass);
- $this->assertTrue($v->errors()->has('file'));
+ $this->assertFalse($v->passes());
+ $this->assertSame(['validation.uploaded'], $v->errors()->get('file'));
+ }
+ }
}
public function testExcludeAttributesResetAcrossValidatorReuse()
diff --git a/tests/Validation/ValidationPlanExecutorTest.php b/tests/Validation/ValidationPlanExecutorTest.php
index d3a72932d..6db1e6752 100644
--- a/tests/Validation/ValidationPlanExecutorTest.php
+++ b/tests/Validation/ValidationPlanExecutorTest.php
@@ -12,8 +12,8 @@
use Hypervel\Validation\InlineCheck;
use Hypervel\Validation\RulePlan\ExposedExecutorValidator;
use PHPUnit\Framework\Attributes\DataProvider;
-use SplFileInfo;
use Stringable;
+use Symfony\Component\HttpFoundation\File\File;
class ValidationPlanExecutorTest extends TestCase
{
@@ -182,9 +182,12 @@ public function __toString(): string
return 'value';
}
};
- $file = new class(__FILE__) extends SplFileInfo {
+ $file = new class(__FILE__, false) extends File {
public int $reads = 0;
+ /**
+ * Get the file size and record the read.
+ */
public function getSize(): int|false
{
++$this->reads;
diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php
index af2ddf6ed..4ca8538a5 100755
--- a/tests/Validation/ValidationValidatorTest.php
+++ b/tests/Validation/ValidationValidatorTest.php
@@ -41,10 +41,13 @@
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionProperty;
use RuntimeException;
use SplFileInfo;
use stdClass;
+use Symfony\Component\HttpFoundation\File\File;
+use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;
use UnitEnum;
class ValidationValidatorTest extends TestCase
@@ -1104,7 +1107,7 @@ public function testCustomValidationLinesAreRespected()
$this->assertSame('really required!', $v->messages()->first('name'));
}
- public function testCustomValidationLinesForSizeRules()
+ public function testCustomValidationLinesForSizeRules(): void
{
$trans = $this->getArrayTranslator();
$trans->getLoader()->addMessages('en', 'validation', [
@@ -1123,10 +1126,15 @@ public function testCustomValidationLinesForSizeRules()
$this->assertFalse($v->passes());
$this->assertSame('Custom message for image filenames.', $v->messages()->first('image'));
- $file = new UploadedFile(__FILE__, '');
- $v = new Validator($trans, ['image' => $file], ['image' => 'gte:50']);
- $this->assertFalse($v->passes());
- $this->assertSame('Custom message for image files.', $v->messages()->first('image'));
+ foreach ([
+ new UploadedFile(__FILE__, '', test: true),
+ new SymfonyUploadedFile(__FILE__, '', test: true),
+ new File(__FILE__),
+ ] as $file) {
+ $v = new Validator($trans, ['image' => $file], ['image' => 'gte:50']);
+ $this->assertFalse($v->passes());
+ $this->assertSame('Custom message for image files.', $v->messages()->first('image'));
+ }
}
public function testCustomValidationLinesAreRespectedWithAsterisks()
@@ -1204,7 +1212,7 @@ public function testValidationDotCustomDotAnythingCanBeTranslated()
$this->assertSame('should be integer!', $v->messages()->first('validation.custom.1'));
}
- public function testInlineValidationMessagesAreRespected()
+ public function testInlineValidationMessagesAreRespected(): void
{
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['name' => ''], ['name' => 'Required'], ['name.required' => 'require it please!']);
@@ -1223,6 +1231,21 @@ public function testInlineValidationMessagesAreRespected()
$this->assertFalse($v->passes());
$v->messages()->setFormat(':message');
$this->assertSame('name should be of length 9', $v->messages()->first('name'));
+
+ foreach ([
+ $this->uploadedFile(__FILE__, '', isValid: true, size: 4072),
+ new SymfonyUploadedFile(__FILE__, '', test: true),
+ new File(__FILE__),
+ ] as $file) {
+ $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3'], [
+ 'max' => [
+ 'file' => ':attribute must not exceed :max kilobytes.',
+ 'string' => ':attribute must not exceed :max characters.',
+ ],
+ ]);
+ $this->assertFalse($v->passes());
+ $this->assertSame('photo must not exceed 3 kilobytes.', $v->messages()->first('photo'));
+ }
}
#[DataProvider('integerMessageParameterCases')]
@@ -1338,25 +1361,25 @@ public function testIfRulesAreSuccessfullyAdded()
$this->assertFalse($v->hasRule('bar', 'Required'));
}
- public function testValidateArray()
+ public function testValidateArray(): void
{
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'Array']);
$this->assertTrue($v->passes());
- $v = new Validator($trans, ['foo' => new SplFileInfo('/tmp/foo')], ['foo' => 'Array']);
+ $v = new Validator($trans, ['foo' => new File('/tmp/foo', false)], ['foo' => 'Array']);
$this->assertFalse($v->passes());
}
- public function testValidateList()
+ public function testValidateList(): void
{
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'list']);
$this->assertTrue($v->passes());
- $v = new Validator($trans, ['foo' => new SplFileInfo('/tmp/foo')], ['foo' => 'list']);
+ $v = new Validator($trans, ['foo' => new File('/tmp/foo', false)], ['foo' => 'list']);
$this->assertFalse($v->passes());
$v = new Validator($trans, ['foo' => [1 => 1, 2 => 2]], ['foo' => 'list']);
@@ -1675,7 +1698,7 @@ public function testValidatePresentWithAll()
$this->assertSame('The foo field must be present when bar / baz are present.', $v->errors()->first('foo'));
}
- public function testValidateRequired()
+ public function testValidateRequired(): void
{
$trans = $this->getArrayTranslator();
$v = new Validator($trans, [], ['name' => 'Required']);
@@ -1687,16 +1710,16 @@ public function testValidateRequired()
$v = new Validator($trans, ['name' => 'foo'], ['name' => 'Required']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo('');
+ $file = new File('', false);
$v = new Validator($trans, ['name' => $file], ['name' => 'Required']);
$this->assertFalse($v->passes());
- $file = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
$v = new Validator($trans, ['name' => $file], ['name' => 'Required']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo(__FILE__);
- $file2 = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
+ $file2 = new File(__FILE__, false);
$v = new Validator($trans, ['files' => [$file, $file2]], ['files.0' => 'Required', 'files.1' => 'Required']);
$this->assertTrue($v->passes());
@@ -1704,7 +1727,7 @@ public function testValidateRequired()
$this->assertTrue($v->passes());
}
- public function testValidateRequiredWith()
+ public function testValidateRequiredWith(): void
{
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['first' => 'Taylor'], ['last' => 'required_with:first']);
@@ -1722,17 +1745,17 @@ public function testValidateRequiredWith()
$v = new Validator($trans, ['first' => 'Taylor', 'last' => 'Otwell'], ['last' => 'required_with:first']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo('');
+ $file = new File('', false);
$v = new Validator($trans, ['file' => $file, 'foo' => ''], ['foo' => 'required_with:file']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo(__FILE__);
- $foo = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
+ $foo = new File(__FILE__, false);
$v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_with:file']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo(__FILE__);
- $foo = new SplFileInfo('');
+ $file = new File(__FILE__, false);
+ $foo = new File('', false);
$v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_with:file']);
$this->assertFalse($v->passes());
}
@@ -1747,7 +1770,7 @@ public function testRequiredWithAll()
$this->assertFalse($v->passes());
}
- public function testValidateRequiredWithout()
+ public function testValidateRequiredWithout(): void
{
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['first' => 'Taylor'], ['last' => 'required_without:first']);
@@ -1768,35 +1791,35 @@ public function testValidateRequiredWithout()
$v = new Validator($trans, ['last' => 'Otwell'], ['last' => 'required_without:first']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo('');
+ $file = new File('', false);
$v = new Validator($trans, ['file' => $file], ['foo' => 'required_without:file']);
$this->assertFalse($v->passes());
- $foo = new SplFileInfo('');
+ $foo = new File('', false);
$v = new Validator($trans, ['foo' => $foo], ['foo' => 'required_without:file']);
$this->assertFalse($v->passes());
- $foo = new SplFileInfo(__FILE__);
+ $foo = new File(__FILE__, false);
$v = new Validator($trans, ['foo' => $foo], ['foo' => 'required_without:file']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo(__FILE__);
- $foo = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
+ $foo = new File(__FILE__, false);
$v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo(__FILE__);
- $foo = new SplFileInfo('');
+ $file = new File(__FILE__, false);
+ $foo = new File('', false);
$v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo('');
- $foo = new SplFileInfo(__FILE__);
+ $file = new File('', false);
+ $foo = new File(__FILE__, false);
$v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo('');
- $foo = new SplFileInfo('');
+ $file = new File('', false);
+ $foo = new File('', false);
$v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']);
$this->assertFalse($v->passes());
}
@@ -2072,7 +2095,7 @@ public function testRequiredUnless()
$this->assertSame('The last field is required unless first is in taylor, sven.', $v->messages()->first('last'));
}
- public function testProhibited()
+ public function testProhibited(): void
{
$trans = $this->getArrayTranslator();
@@ -2085,16 +2108,16 @@ public function testProhibited()
$v = new Validator($trans, ['name' => 'foo'], ['name' => 'prohibited']);
$this->assertTrue($v->fails());
- $file = new SplFileInfo('');
+ $file = new File('', false);
$v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']);
$this->assertTrue($v->passes());
- $file = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
$v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']);
$this->assertTrue($v->fails());
- $file = new SplFileInfo(__FILE__);
- $file2 = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
+ $file2 = new File(__FILE__, false);
$v = new Validator($trans, ['files' => [$file, $file2]], ['files.0' => 'prohibited', 'files.1' => 'prohibited']);
$this->assertTrue($v->fails());
@@ -2368,21 +2391,23 @@ public function count(): int
];
}
- public function testFailedFileUploads()
+ #[TestWith([UploadedFile::class])]
+ #[TestWith([SymfonyUploadedFile::class])]
+ public function testFailedFileUploads(string $fileClass): void
{
$trans = $this->getArrayTranslator();
// If file is not successfully uploaded validation should fail with a
// 'uploaded' error message instead of the original rule.
- $file = m::mock(UploadedFile::class);
- $file->shouldReceive('isValid')->andReturn(false);
+ $file = m::mock($fileClass);
+ $file->shouldReceive('isValid')->once()->andReturn(false);
$file->shouldNotReceive('getSize');
$v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:10']);
$this->assertTrue($v->fails());
$this->assertEquals(['validation.uploaded'], $v->errors()->get('photo'));
// Even "required" will not run if the file failed to upload.
- $file = m::mock(UploadedFile::class);
+ $file = m::mock($fileClass);
$file->shouldReceive('isValid')->once()->andReturn(false);
$v = new Validator($trans, ['photo' => $file], ['photo' => 'required']);
$this->assertTrue($v->fails());
@@ -2390,20 +2415,33 @@ public function testFailedFileUploads()
// It should only fail with that rule if a validation rule implies it's
// a file. Otherwise it should fail with the regular rule.
- $file = m::mock(UploadedFile::class);
- $file->shouldReceive('isValid')->andReturn(false);
+ $file = m::mock($fileClass);
+ $file->shouldReceive('isValid')->once()->andReturn(false);
$v = new Validator($trans, ['photo' => $file], ['photo' => 'string']);
$this->assertTrue($v->fails());
$this->assertEquals(['validation.string'], $v->errors()->get('photo'));
// Validation shouldn't continue if a file failed to upload.
- $file = m::mock(UploadedFile::class);
+ $file = m::mock($fileClass);
$file->shouldReceive('isValid')->once()->andReturn(false);
$v = new Validator($trans, ['photo' => $file], ['photo' => 'file|mimes:pdf|min:10']);
$this->assertTrue($v->fails());
$this->assertEquals(['validation.uploaded'], $v->errors()->get('photo'));
}
+ #[TestWith([UploadedFile::class])]
+ #[TestWith([SymfonyUploadedFile::class])]
+ public function testDirectFileRulesRejectFailedUploads(string $fileClass): void
+ {
+ $file = m::mock($fileClass);
+ $file->shouldReceive('isValid')->twice()->andReturn(false);
+ $file->shouldNotReceive('getSize');
+ $validator = new Validator($this->getArrayTranslator(), [], []);
+
+ $this->assertFalse($validator->isValidFileInstance($file));
+ $this->assertFalse($validator->validateMax('photo', $file, [10]));
+ }
+
public function testValidateInArray()
{
$trans = $this->getArrayTranslator();
@@ -4153,7 +4191,7 @@ public static function multipleOfDataProvider()
];
}
- public function testProperMessagesAreReturnedForSizes()
+ public function testProperMessagesAreReturnedForSizes(): void
{
$trans = $this->getArrayTranslator();
$trans->addLines(['validation.min.numeric' => 'numeric', 'validation.size.string' => 'string', 'validation.max.file' => 'file'], 'en');
@@ -4167,11 +4205,16 @@ public function testProperMessagesAreReturnedForSizes()
$v->messages()->setFormat(':message');
$this->assertSame('string', $v->messages()->first('name'));
- $file = $this->uploadedFile(__FILE__, '', isValid: true, size: 4072);
- $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3']);
- $this->assertFalse($v->passes());
- $v->messages()->setFormat(':message');
- $this->assertSame('file', $v->messages()->first('photo'));
+ foreach ([
+ $this->uploadedFile(__FILE__, '', isValid: true, size: 4072),
+ new SymfonyUploadedFile(__FILE__, '', test: true),
+ new File(__FILE__),
+ ] as $file) {
+ $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3']);
+ $this->assertFalse($v->passes());
+ $v->messages()->setFormat(':message');
+ $this->assertSame('file', $v->messages()->first('photo'));
+ }
}
public function testValidateGtPlaceHolderIsReplacedProperly()
@@ -5570,20 +5613,21 @@ public function testValidateImageDimensions(): void
$v = new Validator($trans, ['x' => $svgXmlUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']);
$this->assertTrue($v->passes());
- $svgXmlFile = new UploadedFile(__DIR__ . '/Fixtures/image.svg', '', 'image/svg+xml', null, true);
+ $svgXmlFile = new File(__DIR__ . '/Fixtures/image.svg');
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['x' => $svgXmlFile], ['x' => 'dimensions:max_width=1,max_height=1']);
$this->assertTrue($v->passes());
// Ensure svg images always pass as size is irrelevant (image/svg)
- $svgUploadedFile = new UploadedFile(__DIR__ . '/Fixtures/image2.svg', '', 'image/svg', null, true);
+ $svgUploadedFile = $this->uploadedFile(__DIR__ . '/Fixtures/image2.svg', '', mimeType: 'image/svg');
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['x' => $svgUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']);
$this->assertTrue($v->passes());
- $svgFile = new UploadedFile(__DIR__ . '/Fixtures/image2.svg', '', 'image/svg', null, true);
+ $svgFile = m::mock(File::class, [__DIR__ . '/Fixtures/image2.svg'])->makePartial();
+ $svgFile->shouldReceive('getMimeType')->once()->andReturn('image/svg');
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['x' => $svgFile], ['x' => 'dimensions:max_width=1,max_height=1']);
@@ -5674,7 +5718,7 @@ public function testValidateExtension()
$this->assertFalse($v->passes());
}
- public function testValidateMimeEnforcesPhpCheck()
+ public function testValidateMimeEnforcesPhpCheck(): void
{
$trans = $this->getArrayTranslator();
$file = $this->uploadedFile(__FILE__, '', guessedExtension: 'pdf', clientOriginalExtension: 'php');
@@ -5684,10 +5728,20 @@ public function testValidateMimeEnforcesPhpCheck()
$file2 = $this->uploadedFile(__FILE__, '', guessedExtension: 'php', clientOriginalExtension: 'php');
$v = new Validator($trans, ['x' => $file2], ['x' => 'mimes:pdf,php']);
$this->assertTrue($v->passes());
+
+ $file = new SymfonyUploadedFile(__DIR__ . '/Fixtures/image.png', 'image.php', test: true);
+ $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:png']);
+ $this->assertFalse($v->passes());
+
+ $v = new Validator($trans, ['x' => $file], ['x' => 'mimetypes:image/png']);
+ $this->assertFalse($v->passes());
+
+ $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:png,php']);
+ $this->assertTrue($v->passes());
}
#[RequiresPhpExtension('fileinfo')]
- public function testValidateFile()
+ public function testValidateFile(): void
{
$trans = $this->getArrayTranslator();
$file = new UploadedFile(__FILE__, '', null, null, true);
@@ -5697,6 +5751,15 @@ public function testValidateFile()
$v = new Validator($trans, ['x' => $file], ['x' => 'file']);
$this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['x' => new SymfonyUploadedFile(__FILE__, '', test: true)], ['x' => 'file']);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['x' => new File(__FILE__)], ['x' => 'file']);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['x' => new SplFileInfo(__FILE__)], ['x' => 'file']);
+ $this->assertTrue($v->fails());
}
public function testEmptyRulesSkipped()
@@ -8685,19 +8748,19 @@ public function testNestedInvalidMethod()
);
}
- public function testMultipleFileUploads()
+ public function testMultipleFileUploads(): void
{
$trans = $this->getArrayTranslator();
- $file = new SplFileInfo(__FILE__);
- $file2 = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
+ $file2 = new File(__FILE__, false);
$v = new Validator($trans, ['file' => [$file, $file2]], ['file.*' => 'Required|mimes:xls']);
$this->assertFalse($v->passes());
}
- public function testFileUploads()
+ public function testFileUploads(): void
{
$trans = $this->getArrayTranslator();
- $file = new SplFileInfo(__FILE__);
+ $file = new File(__FILE__, false);
$v = new Validator($trans, ['file' => $file], ['file' => 'Required|mimes:xls']);
$this->assertFalse($v->passes());
}
@@ -10504,9 +10567,12 @@ public function testWhenPasses()
$this->assertSame('whenNotPasses', $result);
}
- protected function fileInfoWithSize(int $size): SplFileInfo
+ /**
+ * Create a file with the given size.
+ */
+ protected function fileInfoWithSize(int $size): File
{
- return new SizedSplFileInfo(__FILE__, $size);
+ return new SizedFile(__FILE__, $size);
}
protected function uploadedFile(
@@ -10594,13 +10660,19 @@ class NonEloquentModel
{
}
-class SizedSplFileInfo extends SplFileInfo
+class SizedFile extends File
{
+ /**
+ * Create a file with the given size.
+ */
public function __construct(string $filename, private readonly int $size)
{
parent::__construct($filename);
}
+ /**
+ * Get the file size.
+ */
public function getSize(): int
{
return $this->size;
From fffbf3bf65853c222148e0abb8303bd70ed8874d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 02:42:11 +0000
Subject: [PATCH 07/22] Port queue worker stop output from Laravel
Report why queue:work stops in both console and JSON output. Preserve all
nine upstream reason descriptions, the stop status and exit code, nullable
metrics, memory rounding, timestamps, and quiet/silent suppression.
Port Laravel PR #61339 from the current 13.x implementation:
https://github.com/laravel/framework/pull/61339
Source revision: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2
Resolve the command through the stopping event's existing worker options.
Graceful stopping runs outside the configured job coroutine context, and
the once-registered static listener must not retain the first command.
Reuse existing ownership without adding worker state or per-job work.
Keep native types, imported names, and the immutable clock.
Port both upstream integration tests and add focused coverage for distinct
command instances, nullable and zero metrics, JSON formatting, suppression,
and events without command-owned options. Document stop output and --json
at the worker command's public documentation surface.
Validation: integration file 18 tests / 58 assertions; new command file
6 / 29; Queue ParaTest suite 650 / 2724; affected Horizon, SQLite worker
lifetime, and Sentry tests 16 / 76. Scoped formatting, full source/type
PHPStan analysis, and diff checks pass. Database checks use SQLite.
Self-reviewed and signed off by claude-laravel-parity.
---
src/docs/queues.md | 6 +
src/queue/src/Console/WorkCommand.php | 40 ++++++
src/queue/src/WorkerStopReason.php | 18 +++
tests/Integration/Queue/WorkCommandTest.php | 27 ++++
tests/Queue/WorkCommandTest.php | 142 ++++++++++++++++++++
5 files changed, 233 insertions(+)
create mode 100644 tests/Queue/WorkCommandTest.php
diff --git a/src/docs/queues.md b/src/docs/queues.md
index 25187b5fc..a7684e9a4 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -2607,6 +2607,12 @@ You may include the `-v` flag when invoking the `queue:work` command if you woul
php artisan queue:work -v
```
+The command also reports worker stop reasons, such as reaching the memory limit. To output job updates and stop information as JSON, use the `--json` option:
+
+```shell
+php artisan queue:work --json
+```
+
Remember, queue workers are long-lived processes and store the booted application state in memory. As a result, they will not notice changes in your code base after they have been started. So, during your deployment process, be sure to [restart your queue workers](#queue-workers-and-deployment). In addition, remember that any static state created or modified by your application will not be automatically reset between jobs. Request or job specific state should be stored in `CoroutineContext` instead of static properties or mutable singletons.
Alternatively, you may run the `queue:listen` command. When using the `queue:listen` command, you don't have to manually restart the worker when you want to reload your updated code or reset the application state; however, this command is significantly less efficient than the `queue:work` command:
diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php
index 034b42f89..2754b5835 100644
--- a/src/queue/src/Console/WorkCommand.php
+++ b/src/queue/src/Console/WorkCommand.php
@@ -14,6 +14,7 @@
use Hypervel\Queue\Events\JobProcessed;
use Hypervel\Queue\Events\JobProcessing;
use Hypervel\Queue\Events\JobReleasedAfterException;
+use Hypervel\Queue\Events\WorkerStopping;
use Hypervel\Queue\Failed\FailedJobProviderInterface;
use Hypervel\Queue\InvalidPayloadException;
use Hypervel\Queue\Worker;
@@ -197,6 +198,15 @@ protected function listenForEvents(): void
$command?->writeOutput($event->job, 'failed', $event->exception);
});
+ $events->listen(WorkerStopping::class, static function (WorkerStopping $event): void {
+ // Graceful stopping runs outside the configured job coroutine context.
+ $command = $event->workerOptions?->coroutineContext[self::CURRENT_COMMAND_CONTEXT_KEY] ?? null;
+
+ if ($command instanceof self) {
+ $command->writeStopReason($event);
+ }
+ });
+
static::$hasRegisteredListeners = true;
}
@@ -214,6 +224,36 @@ protected function writeOutput(Job $job, string $status, ?Throwable $exception =
: $this->writeOutputForCli($job, $status);
}
+ /**
+ * Write the status output for a queue worker that is stopping.
+ */
+ protected function writeStopReason(WorkerStopping $event): void
+ {
+ if ($this->output->isQuiet() || $this->output->isSilent() || is_null($event->reason)) {
+ return;
+ }
+
+ if ($this->outputUsingJson()) {
+ $this->output->writeln(json_encode([
+ 'level' => $event->status === 0 ? 'info' : 'warning',
+ 'status' => 'stopped',
+ 'reason' => $event->reason->value,
+ 'exit_code' => $event->status,
+ 'jobs_processed' => $event->jobsProcessed,
+ 'memory' => is_null($event->memoryUsage) ? null : round($event->memoryUsage, 1),
+ 'timestamp' => $this->now()->format('Y-m-d\TH:i:s.uP'),
+ ]));
+
+ return;
+ }
+
+ $this->output->writeln(sprintf(
+ ' %s> Worker STOPPED> %s>',
+ $this->now()->format('Y-m-d H:i:s'),
+ $event->reason->description(),
+ ));
+ }
+
/**
* Format the status output for the queue worker.
*/
diff --git a/src/queue/src/WorkerStopReason.php b/src/queue/src/WorkerStopReason.php
index 41e2ccc97..e33f57ced 100644
--- a/src/queue/src/WorkerStopReason.php
+++ b/src/queue/src/WorkerStopReason.php
@@ -15,4 +15,22 @@ enum WorkerStopReason: string
case QueueEmptyFor = 'empty_for';
case ReceivedRestartSignal = 'restart_signal';
case TimedOut = 'timed_out';
+
+ /**
+ * Get the description of the worker stop reason.
+ */
+ public function description(): string
+ {
+ return match ($this) {
+ self::Interrupted => 'Interrupted',
+ self::LostConnection => 'Lost connection',
+ self::MaxJobsExceeded => 'Maximum jobs exceeded',
+ self::MaxMemoryExceeded => 'Memory limit exceeded',
+ self::MaxTimeExceeded => 'Maximum run time exceeded',
+ self::QueueEmpty => 'Queue empty',
+ self::QueueEmptyFor => 'Queue empty for the configured duration',
+ self::ReceivedRestartSignal => 'Received restart signal',
+ self::TimedOut => 'Job timed out',
+ };
+ }
}
diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php
index 1135a54e3..70d2e27db 100644
--- a/tests/Integration/Queue/WorkCommandTest.php
+++ b/tests/Integration/Queue/WorkCommandTest.php
@@ -368,6 +368,33 @@ public function testFailedJobListenerOnlyRunsOnce()
Exceptions::assertNotReported(UniqueConstraintViolationException::class);
$this->assertSame(2, substr_count(Artisan::output(), JobWillFail::class));
}
+
+ public function testStopReasonIsWritten(): void
+ {
+ Queue::push(new FirstJob);
+ Queue::push(new SecondJob);
+
+ $this->artisan('queue:work', [
+ '--daemon' => true,
+ '--stop-when-empty' => true,
+ '--memory' => 1024,
+ ])->expectsOutputToContain('Queue empty')
+ ->assertExitCode(0);
+ }
+
+ public function testStopReasonIsWrittenAsJson(): void
+ {
+ Queue::push(new FirstJob);
+ Queue::push(new SecondJob);
+
+ $this->artisan('queue:work', [
+ '--daemon' => true,
+ '--stop-when-empty' => true,
+ '--memory' => 1,
+ '--json' => true,
+ ])->expectsOutputToContain('"status":"stopped","reason":"memory","exit_code":12')
+ ->assertExitCode(12);
+ }
}
class FirstJob implements ShouldQueue
diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php
new file mode 100644
index 000000000..564511530
--- /dev/null
+++ b/tests/Queue/WorkCommandTest.php
@@ -0,0 +1,142 @@
+make('config');
+ $config->set('queue.default', 'sync');
+ $config->set('cache.default', 'array');
+ }
+
+ public function testStopOutputUsesTheCurrentCommand(): void
+ {
+ $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11));
+
+ $firstOutput = new BufferedOutput;
+ $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $firstOutput);
+
+ $this->assertSame(" 2023-01-18 10:10:11 Worker STOPPED Queue empty\n", $firstOutput->fetch());
+
+ $secondOutput = new BufferedOutput;
+ $this->runWorkerCommand(new WorkerStopping(
+ status: Worker::EXIT_MEMORY_LIMIT,
+ reason: WorkerStopReason::MaxMemoryExceeded,
+ jobsProcessed: 0,
+ memoryUsage: 64.25,
+ ), $secondOutput, ['--json' => true]);
+
+ $this->assertSame('', $firstOutput->fetch());
+ $this->assertSame([
+ 'level' => 'warning',
+ 'status' => 'stopped',
+ 'reason' => 'memory',
+ 'exit_code' => 12,
+ 'jobs_processed' => 0,
+ 'memory' => 64.3,
+ 'timestamp' => '2023-01-18T10:10:11.000000+00:00',
+ ], json_decode($secondOutput->fetch(), true, 512, JSON_THROW_ON_ERROR));
+ }
+
+ public function testStopOutputPreservesMissingMetrics(): void
+ {
+ $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11));
+
+ $output = new BufferedOutput;
+ $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $output, ['--json' => true]);
+
+ $this->assertSame([
+ 'level' => 'info',
+ 'status' => 'stopped',
+ 'reason' => 'empty',
+ 'exit_code' => 0,
+ 'jobs_processed' => null,
+ 'memory' => null,
+ 'timestamp' => '2023-01-18T10:10:11.000000+00:00',
+ ], json_decode($output->fetch(), true, 512, JSON_THROW_ON_ERROR));
+ }
+
+ #[DataProvider('suppressedStopOutputProvider')]
+ public function testStopOutputIsSuppressed(int $verbosity, ?WorkerStopReason $reason): void
+ {
+ $output = new BufferedOutput($verbosity);
+ $this->runWorkerCommand(new WorkerStopping(reason: $reason), $output, ['--json' => true]);
+
+ $this->assertSame('', $output->fetch());
+ }
+
+ /**
+ * Provide stop events that should not produce output.
+ */
+ public static function suppressedStopOutputProvider(): array
+ {
+ return [
+ 'quiet' => [OutputInterface::VERBOSITY_QUIET, WorkerStopReason::QueueEmpty],
+ 'silent' => [OutputInterface::VERBOSITY_SILENT, WorkerStopReason::QueueEmpty],
+ 'no reason' => [OutputInterface::VERBOSITY_NORMAL, null],
+ ];
+ }
+
+ public function testStopEventsWithoutCommandOptionsDoNotWriteOutput(): void
+ {
+ $output = new BufferedOutput;
+ $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $output, ['--json' => true]);
+ $output->fetch();
+
+ $this->app->make('events')->dispatch(new WorkerStopping(reason: WorkerStopReason::QueueEmpty));
+
+ $this->assertSame('', $output->fetch());
+ }
+
+ /**
+ * Run a distinct command instance that dispatches the given stop event.
+ *
+ * @param array $arguments
+ */
+ private function runWorkerCommand(WorkerStopping $event, BufferedOutput $output, array $arguments = []): void
+ {
+ $worker = m::mock(Worker::class);
+ $worker->shouldReceive('setName')->once()->with('default')->andReturnSelf();
+ $worker->shouldReceive('setCache')->once()->with(m::type(Repository::class))->andReturnSelf();
+ $worker->shouldReceive('daemon')
+ ->once()
+ ->with('sync', 'default', m::type(WorkerOptions::class))
+ ->andReturnUsing(function (string $connection, string $queue, WorkerOptions $options) use ($event): int {
+ $event->workerOptions = $options;
+ $this->app->make('events')->dispatch($event);
+
+ return $event->status;
+ });
+
+ $command = new WorkCommand(
+ $this->app,
+ $this->app->make('config'),
+ $worker,
+ $this->app->make('cache'),
+ );
+ $command->setHypervel($this->app);
+ $command->run(new ArrayInput($arguments), $output);
+ }
+}
From 38a901faeaf34182091b537479b84310d5d7c656 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 03:51:25 +0000
Subject: [PATCH 08/22] Port context propagation across explicit process
boundaries
Port Laravel framework PR #61419 and its receiving/scheduler dependency
#57918 from the pinned 13.x source at
01d008c9b5f32cb7c5e50a9a22273113d810b2a2:
https://github.com/laravel/framework/pull/61419
https://github.com/laravel/framework/pull/57918
Propagate visible and hidden context to process-driver tasks, including
deferred tasks, and to explicitly scheduled system commands. Preserve
Hypervel's coroutine driver as the default and its native in-worker
scheduling and background execution paths.
Use a base64-encoded serialized dehydration payload because upstream's
JSON encoding silently drops valid binary context. Receive the payload
in ConsoleServiceProvider before command handling, where the repository
belongs to the executing coroutine. Claim the startup payload before
hydrating so callbacks can invoke nested commands without hydrating
twice, and later worker-job coroutines cannot restore startup context.
Skip receiver registration for empty payloads and non-console startup.
Fix AI-020: the queue payload hook must run registered dehydration hooks
in fresh coroutines even when no context repository exists yet. Retain
the allocation-free path when neither context nor listeners exist and
capture the worker-safe dispatcher once during provider boot.
Declare concurrency's direct log dependency, port both upstream tests,
add focused binary transport, receiver lifecycle, eligibility, scheduler,
and fresh-coroutine regressions, and document the public context behavior.
Update transport-hook comments that incorrectly restricted them to jobs.
Validation: immediate changed-file PHPUnit runs; affected ParaTest suites
passed with 1616 tests and 5700 assertions before review-only corrections.
Final review checks passed: Concurrency 40/76, ConsoleServiceProvider 4/26,
ContextQueue 17/64. Full source/type PHPStan, scoped PHP-CS-Fixer,
concurrency Composer validation and git diff --check passed.
Self-reviewed and signed off by claude-laravel-parity.
---
src/concurrency/composer.json | 1 +
src/concurrency/src/ProcessDriver.php | 5 +
src/console/src/ConsoleServiceProvider.php | 24 +++++
src/console/src/Scheduling/Event.php | 6 +-
src/docs/concurrency.md | 2 +
src/docs/context.md | 2 +-
src/docs/scheduling.md | 2 +
.../src/Context/ContextServiceProvider.php | 9 +-
src/log/src/Context/Repository.php | 6 +-
tests/Concurrency/ConcurrencyTest.php | 45 +++++++++
tests/Console/ConsoleServiceProviderTest.php | 98 +++++++++++++++++++
.../ScheduleRunContextPropagationTest.php | 40 ++++++++
tests/Log/ContextQueueTest.php | 19 ++++
13 files changed, 251 insertions(+), 8 deletions(-)
diff --git a/src/concurrency/composer.json b/src/concurrency/composer.json
index c092be10c..7aad3c708 100644
--- a/src/concurrency/composer.json
+++ b/src/concurrency/composer.json
@@ -37,6 +37,7 @@
"hypervel/context": "^0.4",
"hypervel/contracts": "^0.4",
"hypervel/coroutine": "^0.4",
+ "hypervel/log": "^0.4",
"hypervel/process": "^0.4",
"hypervel/support": "^0.4",
"nesbot/carbon": "^3.13.1",
diff --git a/src/concurrency/src/ProcessDriver.php b/src/concurrency/src/ProcessDriver.php
index 929bfe85a..7399dff06 100644
--- a/src/concurrency/src/ProcessDriver.php
+++ b/src/concurrency/src/ProcessDriver.php
@@ -13,6 +13,7 @@
use Hypervel\Process\Pool;
use Hypervel\Support\Arr;
use Hypervel\Support\Defer\DeferredCallback;
+use Hypervel\Support\Facades\Context;
use Laravel\SerializableClosure\SerializableClosure;
use function Hypervel\Support\defer;
@@ -37,6 +38,8 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul
$results = $this->processFactory->pool(function (Pool $pool) use ($tasks, $command, $timeout) {
foreach (Arr::wrap($tasks) as $key => $task) {
$process = $pool->as((string) $key)->path(base_path())->env([
+ /* @phpstan-ignore staticMethod.notFound */
+ '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())),
'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode(
serialize(new SerializableClosure($task))
),
@@ -67,6 +70,8 @@ public function defer(Closure|array $tasks): DeferredCallback
return defer(function () use ($tasks, $command) {
foreach (Arr::wrap($tasks) as $task) {
$this->processFactory->path(base_path())->env([
+ /* @phpstan-ignore staticMethod.notFound */
+ '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())),
'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode(
serialize(new SerializableClosure($task))
),
diff --git a/src/console/src/ConsoleServiceProvider.php b/src/console/src/ConsoleServiceProvider.php
index 1a1b26ffe..152954c22 100644
--- a/src/console/src/ConsoleServiceProvider.php
+++ b/src/console/src/ConsoleServiceProvider.php
@@ -11,6 +11,10 @@
use Hypervel\Console\Commands\ScheduleResumeCommand;
use Hypervel\Console\Commands\ScheduleRunCommand;
use Hypervel\Console\Commands\ScheduleTestCommand;
+use Hypervel\Console\Events\BeforeHandle;
+use Hypervel\Contracts\Events\Dispatcher;
+use Hypervel\Log\Context\Repository;
+use Hypervel\Support\Env;
use Hypervel\Support\ServiceProvider;
class ConsoleServiceProvider extends ServiceProvider
@@ -30,4 +34,24 @@ public function register(): void
ScheduleTestCommand::class,
]);
}
+
+ /**
+ * Bootstrap the service provider.
+ */
+ public function boot(Dispatcher $events): void
+ {
+ if ($this->app->runningInConsole()
+ && is_string($encoded = Env::get('__HYPERVEL_CONTEXT'))
+ && is_array($context = unserialize(base64_decode($encoded, true), ['allowed_classes' => false]))) {
+ $events->listen(BeforeHandle::class, static function () use (&$context): void {
+ if ($context !== null) {
+ // Hydration callbacks may invoke another command, so claim the startup context first.
+ $payload = $context;
+ $context = null;
+
+ Repository::getInstance()->hydrate($payload);
+ }
+ });
+ }
+ }
}
diff --git a/src/console/src/Scheduling/Event.php b/src/console/src/Scheduling/Event.php
index b1c5c49c0..20ba49f3f 100644
--- a/src/console/src/Scheduling/Event.php
+++ b/src/console/src/Scheduling/Event.php
@@ -20,6 +20,7 @@
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Contracts\Mail\Mailer;
use Hypervel\Filesystem\Filesystem;
+use Hypervel\Log\Context\Repository;
use Hypervel\Support\Arr;
use Hypervel\Support\Facades\Date;
use Hypervel\Support\Stringable;
@@ -265,10 +266,13 @@ protected function execute(Container $container): int
*/
protected function runProcess(Container $container): int
{
+ $context = base64_encode(serialize(Repository::getInstance()->dehydrate()));
+
/** @var \Hypervel\Contracts\Foundation\Application $container */
$process = Process::fromShellCommandline(
$this->command,
- $container->basePath()
+ $container->basePath(),
+ ['__HYPERVEL_CONTEXT' => $context]
);
CoroutineContext::set($this->processContextKey(), $process);
diff --git a/src/docs/concurrency.md b/src/docs/concurrency.md
index c503c5b0e..c301c6230 100644
--- a/src/docs/concurrency.md
+++ b/src/docs/concurrency.md
@@ -110,6 +110,8 @@ The `coroutine` driver is the correct choice for almost all application code. It
The `process` driver should be reserved for tasks that need OS-level process isolation. This includes work that must run outside the current framework lifecycle, work that should not share long-lived worker memory, or work that calls extensions Swoole cannot hook. Since process tasks are serialized before being sent to the child process, the closure and any captured values must be serializable.
+When using the `process` driver, each task receives the visible and hidden [context](/docs/{{version}}/context) captured when the process is started. This applies to both `run` and `defer`.
+
The `sync` driver executes tasks one after another. This is useful in tests and simple scripts where you want deterministic execution without concurrency.
diff --git a/src/docs/context.md b/src/docs/context.md
index dd01e72ec..6857a7f20 100644
--- a/src/docs/context.md
+++ b/src/docs/context.md
@@ -109,7 +109,7 @@ The resulting log entry would contain the information that was added to the cont
Processing podcast. {"podcast_id":95} {"url":"https://example.com/login","trace_id":"e04e1a11-e75c-4db3-b5b5-cfef4ef56697"}
```
-Although we have focused on the built-in logging related features of Hypervel's context, the following documentation will illustrate how context allows you to share information across the HTTP request / queued job boundary and even how to add [hidden context data](#hidden-context) that is not written with log entries.
+In addition to its logging features, context allows you to share information with queued jobs, [concurrent processes](/docs/{{version}}/concurrency), and [scheduled commands](/docs/{{version}}/scheduling). You may also add [hidden context data](#hidden-context) that is not written with log entries.
## Capturing Context
diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md
index ec1718e21..72981e67a 100644
--- a/src/docs/scheduling.md
+++ b/src/docs/scheduling.md
@@ -145,6 +145,8 @@ use Hypervel\Support\Facades\Schedule;
Schedule::exec('node /path/to/script.js')->daily();
```
+If the shell command launches a Hypervel Artisan command, the child command receives the task's visible and hidden [context](/docs/{{version}}/context).
+
### Schedule Frequency Options
diff --git a/src/log/src/Context/ContextServiceProvider.php b/src/log/src/Context/ContextServiceProvider.php
index cffa5b3d5..5a8f8f662 100644
--- a/src/log/src/Context/ContextServiceProvider.php
+++ b/src/log/src/Context/ContextServiceProvider.php
@@ -5,6 +5,7 @@
namespace Hypervel\Log\Context;
use Hypervel\Contracts\Log\ContextLogProcessor as ContextLogProcessorContract;
+use Hypervel\Log\Context\Events\ContextDehydrating;
use Hypervel\Queue\Events\JobProcessing;
use Hypervel\Queue\Queue;
use Hypervel\Support\Facades\Context;
@@ -25,8 +26,10 @@ public function register(): void
*/
public function boot(): void
{
- Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload): array {
- if (! Repository::hasInstance()) {
+ $events = $this->app->make('events');
+
+ Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload) use ($events): array {
+ if (! Repository::hasInstance() && ! $events->hasListeners(ContextDehydrating::class)) {
return [];
}
@@ -40,7 +43,7 @@ public function boot(): void
});
// IMPORTANT: Uses Laravel's payload key for cross-framework queue interoperability.
- $this->app->make('events')->listen(JobProcessing::class, function (JobProcessing $event): void {
+ $events->listen(JobProcessing::class, function (JobProcessing $event): void {
$context = $event->job->payload()['illuminate:log:context'] ?? null;
if ($context !== null || Repository::hasInstance()) {
diff --git a/src/log/src/Context/Repository.php b/src/log/src/Context/Repository.php
index cd1708d4f..09793e04d 100644
--- a/src/log/src/Context/Repository.php
+++ b/src/log/src/Context/Repository.php
@@ -546,7 +546,7 @@ public function replicate(): static
// --- Transport hooks ---
/**
- * Register a callback to execute before context is dehydrated for a job.
+ * Register a callback to execute before context is dehydrated.
*
* Boot-only. Registers a listener on the worker-global event dispatcher;
* per-request registration persists and affects subsequent requests.
@@ -562,7 +562,7 @@ public function dehydrating(callable $callback): static
}
/**
- * Register a callback to execute after context has been hydrated from a job.
+ * Register a callback to execute after context has been hydrated.
*
* Boot-only. Registers a listener on the worker-global event dispatcher;
* per-request registration persists and affects subsequent requests.
@@ -603,7 +603,7 @@ public static function flushState(): void
static::flushMacros();
}
- // --- Internal transport (called by queue infrastructure) ---
+ // --- Internal transport ---
/**
* Dehydrate the context into a serializable payload.
diff --git a/tests/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php
index 2d71f0f4a..eb805e850 100644
--- a/tests/Concurrency/ConcurrencyTest.php
+++ b/tests/Concurrency/ConcurrencyTest.php
@@ -14,9 +14,12 @@
use Hypervel\Coroutine\Coroutine;
use Hypervel\Engine\Channel;
use Hypervel\Process\Factory as ProcessFactory;
+use Hypervel\Process\PendingProcess;
use Hypervel\Support\Defer\DeferredCallback;
use Hypervel\Support\Defer\DeferredCallbackCollection;
use Hypervel\Support\Facades\Concurrency as ConcurrencyFacade;
+use Hypervel\Support\Facades\Context;
+use Hypervel\Testbench\Attributes\UsesVendor;
use Hypervel\Testbench\TestCase;
use Hypervel\Tests\Concurrency\Fixtures\ConcurrentProcessExceptionFixtures;
use Hypervel\Tests\Context\Fixtures\ThrowingReplicableContext;
@@ -508,6 +511,48 @@ public function testProcessDriverReportsFailedChildProcessesBeforeDecoding(): vo
$driver->run(static fn () => null);
}
+ #[UsesVendor]
+ public function testContextIsPropagatedToConcurrentProcesses(): void
+ {
+ Context::add('task', 'concurrency');
+ Context::addHidden('token', 'secret');
+
+ [$task, $token] = ConcurrencyFacade::driver('process')->run([
+ static fn (): mixed => Context::get('task'),
+ static fn (): mixed => Context::getHidden('token'),
+ ]);
+
+ $this->assertSame('concurrency', $task);
+ $this->assertSame('secret', $token);
+ }
+
+ public function testContextIsPropagatedToDeferredConcurrentProcesses(): void
+ {
+ $this->withoutDefer();
+
+ Context::add('task', 'concurrency');
+
+ $factory = $this->app->make(ProcessFactory::class);
+ $factory->fake();
+
+ (new ProcessDriver($factory))->defer([static fn (): string => 'result']);
+
+ $factory->assertRan(static fn (PendingProcess $process): bool => ($process->environment['__HYPERVEL_CONTEXT'] ?? null) === base64_encode(serialize(Context::dehydrate())));
+ }
+
+ #[UsesVendor]
+ public function testBinaryContextIsPropagatedToConcurrentProcesses(): void
+ {
+ Context::add('task', 'concurrency');
+ Context::addHidden('token', "binary-\xFF\x00\x8B");
+
+ [$context] = ConcurrencyFacade::driver('process')->run([
+ static fn (): array => [Context::get('task'), Context::getHidden('token')],
+ ]);
+
+ $this->assertSame(['concurrency', "binary-\xFF\x00\x8B"], $context);
+ }
+
public function testProcessDriverAppliesCustomTimeouts(): void
{
$factory = $this->app->make(ProcessFactory::class);
diff --git a/tests/Console/ConsoleServiceProviderTest.php b/tests/Console/ConsoleServiceProviderTest.php
index 121c3ceac..01e30b031 100644
--- a/tests/Console/ConsoleServiceProviderTest.php
+++ b/tests/Console/ConsoleServiceProviderTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Console;
+use Hypervel\Console\Command;
use Hypervel\Console\Commands\ScheduleClearCacheCommand;
use Hypervel\Console\Commands\ScheduleInterruptCommand;
use Hypervel\Console\Commands\ScheduleListCommand;
@@ -11,8 +12,18 @@
use Hypervel\Console\Commands\ScheduleResumeCommand;
use Hypervel\Console\Commands\ScheduleRunCommand;
use Hypervel\Console\Commands\ScheduleTestCommand;
+use Hypervel\Console\ConsoleServiceProvider;
+use Hypervel\Console\Events\BeforeHandle;
use Hypervel\Contracts\Console\Kernel as KernelContract;
+use Hypervel\Coroutine\Coroutine;
+use Hypervel\Engine\Channel;
+use Hypervel\Log\Context\Events\ContextHydrated;
+use Hypervel\Log\Context\Repository;
+use Hypervel\Support\Facades\Context;
+use Hypervel\Testbench\Attributes\WithEnv;
use Hypervel\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use Symfony\Component\Console\Input\ArrayInput;
class ConsoleServiceProviderTest extends TestCase
{
@@ -37,4 +48,91 @@ public function testScheduleCommandsAreRegistered()
$this->assertInstanceOf($class, $artisan->find($name));
}
}
+
+ public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void
+ {
+ $payload = [
+ 'data' => ['task' => serialize('concurrency')],
+ 'hidden' => ['token' => serialize('secret')],
+ ];
+ $restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app);
+
+ try {
+ $events = $this->app->make('events');
+ (new ConsoleServiceProvider($this->app))->boot($events);
+
+ $command = new BeforeHandle(new Command('context:test'), new ArrayInput([]));
+ $hydrations = 0;
+ $received = null;
+
+ Context::hydrated(static function (Repository $context) use ($events, $command, &$hydrations, &$received): void {
+ ++$hydrations;
+ $received = [$context->get('task'), $context->getHidden('token')];
+ $context->add('task', 'updated');
+
+ if ($hydrations === 1) {
+ $events->dispatch($command);
+ }
+ });
+
+ $events->dispatch($command);
+
+ $this->assertSame(['concurrency', 'secret'], $received);
+ $this->assertSame(1, $hydrations);
+ $this->assertSame('updated', Context::get('task'));
+
+ Context::add('task', 'later');
+ $events->dispatch($command);
+
+ $this->assertSame('later', Context::get('task'));
+
+ $result = new Channel(1);
+ Coroutine::create(static function () use ($events, $command, $result): void {
+ $events->dispatch($command);
+ $result->push([Repository::hasInstance()]);
+ });
+
+ $this->assertSame([false], $result->pop(1));
+ $this->assertSame(1, $hydrations);
+ } finally {
+ $restoreEnvironment();
+ }
+ }
+
+ #[DataProvider('contextsWithoutProcessHydration')]
+ public function testProcessContextIsNotHydratedWithoutAConsolePayload(bool $runningInConsole, ?array $payload): void
+ {
+ $restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app);
+ $previousRunningInConsole = $this->app->runningInConsole();
+
+ try {
+ $this->app->setRunningInConsole($runningInConsole);
+ $events = $this->app->make('events');
+ (new ConsoleServiceProvider($this->app))->boot($events);
+
+ $hydrations = 0;
+ $events->listen(ContextHydrated::class, static function () use (&$hydrations): void {
+ ++$hydrations;
+ });
+
+ $events->dispatch(new BeforeHandle(new Command('context:test'), new ArrayInput([])));
+
+ $this->assertSame(0, $hydrations);
+ $this->assertFalse(Repository::hasInstance());
+ } finally {
+ $this->app->setRunningInConsole($previousRunningInConsole);
+ $restoreEnvironment();
+ }
+ }
+
+ /**
+ * Provide startup contexts that must not hydrate command context.
+ */
+ public static function contextsWithoutProcessHydration(): array
+ {
+ return [
+ 'empty context' => [true, null],
+ 'HTTP server' => [false, ['data' => ['task' => serialize('concurrency')]]],
+ ];
+ }
}
diff --git a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php
index 8588adaee..81fa79648 100644
--- a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php
+++ b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php
@@ -15,9 +15,11 @@
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Coroutine\Concurrent;
use Hypervel\Engine\Channel;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Log\Context\Repository as ContextRepository;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Collection;
+use Hypervel\Support\Stringable;
use Hypervel\Testbench\TestCase;
use Mockery as m;
use ReflectionMethod;
@@ -30,6 +32,8 @@ class ScheduleRunContextPropagationTest extends TestCase
protected ExceptionHandler $handler;
+ protected ?string $outputFile = null;
+
protected function setUp(): void
{
parent::setUp();
@@ -41,6 +45,20 @@ protected function setUp(): void
$this->handler = m::mock(ExceptionHandler::class);
}
+ /**
+ * Remove captured process output.
+ */
+ protected function tearDown(): void
+ {
+ try {
+ if ($this->outputFile !== null) {
+ (new Filesystem)->delete($this->outputFile);
+ }
+ } finally {
+ parent::tearDown();
+ }
+ }
+
public function testBackgroundTaskReceivesParentContext()
{
ContextRepository::getInstance()->add('trace_id', 'parent-trace-123');
@@ -141,6 +159,28 @@ public function testForegroundTaskReceivesIndependentCopyOfParentLogContext(): v
$this->assertNull(ContextRepository::getInstance()->get('child_only'));
}
+ public function testSystemTaskReceivesContextThroughItsEnvironment(): void
+ {
+ ContextRepository::getInstance()
+ ->add('trace_id', 'parent-trace-123')
+ ->addHidden('token', 'secret');
+
+ $context = null;
+ $event = new Event(m::mock(EventMutex::class), 'printf %s "$__HYPERVEL_CONTEXT"', isSystem: true);
+ $event->thenWithOutput(static function (Stringable $output) use (&$context): void {
+ $context = (string) $output;
+ });
+ $this->outputFile = $event->output;
+
+ $event->run($this->app);
+
+ $this->assertSame(0, $event->exitCode());
+ $this->assertSame([
+ 'data' => ['trace_id' => serialize('parent-trace-123')],
+ 'hidden' => ['token' => serialize('secret')],
+ ], unserialize(base64_decode($context, true), ['allowed_classes' => false]));
+ }
+
/**
* Create a background Event mock that executes a callback inside run().
*/
diff --git a/tests/Log/ContextQueueTest.php b/tests/Log/ContextQueueTest.php
index fd951c425..342442dde 100644
--- a/tests/Log/ContextQueueTest.php
+++ b/tests/Log/ContextQueueTest.php
@@ -11,6 +11,7 @@
use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Contracts\Queue\ShouldQueue;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Engine\Channel;
use Hypervel\Foundation\Bus\Dispatchable;
use Hypervel\Foundation\Queue\Queueable;
@@ -255,6 +256,24 @@ public function testDehydratingHookFiresBeforeJobDispatch(): void
$this->assertArrayHasKey('dehydrated_at', $payload['illuminate:log:context']['data']);
}
+ public function testDehydratingHookContributesContextInAFreshCoroutine(): void
+ {
+ Repository::getInstance()->dehydrating(static function (Repository $context): void {
+ $context->addHidden('locale', 'en');
+ });
+
+ $queue = $this->createSyncQueue();
+ $result = new Channel(1);
+
+ Coroutine::create(static function () use ($queue, $result): void {
+ $result->push($queue->testCreatePayload('SomeJob', null));
+ });
+
+ $payload = $result->pop(1);
+
+ $this->assertSame(serialize('en'), $payload['illuminate:log:context']['hidden']['locale']);
+ }
+
public function testHydratedHookFiresWhenJobProcesses(): void
{
$called = false;
From d18725ff614cc0e2ce6b3f8bfe0cd6e9aeff816a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 04:24:19 +0000
Subject: [PATCH 09/22] Port Laravel collection filtering updates
Use Collection::diff() to remove exception classes from both reporting
exclusion lists and to remove exact provider names from the bootstrap file.
This replaces callback-based membership scans while preserving class-name
matching, reindexing, fluent returns and fuzzy provider removal.
Complete Laravel PR #60945 from the pinned current 13.x source:
https://github.com/laravel/framework/pull/60945
Upstream source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2
The PR's DefaultProviders::except() and JSON:API relationship-selection
changes are already present. Preserve Hypervel's resolved paths, atomic
provider-file replacement and permissions. Apply native callback types and
document the shared exception configuration's boot-time lifetime on both
the handler and its configuration wrapper.
Validation: affected ParaTest suite passes 104 tests / 316 assertions;
full source and type-fixture PHPStan, scoped formatting and diff checks pass.
No tests were added upstream; existing coverage includes exact/fuzzy
provider removal, file permissions and string/array exception inputs.
Peer review: claude-laravel-parity signed off on the final three-file diff.
---
src/foundation/src/Configuration/Exceptions.php | 3 +++
src/foundation/src/Exceptions/Handler.php | 7 +++++--
src/support/src/ServiceProvider.php | 6 +++---
3 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/src/foundation/src/Configuration/Exceptions.php b/src/foundation/src/Configuration/Exceptions.php
index 71fffceec..fad89028c 100644
--- a/src/foundation/src/Configuration/Exceptions.php
+++ b/src/foundation/src/Configuration/Exceptions.php
@@ -201,6 +201,9 @@ public function shouldRenderJsonWhen(callable $callback): static
/**
* Indicate that the given exception class should not be ignored.
*
+ * Boot-only. The exception lists persist on the shared handler and affect
+ * exception reporting for every subsequent request and job in the worker.
+ *
* @param array>|class-string $class
*/
public function stopIgnoring(array|string $class): static
diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php
index 3b8bddb1d..af0a06a4b 100644
--- a/src/foundation/src/Exceptions/Handler.php
+++ b/src/foundation/src/Exceptions/Handler.php
@@ -641,18 +641,21 @@ public function throttleUsing(callable $throttleUsing): static
/**
* Remove the given exception class from the list of exceptions that should be ignored.
+ *
+ * Boot-only. The exception lists persist on the shared handler and affect
+ * exception reporting for every subsequent request and job in the worker.
*/
public function stopIgnoring(array|string $exceptions): static
{
$exceptions = Arr::wrap($exceptions);
$this->dontReport = (new Collection($this->dontReport))
- ->reject(fn ($ignored) => in_array($ignored, $exceptions))
+ ->diff($exceptions)
->values()
->all();
$this->internalDontReport = (new Collection($this->internalDontReport))
- ->reject(fn ($ignored) => in_array($ignored, $exceptions))
+ ->diff($exceptions)
->values()
->all();
diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php
index 41aea538a..895abf61a 100644
--- a/src/support/src/ServiceProvider.php
+++ b/src/support/src/ServiceProvider.php
@@ -505,10 +505,10 @@ public static function removeProviderFromBootstrapFile(string|array $providersTo
->values()
->when(
$strict,
- static fn (Collection $providerCollection) => $providerCollection->reject(fn (string $p) => in_array($p, $providersToRemove, true)),
- static fn (Collection $providerCollection) => $providerCollection->reject(fn (string $p) => Str::contains($p, $providersToRemove))
+ static fn (Collection $providerCollection): Collection => $providerCollection->diff($providersToRemove),
+ static fn (Collection $providerCollection): Collection => $providerCollection->reject(fn (string $p): bool => Str::contains($p, $providersToRemove))
)
- ->map(fn ($p) => ' ' . $p . '::class,')
+ ->map(fn (string $p): string => ' ' . $p . '::class,')
->implode(PHP_EOL);
$content = '
Date: Sun, 6 Sep 2026 05:16:39 +0000
Subject: [PATCH 10/22] Fix Composer package uninstall event dispatch
The pre-package-uninstall callback retained Laravel's container array
access after Hypervel removed that API. Its child process failed before
dispatching the package event, and the best-effort warning allowed removal
to continue without running package cleanup such as Telescope's provider
removal.
Resolve the event dispatcher through the named container API and retain
the current Laravel process isolation, development-mode gate and failure
reporting. Preserve the dispatcher contract's mixed result. Narrow the
Composer operation to the UninstallOperation supplied by this event,
without adding runtime guards or changing public signatures.
Complete the current-source disposition of the full uninstall history:
- https://github.com/laravel/framework/pull/57144 introduces the callback
and provider-removal API; the provider implementation and all three
upstream test assertions were already present.
- https://github.com/laravel/framework/pull/57222 and
https://github.com/laravel/framework/pull/57226 are bootstrap fixes
superseded by the process-based implementation, so no legacy branches
are reintroduced.
- https://github.com/laravel/framework/pull/58177 owns process isolation.
- https://github.com/laravel/framework/pull/58338 owns the dev-mode gate.
- https://github.com/laravel/framework/pull/58609 owns encompassing error
reporting so cleanup failures do not prevent package removal.
Porting source: Laravel 13.x at
01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Add Composer as a development dependency for real package-event tests and
restore static analysis of ComposerScripts. Tests exercise successive
package removals, non-dev mode, normal and verbose failure output, and
suppression of the deliberate fixture exception through the bound handler.
Use an isolated Testbench runtime and restore all owned files and cwd.
Document the public script and package-listener APIs.
The newly available Composer types also exposed an obsolete Testbench
method-availability check. Keep its loaded-class check, remove the dead
compatibility condition and ignore, and extend the existing serve test to
verify timeout disabling while restoring Composer's timeout for every case.
Validation:
- Uninstall tests: 4 tests, 10 assertions.
- Affected ParaTest suites: 120 tests, 353 assertions.
- Serve command tests: 3 tests, 26 assertions.
- Testbench contract suite: 543 tests, 1655 assertions, 3 skips.
- Full source and type PHPStan checks, scoped formatting, Composer
validation and git diff checks pass.
Peer-reviewed and signed off by claude-laravel-parity. Resolves parity
issues AI-019 and AI-022.
---
composer.json | 1 +
phpstan.neon.dist | 1 -
src/docs/packages.md | 33 ++++
src/foundation/src/ComposerScripts.php | 9 +-
.../src/Foundation/Console/ServeCommand.php | 8 +-
.../ComposerScriptsUninstallTest.php | 157 ++++++++++++++++++
.../ComposerUninstallServiceProvider.php | 33 ++++
.../Foundation/Console/ServeCommandTest.php | 17 ++
8 files changed, 251 insertions(+), 8 deletions(-)
create mode 100644 tests/Foundation/ComposerScriptsUninstallTest.php
create mode 100644 tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php
diff --git a/composer.json b/composer.json
index b3ec1853f..954253254 100644
--- a/composer.json
+++ b/composer.json
@@ -308,6 +308,7 @@
"ably/ably-php": "^1.0",
"algolia/algoliasearch-client-php": "^4.0",
"brianium/paratest": "^7.24",
+ "composer/composer": "^2.10.3",
"composer/semver": "^3.4",
"fakerphp/faker": "^1.24",
"friendsofphp/php-cs-fixer": "^3.57.2",
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 4a1cca4c8..eec673130 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -31,7 +31,6 @@ parameters:
- %currentWorkingDirectory%/src/*/node_modules/*
- %currentWorkingDirectory%/src/*/resources/views/*
- %currentWorkingDirectory%/src/*/publish/*
- - %currentWorkingDirectory%/src/foundation/src/ComposerScripts.php
ignoreErrors:
# CreatesApplication is an open trait consumed by both PHPUnit test cases and the
# standalone Testbench application. PHPStan reports this shared capability guard
diff --git a/src/docs/packages.md b/src/docs/packages.md
index 0c576400b..a79959c95 100644
--- a/src/docs/packages.md
+++ b/src/docs/packages.md
@@ -6,6 +6,7 @@
- [Generating Facade Docblocks](#generating-facade-docblocks)
- [Package Discovery](#package-discovery)
- [Test State Cleanup](#test-state-cleanup)
+- [Package Uninstallation](#package-uninstallation)
- [Inspecting Installed Packages](#inspecting-installed-packages)
- [Service Providers](#service-providers)
- [Provider Priority](#provider-priority)
@@ -179,6 +180,38 @@ Use your Composer package name as the callback name. Registrar classes are disco
Test-state callbacks run after the test application has been destroyed. Use them for process-local state that can be reset directly, not cleanup that resolves container services. Use the appropriate testing trait to clean up external resources.
+
+## Package Uninstallation
+
+Packages may listen for an event before Composer removes them. To enable these events, add the following script to your application's `composer.json` file:
+
+```json
+"scripts": {
+ "pre-package-uninstall": [
+ "Hypervel\\Foundation\\ComposerScripts::prePackageUninstall"
+ ]
+}
+```
+
+Register a listener in your package's service provider using the event name `composer_package.vendor/package:pre_uninstall`, replacing `vendor/package` with your Composer package name. For example, you may remove a manually registered provider from the application's `bootstrap/providers.php` file:
+
+```php
+use Hypervel\Contracts\Events\Dispatcher;
+use Hypervel\Support\ServiceProvider;
+
+/**
+ * Bootstrap any package services.
+ */
+public function boot(Dispatcher $events): void
+{
+ $events->listen('composer_package.vendor/courier:pre_uninstall', static function (): void {
+ ServiceProvider::removeProviderFromBootstrapFile(CourierServiceProvider::class);
+ });
+}
+```
+
+These events do not run when Composer's `--no-dev` option is used. If an event cannot be dispatched or a listener fails, Hypervel displays a warning and allows the package removal to continue. Run Composer with `--verbose` to see the exception message.
+
## Inspecting Installed Packages
diff --git a/src/foundation/src/ComposerScripts.php b/src/foundation/src/ComposerScripts.php
index 6a58ba8f0..56d42ce0b 100644
--- a/src/foundation/src/ComposerScripts.php
+++ b/src/foundation/src/ComposerScripts.php
@@ -4,6 +4,7 @@
namespace Hypervel\Foundation;
+use Composer\DependencyResolver\Operation\UninstallOperation;
use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface;
use Composer\Script\Event;
@@ -70,16 +71,18 @@ public static function prePackageUninstall(PackageEvent $event): void
// Ensure we can encrypt our serializable closure...
(new EncryptionServiceProvider($hypervel))->register();
- $name = $event->getOperation()->getPackage()->getName();
+ /** @var UninstallOperation $operation */
+ $operation = $event->getOperation();
+ $name = $operation->getPackage()->getName();
$eventName = "composer_package.{$name}:pre_uninstall";
$hypervel->make(ProcessDriver::class)->run(
- static fn () => app()['events']->dispatch($eventName)
+ static fn (): mixed => app()->make('events')->dispatch($eventName)
);
} catch (Throwable $e) {
// Ignore any errors to allow the package removal to complete...
$event->getIO()->write('There was an error dispatching or handling the [' . ($eventName ?? 'unknown') . '] event. Continuing with package removal...');
- $event->getIO()->writeError('Exception message: ' . $e->getMessage(), verbosity: IOInterface::VERBOSE); // @phpstan-ignore class.notFound (Composer exists if this is running)
+ $event->getIO()->writeError('Exception message: ' . $e->getMessage(), verbosity: IOInterface::VERBOSE);
}
}
diff --git a/src/testbench/src/Foundation/Console/ServeCommand.php b/src/testbench/src/Foundation/Console/ServeCommand.php
index 22f06a6a3..3686309f1 100644
--- a/src/testbench/src/Foundation/Console/ServeCommand.php
+++ b/src/testbench/src/Foundation/Console/ServeCommand.php
@@ -22,13 +22,13 @@
#[AsCommand(name: 'serve', description: 'Start Hypervel servers.')]
class ServeCommand extends Command
{
+ /**
+ * Execute the console command.
+ */
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
- if (
- class_exists(ComposerConfig::class, false)
- && method_exists(ComposerConfig::class, 'disableProcessTimeout') // @phpstan-ignore function.impossibleType
- ) {
+ if (class_exists(ComposerConfig::class, false)) {
ComposerConfig::disableProcessTimeout();
}
diff --git a/tests/Foundation/ComposerScriptsUninstallTest.php b/tests/Foundation/ComposerScriptsUninstallTest.php
new file mode 100644
index 000000000..3dbc921d4
--- /dev/null
+++ b/tests/Foundation/ComposerScriptsUninstallTest.php
@@ -0,0 +1,157 @@
+files = new Filesystem;
+ $this->providersPath = $this->app->getBootstrapProvidersPath();
+ $this->originalProviders = $this->files->get($this->providersPath);
+ $this->marker = $this->app->storagePath('composer-uninstall-events.log');
+ $this->previousDirectory = getcwd();
+
+ $this->composer = new Composer;
+ $config = new Config(false);
+ $config->merge(['config' => ['vendor-dir' => $this->app->basePath('vendor')]]);
+ $this->composer->setConfig($config);
+
+ // Only the child application boots this provider.
+ $this->files->replace($this->providersPath, 'app->basePath());
+ }
+
+ /**
+ * Restore the shared application files and working directory.
+ */
+ protected function tearDown(): void
+ {
+ CleanupActions::run(
+ fn (): bool => chdir($this->previousDirectory),
+ function (): void {
+ $this->files->replace($this->providersPath, $this->originalProviders);
+ },
+ fn (): bool => $this->files->delete($this->marker),
+ function (): void {
+ parent::tearDown();
+ },
+ );
+ }
+
+ public function testPrePackageUninstallDispatchesEachPackageEvent(): void
+ {
+ $io = new BufferIO;
+
+ ComposerScripts::prePackageUninstall($this->createPackageEvent('example/first', $io));
+ ComposerScripts::prePackageUninstall($this->createPackageEvent('example/second', $io));
+
+ $this->assertSame('', $io->getOutput());
+ $this->assertSame(
+ 'composer_package.example/first:pre_uninstall' . PHP_EOL . 'composer_package.example/second:pre_uninstall' . PHP_EOL,
+ $this->files->get($this->marker),
+ );
+ }
+
+ public function testPrePackageUninstallDoesNotDispatchOutsideDevMode(): void
+ {
+ $io = new BufferIO;
+
+ ComposerScripts::prePackageUninstall($this->createPackageEvent('example/first', $io, devMode: false));
+
+ $this->assertSame('', $io->getOutput());
+ $this->assertFileDoesNotExist($this->marker);
+ }
+
+ #[DataProvider('failureVerbosityProvider')]
+ public function testPrePackageUninstallContinuesAfterListenerFailure(int $verbosity, string $exceptionOutput): void
+ {
+ $io = new BufferIO(verbosity: $verbosity);
+
+ ComposerScripts::prePackageUninstall($this->createPackageEvent('example/failing', $io));
+
+ $this->assertSame('composer_package.example/failing:pre_uninstall' . PHP_EOL, $this->files->get($this->marker));
+ $this->assertSame(
+ 'There was an error dispatching or handling the [composer_package.example/failing:pre_uninstall] event. Continuing with package removal...' . PHP_EOL . $exceptionOutput,
+ $io->getOutput(),
+ );
+
+ $logPath = $this->app->storagePath('logs/hypervel.log');
+
+ $this->assertStringNotContainsString(
+ 'Package cleanup failed.',
+ $this->files->exists($logPath) ? $this->files->get($logPath) : '',
+ );
+ }
+
+ /**
+ * Provide the exception detail shown at each output level.
+ *
+ * @return array
+ */
+ public static function failureVerbosityProvider(): array
+ {
+ return [
+ 'normal' => [OutputInterface::VERBOSITY_NORMAL, ''],
+ 'verbose' => [OutputInterface::VERBOSITY_VERBOSE, 'Exception message: Package cleanup failed.' . PHP_EOL],
+ ];
+ }
+
+ /**
+ * Create the Composer event for a package removal.
+ */
+ protected function createPackageEvent(string $package, BufferIO $io, bool $devMode = true): PackageEvent
+ {
+ $operation = new UninstallOperation(new Package($package, '1.0.0.0', '1.0.0'));
+
+ return new PackageEvent(
+ PackageEvents::PRE_PACKAGE_UNINSTALL,
+ $this->composer,
+ $io,
+ $devMode,
+ new ArrayRepository,
+ [$operation],
+ $operation,
+ );
+ }
+}
diff --git a/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php b/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php
new file mode 100644
index 000000000..aac8a376e
--- /dev/null
+++ b/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php
@@ -0,0 +1,33 @@
+dontReport(RuntimeException::class);
+
+ $marker = $this->app->storagePath('composer-uninstall-events.log');
+ $events->listen('composer_package.example/*:pre_uninstall', static function (string $event) use ($files, $marker): void {
+ $files->append($marker, $event . PHP_EOL);
+
+ if ($event === 'composer_package.example/failing:pre_uninstall') {
+ throw new RuntimeException('Package cleanup failed.');
+ }
+ });
+ }
+}
diff --git a/tests/Testbench/Foundation/Console/ServeCommandTest.php b/tests/Testbench/Foundation/Console/ServeCommandTest.php
index c7ade76e8..980ef773a 100644
--- a/tests/Testbench/Foundation/Console/ServeCommandTest.php
+++ b/tests/Testbench/Foundation/Console/ServeCommandTest.php
@@ -4,6 +4,8 @@
namespace Hypervel\Tests\Testbench\Foundation\Console;
+use Composer\Config as ComposerConfig;
+use Composer\Util\ProcessExecutor;
use Hypervel\Console\OutputStyle;
use Hypervel\Console\View\Components\Factory;
use Hypervel\Contracts\Config\Repository;
@@ -30,10 +32,16 @@ class ServeCommandTest extends TestCase
/** @var array{process: false|string, environment_exists: bool, environment: mixed, server_exists: bool, server: mixed} */
private array $workingPathState;
+ private int $processTimeout;
+
+ /**
+ * Capture the working environment and Composer timeout.
+ */
protected function setUp(): void
{
parent::setUp();
+ $this->processTimeout = ProcessExecutor::getTimeout();
$this->workingPathState = [
'process' => getenv(self::WORKING_PATH_ENV),
'environment_exists' => array_key_exists(self::WORKING_PATH_ENV, $_ENV),
@@ -43,6 +51,9 @@ protected function setUp(): void
];
}
+ /**
+ * Restore the working environment and Composer timeout.
+ */
protected function tearDown(): void
{
try {
@@ -63,6 +74,8 @@ protected function tearDown(): void
unset($_SERVER[self::WORKING_PATH_ENV]);
}
} finally {
+ ProcessExecutor::setTimeout($this->processTimeout);
+
parent::tearDown();
}
}
@@ -100,9 +113,13 @@ public function itStartsTheUnderlyingServerCommandAndDispatchesLifecycleEvents()
Application::getInstance()->setRunningInConsole(false);
+ class_exists(ComposerConfig::class);
+ ProcessExecutor::setTimeout(300);
+
$result = $command->run(new ArrayInput([]), new NullOutput);
$this->assertSame(0, $result);
+ $this->assertSame(0, ProcessExecutor::getTimeout());
$this->assertCount(1, $startedEvents);
$this->assertCount(1, $endedEvents);
$this->assertSame(0, $endedEvents[0]->exitCode);
From 25dc2ea9a3144ba72778522a4e2357a9cebc5cfc Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 05:35:36 +0000
Subject: [PATCH 11/22] Clarify PHPUnit exception assertion cleanup
Expand the existing testing follow-up with the combined exception-object assertion and explicit matching semantics. Link the PHPUnit soft-deprecation so the later cleanup has its upstream rationale.
The parity session modernizes only test code it modifies; the suite-wide cleanup remains a follow-up. No source or tests changed. Validation: git diff --check.
---
docs/todo.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/todo.md b/docs/todo.md
index d4d23aa48..542bf0858 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -31,7 +31,7 @@
## Testing
-- Replace PHPUnit 13's deprecated `expectExceptionMessage()` calls across the test suite. Use `expectExceptionMessageIs()` for complete messages and `expectExceptionMessageIsOrContains()` for fragments, auditing each assertion's intent and running its owning test file as it is changed.
+- Replace PHPUnit 13's [soft-deprecated `expectExceptionMessage()`](https://github.com/sebastianbergmann/phpunit/issues/6560) calls across the test suite. Preserve intended matching semantics: use `expectExceptionObject()` for combined class/message/code expectations, `expectExceptionMessageIs()` for exact messages, and `expectExceptionMessageIsOrContains()` for substring matching. Audit each assertion's intent and run its owning test file as it is changed.
## HTTP Server
From f7f5dd9aee387c02ba462c9a1c5bd0eaa3102d3c Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 09:46:26 +0000
Subject: [PATCH 12/22] Port queue totals and complete Redis bulk parity
Add totalSize(), totalPendingSize(), totalDelayedSize() and
totalReservedSize() across the supported queue drivers, QueueFake,
QueuePoolProxy and the generated Queue facade. Database totals use the
upstream state predicates. Redis totals retain virtual discovery and
per-queue size methods while pinning one pooled connection per operation.
With owner approval, Beanstalkd reads native server stats rather than
returning zero; buried jobs remain excluded consistently with size().
Complete the QueueFake bulk delay-attribute fix using Hypervel's existing
attribute resolver. Count its three disjoint inspection states without
duplicating delayed jobs in the aggregate.
Port all Redis Cluster bulk/discovery tests against Hypervel's existing
unified Lua batch dispatch and streamed key discovery. The owner approved
omitting scanQueueKeys(), bulkOnClusterConnection() and bulkPush(); record
those specific extension-point differences in source and the queue README.
Preserve numeric queue names as strings when deduplicating discovery.
Fix confirmed scan-prefix failures exposed by the port. SafeScan honors
the native prefix bit, including combined retry/prefix settings, without
double-prefixing key patterns. Cache member scans temporarily disable
prefix matching on their held connection and restore it in finally.
Keep native member-filtering behavior elsewhere, preserve the retry bit,
and exclude this connection-bound scope from proxy/facade forwarding.
Port every applicable upstream test and add focused coverage for pooled
extension-point dispatch, native Beanstalkd totals, queue-name identity,
scan flag combinations, exception cleanup and multi-page cache scans.
Document the public totals and scan behavior concisely.
Upstream PRs:
https://github.com/laravel/framework/pull/61231
https://github.com/laravel/framework/pull/61373
https://github.com/laravel/framework/pull/60916
https://github.com/laravel/framework/pull/61198
Ported from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Validation: Queue units 659/2799; QueueFake 63/204; Redis units 671/2377
(two existing skips); Redis cache units 479/2312. Redis queue integration
46/269 on both standalone and Cluster; permanent cache flush/prune
integration 32/1279 on Cluster, with both files also passing standalone.
SafeScan regressions pass on both topologies. Full source/type PHPStan,
scoped project formatter, Queue/Redis facade lint and diff checks pass.
Beanstalkd uses real ServerStats fixtures; no local server is available.
Full combined diff independently reviewed and signed off by
claude-laravel-parity in Codesonic message
2026-09-06-191511-claude-laravel-parity-to-codex-laravel-parity-pr-61231-61373-60916-61198-combined-diff-approved.md.
---
.../Redis/Operations/AllTag/GetEntries.php | 6 +-
.../src/Redis/Operations/AllTag/Prune.php | 6 +-
.../Redis/Operations/AnyTag/GetTaggedKeys.php | 6 +-
.../src/Redis/Operations/AnyTag/Prune.php | 12 +-
src/docs/queues.md | 11 +
src/docs/redis.md | 4 +-
src/queue/README.md | 3 +-
src/queue/src/BeanstalkdQueue.php | 36 ++++
src/queue/src/DatabaseQueue.php | 40 ++++
src/queue/src/FailoverQueue.php | 33 +++
src/queue/src/LuaScripts.php | 3 +
src/queue/src/NullQueue.php | 32 +++
src/queue/src/QueuePoolProxy.php | 32 +++
src/queue/src/RedisQueue.php | 120 ++++++++---
src/queue/src/SqsQueue.php | 32 +++
src/queue/src/SyncQueue.php | 32 +++
src/redis/src/Operations/SafeScan.php | 20 +-
src/redis/src/RedisConnection.php | 29 ++-
src/redis/src/RedisProxy.php | 1 +
src/support/src/Facades/Queue.php | 4 +
src/support/src/Facades/Redis.php | 1 +
src/support/src/Testing/Fakes/QueueFake.php | 47 ++++-
tests/Cache/Redis/RedisCacheTestCase.php | 10 +-
.../Redis/FlushOperationsIntegrationTest.php | 18 +-
.../Cache/Redis/PruneIntegrationTest.php | 6 +
.../Queue/Redis/RedisQueueTest.php | 195 ++++++++++++++++++
.../Redis/SafeScanIntegrationTest.php | 29 ++-
tests/Queue/FailoverQueueTest.php | 10 +-
tests/Queue/QueueBeanstalkdQueueTest.php | 69 +++++++
tests/Queue/QueueDatabaseQueueUnitTest.php | 68 +++++-
tests/Queue/QueuePoolProxyTest.php | 8 +
tests/Queue/QueueRedisQueueTest.php | 51 +++++
tests/Redis/RedisConnectionTest.php | 57 +++++
tests/Support/SupportTestingQueueFakeTest.php | 88 ++++++++
34 files changed, 1058 insertions(+), 61 deletions(-)
diff --git a/src/cache/src/Redis/Operations/AllTag/GetEntries.php b/src/cache/src/Redis/Operations/AllTag/GetEntries.php
index 28834e4d8..677320216 100644
--- a/src/cache/src/Redis/Operations/AllTag/GetEntries.php
+++ b/src/cache/src/Redis/Operations/AllTag/GetEntries.php
@@ -38,8 +38,10 @@ public function execute(array $tagIds): LazyCollection
do {
$entries = $context->withConnection(
- function (RedisConnection $connection) use ($prefix, $tagId, &$cursor) {
- return $connection->zscan($prefix . $tagId, $cursor, '*', 1000);
+ function (RedisConnection $connection) use ($prefix, $tagId, &$cursor): mixed {
+ return $connection->withoutScanPrefix(function () use ($connection, $prefix, $tagId, &$cursor): mixed {
+ return $connection->zscan($prefix . $tagId, $cursor, '*', 1000);
+ });
}
);
diff --git a/src/cache/src/Redis/Operations/AllTag/Prune.php b/src/cache/src/Redis/Operations/AllTag/Prune.php
index 6bdba4555..b433af34c 100644
--- a/src/cache/src/Redis/Operations/AllTag/Prune.php
+++ b/src/cache/src/Redis/Operations/AllTag/Prune.php
@@ -100,8 +100,10 @@ private function removeOrphanedEntries(
$iterator = PhpRedis::initialScanCursor();
do {
- // ZSCAN returns [member => score, ...] array
- $members = $connection->zScan($tagKey, $iterator, '*', $scanCount);
+ // Tag members omit OPT_PREFIX, so scan every member without prefixing the pattern.
+ $members = $connection->withoutScanPrefix(function () use ($connection, $tagKey, &$iterator, $scanCount): mixed {
+ return $connection->zScan($tagKey, $iterator, '*', $scanCount);
+ });
if ($members === false || ! is_array($members)) {
break;
diff --git a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php
index 897b4d7d2..e61333647 100644
--- a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php
+++ b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php
@@ -90,8 +90,10 @@ private function hscanGenerator(string $tagKey, int $count): Generator
do {
// Acquire connection just for this HSCAN batch
$fields = $this->context->withConnection(
- function (RedisConnection $connection) use ($tagKey, &$iterator, $count) {
- return $connection->hscan($tagKey, $iterator, null, $count);
+ function (RedisConnection $connection) use ($tagKey, &$iterator, $count): mixed {
+ return $connection->withoutScanPrefix(function () use ($connection, $tagKey, &$iterator, $count): mixed {
+ return $connection->hscan($tagKey, $iterator, null, $count);
+ });
}
);
diff --git a/src/cache/src/Redis/Operations/AnyTag/Prune.php b/src/cache/src/Redis/Operations/AnyTag/Prune.php
index ab8417d68..5cd1c2eb5 100644
--- a/src/cache/src/Redis/Operations/AnyTag/Prune.php
+++ b/src/cache/src/Redis/Operations/AnyTag/Prune.php
@@ -169,8 +169,10 @@ private function cleanupTagHashUsingLua(
$iterator = PhpRedis::initialScanCursor();
do {
- // HSCAN returns [field => value, ...] array
- $fields = $connection->hScan($tagHash, $iterator, '*', $scanCount);
+ // Tag fields omit OPT_PREFIX, so scan every field without prefixing the pattern.
+ $fields = $connection->withoutScanPrefix(function () use ($connection, $tagHash, &$iterator, $scanCount): mixed {
+ return $connection->hScan($tagHash, $iterator, '*', $scanCount);
+ });
if ($fields === false || ! is_array($fields)) {
break;
@@ -230,8 +232,10 @@ private function cleanupTagHashCluster(
$iterator = PhpRedis::initialScanCursor();
do {
- // HSCAN returns [field => value, ...] array
- $fields = $connection->hScan($tagHash, $iterator, '*', $scanCount);
+ // Tag fields omit OPT_PREFIX, so scan every field without prefixing the pattern.
+ $fields = $connection->withoutScanPrefix(function () use ($connection, $tagHash, &$iterator, $scanCount): mixed {
+ return $connection->hScan($tagHash, $iterator, '*', $scanCount);
+ });
if ($fields === false || ! is_array($fields)) {
break;
diff --git a/src/docs/queues.md b/src/docs/queues.md
index a7684e9a4..9ef390c1c 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -3304,6 +3304,17 @@ $reserved = $queue->allReservedJobs();
These methods load every matching job into memory. Avoid using them against very large backlogs in latency-sensitive code.
+To count jobs across every queue on a database, Redis, or Beanstalkd connection, use the `totalSize`, `totalPendingSize`, `totalDelayedSize`, and `totalReservedSize` methods:
+
+```php
+$total = $queue->totalSize();
+$pending = $queue->totalPendingSize();
+$delayed = $queue->totalDelayedSize();
+$reserved = $queue->totalReservedSize();
+```
+
+The total includes pending, delayed, and reserved jobs. Beanstalkd's buried jobs are excluded.
+
## Monitoring Your Queues
diff --git a/src/docs/redis.md b/src/docs/redis.md
index dc11900b6..3959ffcc5 100644
--- a/src/docs/redis.md
+++ b/src/docs/redis.md
@@ -608,7 +608,7 @@ PhpRedis does not support pipelining on Redis Cluster connections. Pipelining re
### Advanced Helpers
-If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. The `safeScan` method handles PhpRedis' `OPT_PREFIX` behavior by adding the prefix to the scan pattern and removing it from returned keys:
+If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. The `safeScan` method applies your configured prefix exactly once, including when PhpRedis' `SCAN_PREFIX` option is enabled, and removes it from returned keys:
```php
use Hypervel\Redis\RedisConnection;
@@ -619,6 +619,8 @@ $keys = Redis::withConnection(function (RedisConnection $connection): array {
}, transform: false);
```
+The `SCAN_PREFIX` option also prefixes hash-field and set-member patterns. To scan these without prefixing the pattern, wrap the scan calls in `$connection->withoutScanPrefix($callback)` inside `withConnection`. Key prefixing is unchanged, and scan options are restored when the callback finishes.
+
The `evalWithShaCache` method executes a Lua script using `evalSha` and automatically falls back to `eval` when Redis has not cached the script yet:
```php
diff --git a/src/queue/README.md b/src/queue/README.md
index 552ba2baa..207046744 100644
--- a/src/queue/README.md
+++ b/src/queue/README.md
@@ -10,6 +10,7 @@ Documentation: https://hypervel.org/docs/queues
- The protected `enqueueUsing()` callback receives the queue that owns the operation as its first argument. This lets deferred work borrow a fresh pooled queue connection after a database transaction commits instead of retaining a connection for the lifetime of the transaction.
- Positive per-message delays on SQS FIFO queues throw `LogicException`. Laravel silently omits the delay and sends the job immediately.
- `RateLimited::releaseAfter(0)` requests an immediate retry. Laravel treats zero as absent and uses the limiter's computed retry delay.
-- Redis bulk dispatch uses one same-slot Lua call, keeping Cluster dispatch to one round trip and giving every Redis topology the same exact completion count. Laravel uses a nested transaction and pipeline.
+- `RedisQueue::scanQueueKeys()` is intentionally omitted. Queue discovery streams keys on a held connection instead of materializing a physical-key array; override `allQueueNames()` to customize discovery.
+- Redis bulk dispatch uses `enqueueBatch()` and `LuaScripts::bulk()` for both standalone Redis and Cluster, storing immediate and delayed jobs together with an exact completion count. Laravel's `RedisQueue::bulkOnClusterConnection()` and `LuaScripts::bulkPush()` helpers are intentionally omitted.
Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Queue
diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php
index 54d0ca5bb..bdde992b5 100644
--- a/src/queue/src/BeanstalkdQueue.php
+++ b/src/queue/src/BeanstalkdQueue.php
@@ -73,6 +73,42 @@ public function reservedSize(?string $queue = null): int
return $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue)))->currentJobsReserved;
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ $stats = $this->pheanstalk->stats();
+
+ return $stats->currentJobsReady
+ + $stats->currentJobsDelayed
+ + $stats->currentJobsReserved;
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return $this->pheanstalk->stats()->currentJobsReady;
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return $this->pheanstalk->stats()->currentJobsDelayed;
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return $this->pheanstalk->stats()->currentJobsReserved;
+ }
+
/**
* Get the pending jobs for the given queue.
*/
diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php
index 77ceef0c7..946c7bb6f 100644
--- a/src/queue/src/DatabaseQueue.php
+++ b/src/queue/src/DatabaseQueue.php
@@ -93,6 +93,46 @@ public function reservedSize(?string $queue = null): int
->count();
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ return $this->getDatabase()->table($this->table)->count();
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return $this->getDatabase()->table($this->table)
+ ->whereNull('reserved_at')
+ ->where('available_at', '<=', $this->currentTime())
+ ->count();
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return $this->getDatabase()->table($this->table)
+ ->whereNull('reserved_at')
+ ->where('available_at', '>', $this->currentTime())
+ ->count();
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return $this->getDatabase()->table($this->table)
+ ->whereNotNull('reserved_at')
+ ->count();
+ }
+
/**
* Get the pending jobs for the given queue.
*
diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php
index 378e7d900..3f99aa415 100644
--- a/src/queue/src/FailoverQueue.php
+++ b/src/queue/src/FailoverQueue.php
@@ -77,6 +77,39 @@ public function reservedSize(?string $queue = null): int
return $this->manager->connection($this->connections[0])->reservedSize($queue);
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ // Aggregate counts are an optional driver capability outside the core Queue contract.
+ return $this->manager->connection($this->connections[0])->totalSize(); // @phpstan-ignore method.notFound
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return $this->manager->connection($this->connections[0])->totalPendingSize(); // @phpstan-ignore method.notFound
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return $this->manager->connection($this->connections[0])->totalDelayedSize(); // @phpstan-ignore method.notFound
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return $this->manager->connection($this->connections[0])->totalReservedSize(); // @phpstan-ignore method.notFound
+ }
+
/**
* Get the pending jobs for the given queue.
*/
diff --git a/src/queue/src/LuaScripts.php b/src/queue/src/LuaScripts.php
index 016aed7f8..95a65a79e 100644
--- a/src/queue/src/LuaScripts.php
+++ b/src/queue/src/LuaScripts.php
@@ -37,6 +37,9 @@ public static function push(): string
LUA;
}
+ // REMOVED: Laravel's bulkPush(). bulk() stores immediate and delayed jobs
+ // together and returns the count required for dispatch confirmation.
+
/**
* Get the Lua script for pushing delayed jobs onto the queue.
*
diff --git a/src/queue/src/NullQueue.php b/src/queue/src/NullQueue.php
index deb105dfd..7bf849f45 100644
--- a/src/queue/src/NullQueue.php
+++ b/src/queue/src/NullQueue.php
@@ -44,6 +44,38 @@ public function reservedSize(?string $queue = null): int
return 0;
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return 0;
+ }
+
/**
* Get the pending jobs for the given queue.
*/
diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php
index 364fc0e4b..14c5a391d 100644
--- a/src/queue/src/QueuePoolProxy.php
+++ b/src/queue/src/QueuePoolProxy.php
@@ -84,6 +84,38 @@ public function reservedSize(?string $queue = null): int
return $this->invoke(__FUNCTION__, func_get_args());
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ return $this->invoke(__FUNCTION__, func_get_args());
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return $this->invoke(__FUNCTION__, func_get_args());
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return $this->invoke(__FUNCTION__, func_get_args());
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return $this->invoke(__FUNCTION__, func_get_args());
+ }
+
/**
* Get the pending jobs for the given queue.
*/
diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php
index 4371e25db..5a0ed1414 100644
--- a/src/queue/src/RedisQueue.php
+++ b/src/queue/src/RedisQueue.php
@@ -101,6 +101,46 @@ public function reservedSize(?string $queue = null): int
return $this->getConnection()->zcard($this->getQueueRedisKey($queue) . ':reserved');
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ return $this->getConnection()->withPinnedConnection(
+ fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->size($name)),
+ );
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return $this->getConnection()->withPinnedConnection(
+ fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->pendingSize($name)),
+ );
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return $this->getConnection()->withPinnedConnection(
+ fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->delayedSize($name)),
+ );
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return $this->getConnection()->withPinnedConnection(
+ fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->reservedSize($name)),
+ );
+ }
+
/**
* Get the pending jobs for the given queue.
*
@@ -161,6 +201,55 @@ public function allReservedJobs(): Collection
return $this->inspectAllQueues(':reserved');
}
+ /**
+ * Get the unique queue names.
+ *
+ * @return Collection
+ */
+ protected function allQueueNames(): Collection
+ {
+ return $this->getConnection()->withConnection(
+ fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection),
+ transform: false,
+ );
+ }
+
+ // REMOVED: Laravel's scanQueueKeys(). allQueueNamesUsing() streams keys on
+ // a held connection instead of materializing a physical-key array.
+
+ /**
+ * Get the unique queue names using an already-held raw connection.
+ *
+ * @return Collection
+ */
+ protected function allQueueNamesUsing(RedisConnection $connection): Collection
+ {
+ $this->isCluster ??= $connection->isCluster();
+ $names = [];
+
+ foreach ($connection->safeScan('queues:*') as $key) {
+ $name = substr($key, strlen('queues:'));
+
+ foreach ([':delayed', ':reserved', ':notify'] as $storageSuffix) {
+ if (str_ends_with($name, $storageSuffix)) {
+ $name = substr($name, 0, -strlen($storageSuffix));
+ break;
+ }
+ }
+
+ // Cluster hash tags are routing syntax, so discovery reports the
+ // canonical queue name. On standalone Redis, braces remain identity.
+ if ($this->isCluster && preg_match('/^\{([^{}]+)\}$/', $name, $matches) === 1) {
+ $name = $matches[1];
+ }
+
+ // Keep the string value because PHP converts numeric array keys to integers.
+ $names[$name] = $name;
+ }
+
+ return Collection::make(array_values($names));
+ }
+
/**
* Inspect jobs from one queue while holding one Redis connection.
*
@@ -188,32 +277,8 @@ function (RedisConnection $connection) use ($name, $suffix): Collection {
protected function inspectAllQueues(string $suffix = ''): Collection
{
return $this->getConnection()->withConnection(
- function (RedisConnection $connection) use ($suffix): Collection {
- $this->isCluster ??= $connection->isCluster();
- $names = [];
-
- foreach ($connection->safeScan('queues:*') as $key) {
- $name = substr($key, strlen('queues:'));
-
- foreach ([':delayed', ':reserved', ':notify'] as $storageSuffix) {
- if (str_ends_with($name, $storageSuffix)) {
- $name = substr($name, 0, -strlen($storageSuffix));
- break;
- }
- }
-
- // Cluster hash tags are routing syntax, so discovery reports the
- // canonical queue name. On standalone Redis, braces remain identity.
- if ($this->isCluster && preg_match('/^\{([^{}]+)\}$/', $name, $matches) === 1) {
- $name = $matches[1];
- }
-
- $names[$name] = true;
- }
-
- return Collection::make(array_keys($names))
- ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix));
- },
+ fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection)
+ ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix)),
transform: false,
);
}
@@ -292,6 +357,9 @@ static function (Queue $owner) use ($preparedJobs, $queue): void {
return null;
}
+ // REMOVED: Laravel's bulkOnClusterConnection(). enqueueBatch() owns Lua
+ // bulk dispatch for both standalone Redis and Cluster.
+
/**
* Prepare the payload and delay for each of the given jobs.
*
diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php
index 7fd5af91d..a7c4392c9 100644
--- a/src/queue/src/SqsQueue.php
+++ b/src/queue/src/SqsQueue.php
@@ -129,6 +129,38 @@ public function reservedSize(?string $queue = null): int
return (int) ($response['Attributes']['ApproximateNumberOfMessagesNotVisible'] ?? 0);
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return 0;
+ }
+
/**
* Get the pending jobs for the given queue.
*/
diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php
index 7edbca6b6..a216aeefb 100644
--- a/src/queue/src/SyncQueue.php
+++ b/src/queue/src/SyncQueue.php
@@ -66,6 +66,38 @@ public function reservedSize(?string $queue = null): int
return 0;
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return 0;
+ }
+
/**
* Get the pending jobs for the given queue.
*/
diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php
index fdab04131..907beff47 100644
--- a/src/redis/src/Operations/SafeScan.php
+++ b/src/redis/src/Operations/SafeScan.php
@@ -9,6 +9,7 @@
use Hypervel\Redis\PhpRedis;
use Hypervel\Redis\PhpRedisClusterConnection;
use Hypervel\Redis\RedisConnection;
+use Redis;
/**
* Safely scan the Redis keyspace for keys matching a pattern.
@@ -21,8 +22,8 @@
* phpredis has an OPT_PREFIX option that automatically prepends a prefix to keys
* for most commands (GET, SET, DEL, etc.). However, this creates complexity:
*
- * 1. **SCAN does NOT auto-prefix the pattern** - You must manually include OPT_PREFIX
- * in your SCAN pattern to match keys that were stored with auto-prefixing.
+ * 1. **SCAN prefixing is configurable** - SCAN_PREFIX adds OPT_PREFIX to the pattern.
+ * Without it, the pattern must include OPT_PREFIX to match auto-prefixed keys.
*
* 2. **SCAN returns full keys** - Keys returned include the OPT_PREFIX as stored in Redis.
*
@@ -31,6 +32,8 @@
*
* ## Example of the Bug This Class Prevents
*
+ * With SCAN_PREFIX disabled:
+ *
* ```
* OPT_PREFIX = "myapp:"
* Stored key in Redis = "myapp:cache:user:1"
@@ -102,11 +105,16 @@ public function execute(string $pattern, int $count = 1000): Generator
{
$prefixLen = strlen($this->optPrefix);
- // SCAN does not automatically apply OPT_PREFIX to the pattern,
- // so we must prepend it manually to match keys stored with auto-prefixing.
+ // Apply OPT_PREFIX exactly once, whether phpredis adds it or not.
$scanPattern = $pattern;
- if ($prefixLen > 0 && ! str_starts_with($pattern, $this->optPrefix)) {
- $scanPattern = $this->optPrefix . $pattern;
+ if ($prefixLen > 0) {
+ if (str_starts_with($scanPattern, $this->optPrefix)) {
+ $scanPattern = substr($scanPattern, $prefixLen);
+ }
+
+ if (($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) {
+ $scanPattern = $this->optPrefix . $scanPattern;
+ }
}
// Route to cluster or standard implementation
diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php
index cb4ed75fc..3f9569568 100644
--- a/src/redis/src/RedisConnection.php
+++ b/src/redis/src/RedisConnection.php
@@ -1651,6 +1651,33 @@ public function compressed(): bool
return $this->connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE;
}
+ /**
+ * Execute the given callback without prefixing scan patterns.
+ *
+ * Key prefixing remains unchanged. Hold this connection until the callback
+ * finishes so its scan options are restored before returning it to the pool.
+ *
+ * @template TReturn
+ *
+ * @param callable(): TReturn $callback
+ * @return TReturn
+ */
+ public function withoutScanPrefix(callable $callback): mixed
+ {
+ if (($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) {
+ return $callback();
+ }
+
+ $this->connection->setOption(Redis::OPT_SCAN, Redis::SCAN_NOPREFIX);
+
+ try {
+ return $callback();
+ } finally {
+ // OPT_SCAN setters toggle individual flags; passing the saved bitmask can disable prefixing.
+ $this->connection->setOption(Redis::OPT_SCAN, Redis::SCAN_PREFIX);
+ }
+ }
+
/**
* Execute the given callback without serialization or compression.
*
@@ -1799,7 +1826,7 @@ protected function normalizeNullReplies(mixed $result): mixed
* Safely scan the Redis keyspace for keys matching a pattern.
*
* This method handles the phpredis OPT_PREFIX complexity correctly:
- * - Automatically prepends OPT_PREFIX to the scan pattern
+ * - Applies OPT_PREFIX to the scan pattern exactly once, respecting SCAN_PREFIX
* - Strips OPT_PREFIX from returned keys so they work with other commands
*
* The connection must be held with transform disabled so SCAN retains its
diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php
index a7b5be86a..10ab18ca2 100644
--- a/src/redis/src/RedisProxy.php
+++ b/src/redis/src/RedisProxy.php
@@ -80,6 +80,7 @@ class RedisProxy implements ConnectionContract
'safescan',
'setoption',
'shouldtransform',
+ 'withoutscanprefix',
];
/**
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index 7b1c9e0e3..3bcfd1b4f 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -96,6 +96,10 @@
* @method static \Hypervel\Support\Collection reservedJobs(\UnitEnum|string|null $queue = null)
* @method static \Hypervel\Support\Testing\Fakes\QueueFake serializeAndRestore(bool $serializeAndRestore = true)
* @method static bool shouldFakeJob(object|string $job)
+ * @method static int totalDelayedSize()
+ * @method static int totalPendingSize()
+ * @method static int totalReservedSize()
+ * @method static int totalSize()
*
* @see \Hypervel\Queue\QueueManager
* @see \Hypervel\Queue\Queue
diff --git a/src/support/src/Facades/Redis.php b/src/support/src/Facades/Redis.php
index 9eda33cb1..7edc2d2a9 100644
--- a/src/support/src/Facades/Redis.php
+++ b/src/support/src/Facades/Redis.php
@@ -344,6 +344,7 @@ protected static function ignoredFacadeDocumenterMethods(): array
'setOption',
'shouldTransform',
'ssubscribe',
+ 'withoutScanPrefix',
];
}
diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php
index fe818a783..9f61cad4f 100644
--- a/src/support/src/Testing/Fakes/QueueFake.php
+++ b/src/support/src/Testing/Fakes/QueueFake.php
@@ -17,6 +17,8 @@
use Hypervel\Contracts\Queue\Queue;
use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Events\CallQueuedListener;
+use Hypervel\Queue\Attributes\Delay;
+use Hypervel\Queue\Attributes\ReadsQueueAttributes;
use Hypervel\Queue\CallQueuedClosure;
use Hypervel\Queue\Jobs\InspectedJob;
use Hypervel\Queue\QueueManager;
@@ -34,6 +36,7 @@
*/
class QueueFake extends QueueManager implements Fake, Queue
{
+ use ReadsQueueAttributes;
use ReflectsClosures;
/**
@@ -414,6 +417,40 @@ public function reservedSize(UnitEnum|string|null $queue = null): int
return $this->reservedJobs($queue)->count();
}
+ /**
+ * Get the number of jobs across every queue.
+ */
+ public function totalSize(): int
+ {
+ return $this->totalPendingSize()
+ + $this->totalDelayedSize()
+ + $this->totalReservedSize();
+ }
+
+ /**
+ * Get the number of pending jobs across every queue.
+ */
+ public function totalPendingSize(): int
+ {
+ return $this->allPendingJobs()->count();
+ }
+
+ /**
+ * Get the number of delayed jobs across every queue.
+ */
+ public function totalDelayedSize(): int
+ {
+ return $this->allDelayedJobs()->count();
+ }
+
+ /**
+ * Get the number of reserved jobs across every queue.
+ */
+ public function totalReservedSize(): int
+ {
+ return $this->allReservedJobs()->count();
+ }
+
/**
* Get the pending jobs for the given queue.
*/
@@ -685,9 +722,13 @@ public function pop(UnitEnum|string|null $queue = null): ?Job
public function bulk(array $jobs, mixed $data = '', UnitEnum|string|null $queue = null): mixed
{
foreach ($jobs as $job) {
- is_object($job) && isset($job->delay)
- ? $this->later($job->delay, $job, $data, $queue)
- : $this->push($job, $data, $queue);
+ $delay = is_object($job) ? $this->getAttributeValue($job, Delay::class, 'delay') : null;
+
+ if ($delay !== null) {
+ $this->later($delay, $job, $data, $queue);
+ } else {
+ $this->push($job, $data, $queue);
+ }
}
return null;
diff --git a/tests/Cache/Redis/RedisCacheTestCase.php b/tests/Cache/Redis/RedisCacheTestCase.php
index 7d1bf5ea4..8bf52b5f3 100644
--- a/tests/Cache/Redis/RedisCacheTestCase.php
+++ b/tests/Cache/Redis/RedisCacheTestCase.php
@@ -89,6 +89,9 @@ protected function mockConnection(): m\MockInterface|PhpRedisConnection
$connection = m::mock(PhpRedisConnection::class);
$connection->shouldReceive('release')->zeroOrMoreTimes();
+ $connection->shouldReceive('withoutScanPrefix')
+ ->andReturnUsing(fn (callable $callback): mixed => $callback())
+ ->byDefault();
$connection->shouldReceive('serialized')->andReturn(false)->byDefault();
$connection->shouldReceive('client')->andReturn($client)->byDefault();
$connection->shouldReceive('getOption')
@@ -104,7 +107,7 @@ protected function mockConnection(): m\MockInterface|PhpRedisConnection
$connection->shouldReceive('pipeline')->andReturn($connection)->byDefault();
$connection->shouldReceive('exec')->andReturn([])->byDefault();
- // Store client reference for backward compatibility during migration
+ // Expose the client for expectation setup.
$connection->_mockClient = $client;
return $connection;
@@ -134,6 +137,9 @@ protected function mockClusterConnection(): m\MockInterface|PhpRedisClusterConne
$connection = m::mock(PhpRedisClusterConnection::class);
$connection->shouldReceive('release')->zeroOrMoreTimes();
+ $connection->shouldReceive('withoutScanPrefix')
+ ->andReturnUsing(fn (callable $callback): mixed => $callback())
+ ->byDefault();
$connection->shouldReceive('serialized')->andReturn(false)->byDefault();
$connection->shouldReceive('client')->andReturn($client)->byDefault();
$connection->shouldReceive('getOption')
@@ -145,7 +151,7 @@ protected function mockClusterConnection(): m\MockInterface|PhpRedisClusterConne
->andReturn('')
->byDefault();
- // Store client reference for backward compatibility during migration
+ // Expose the client for expectation setup.
$connection->_mockClient = $client;
return $connection;
diff --git a/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php b/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php
index 59080da32..f723b470d 100644
--- a/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php
+++ b/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php
@@ -6,6 +6,8 @@
use Hypervel\Cache\TagMode;
use Hypervel\Support\Facades\Cache;
+use Hypervel\Testbench\Attributes\WithConfig;
+use Redis;
use Throwable;
/**
@@ -248,6 +250,8 @@ public function testFlushNonExistentTagGracefullyInAnyMode(): void
$this->assertSame('value', Cache::get('item'));
}
+ #[WithConfig('database.redis.options.prefix', 'scan-prefix:')]
+ #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)]
public function testFlushLargeTagSetInAllMode(): void
{
$this->setTagMode(TagMode::All);
@@ -271,25 +275,29 @@ public function testFlushLargeTagSetInAllMode(): void
}
}
+ #[WithConfig('database.redis.options.prefix', 'scan-prefix:')]
+ #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)]
public function testFlushLargeTagSetInAnyMode(): void
{
$this->setTagMode(TagMode::Any);
- // Create many items with the same tag
- for ($i = 0; $i < 100; ++$i) {
- Cache::tags(['bulk'])->put("item.{$i}", "value.{$i}", 60);
+ // Exceed the HSCAN threshold to exercise paged tag enumeration.
+ $values = [];
+ for ($i = 0; $i < 1001; ++$i) {
+ $values["item.{$i}"] = "value.{$i}";
}
+ Cache::tags(['bulk'])->putMany($values, 60);
// Verify some items exist
$this->assertSame('value.0', Cache::get('item.0'));
$this->assertSame('value.50', Cache::get('item.50'));
- $this->assertSame('value.99', Cache::get('item.99'));
+ $this->assertSame('value.1000', Cache::get('item.1000'));
// Flush all at once
Cache::tags(['bulk'])->flush();
// Verify all items are gone
- for ($i = 0; $i < 100; ++$i) {
+ for ($i = 0; $i < 1001; ++$i) {
$this->assertNull(Cache::get("item.{$i}"));
}
}
diff --git a/tests/Integration/Cache/Redis/PruneIntegrationTest.php b/tests/Integration/Cache/Redis/PruneIntegrationTest.php
index c26da5b67..347ed4d96 100644
--- a/tests/Integration/Cache/Redis/PruneIntegrationTest.php
+++ b/tests/Integration/Cache/Redis/PruneIntegrationTest.php
@@ -6,6 +6,8 @@
use Hypervel\Cache\TagMode;
use Hypervel\Support\Facades\Cache;
+use Hypervel\Testbench\Attributes\WithConfig;
+use Redis;
/**
* Integration tests for prune (cleanup) operations.
@@ -80,6 +82,8 @@ public function testAnyModeForgetRemovesTagMembership(): void
// ANY MODE - PRUNE COMMAND
// =========================================================================
+ #[WithConfig('database.redis.options.prefix', 'scan-prefix:')]
+ #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)]
public function testAnyModePruneRemovesOrphanedFields(): void
{
$this->setTagMode(TagMode::Any);
@@ -287,6 +291,8 @@ public function testAllModeFlushLeavesOrphanedEntriesInOtherTags(): void
// ALL MODE - PRUNE COMMAND
// =========================================================================
+ #[WithConfig('database.redis.options.prefix', 'scan-prefix:')]
+ #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)]
public function testAllModePruneRemovesOrphanedEntries(): void
{
$this->setTagMode(TagMode::All);
diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php
index 2e7a0cc90..8322ffa80 100644
--- a/tests/Integration/Queue/Redis/RedisQueueTest.php
+++ b/tests/Integration/Queue/Redis/RedisQueueTest.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Redis\Factory as RedisFactory;
use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis;
+use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\Events\JobPayloadFinalizing;
use Hypervel\Queue\Events\JobQueued;
use Hypervel\Queue\Events\JobQueueing;
@@ -26,6 +27,7 @@
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+use Redis as PhpRedis;
use RedisCluster;
use ReflectionMethod;
@@ -670,6 +672,8 @@ public function testAllPendingJobsReportExplicitHashTaggedNamesByTopology(): voi
$this->usingRedisCluster() ? 'orders' : '{orders}',
0,
);
+ $this->assertSame(1, $this->queue->totalSize());
+ $this->assertSame(1, $this->queue->totalPendingSize());
}
public function testAllDelayedJobs(): void
@@ -702,6 +706,178 @@ public function testAllReservedJobs(): void
$jobs->each(fn (InspectedJob $job) => $this->assertInspectedJob($job, $job->queue, 1));
}
+ public function testTotalSize(): void
+ {
+ $this->setQueue($this->defaultQueueName());
+
+ $this->queue->push(new RedisQueueIntegrationTestJob(1));
+ $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2));
+ $this->queue->later(60, new RedisQueueIntegrationTestJob(3));
+
+ $this->assertSame(3, $this->queue->totalSize());
+ }
+
+ public function testTotalPendingSize(): void
+ {
+ $this->setQueue($this->defaultQueueName());
+
+ $this->queue->push(new RedisQueueIntegrationTestJob(1));
+ $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2));
+
+ $this->assertSame(2, $this->queue->totalPendingSize());
+ }
+
+ public function testTotalDelayedSize(): void
+ {
+ $this->setQueue($this->defaultQueueName());
+
+ $this->queue->later(60, new RedisQueueIntegrationTestJob(1));
+ $this->queue->laterOn('emails', 60, new RedisQueueIntegrationTestJob(2));
+
+ $this->assertSame(2, $this->queue->totalDelayedSize());
+ }
+
+ public function testTotalReservedSize(): void
+ {
+ $this->setQueue($this->defaultQueueName());
+
+ $this->queue->push(new RedisQueueIntegrationTestJob(1));
+ $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2));
+ $this->queue->pop();
+ $this->queue->pop('emails');
+
+ $this->assertSame(2, $this->queue->totalReservedSize());
+ }
+
+ public function testBulkPushesAllJobsOntoQueue(): void
+ {
+ $this->setQueue('default');
+
+ $this->queue->bulk([
+ new RedisQueueIntegrationTestJob(1),
+ new RedisQueueIntegrationTestJob(2),
+ new RedisQueueIntegrationTestJob(3),
+ ], '', 'bulk-test');
+
+ $this->assertSame(3, $this->queue->size('bulk-test'));
+
+ $seen = [];
+
+ for ($i = 0; $i < 3; ++$i) {
+ $seen[] = unserialize(json_decode($this->queue->pop('bulk-test')->getRawBody())->data->command)->i;
+ }
+
+ sort($seen);
+
+ $this->assertSame([1, 2, 3], $seen);
+ $this->assertNull($this->queue->pop('bulk-test'));
+ }
+
+ public function testBulkPushesDelayedJobsOntoDelayedQueue(): void
+ {
+ $this->setQueue('default');
+
+ $this->queue->bulk([
+ new RedisQueueIntegrationTestJob(1),
+ new RedisQueueIntegrationTestDelayedJob(2),
+ ], '', 'bulk-delay');
+
+ $redisKey = $this->getQueueRedisKey('bulk-delay');
+
+ $this->assertSame(1, $this->redisConnection()->llen($redisKey));
+ $this->assertSame(1, $this->redisConnection()->zcard("{$redisKey}:delayed"));
+ }
+
+ public function testBulkPushesManyJobsOntoQueue(): void
+ {
+ $this->setQueue('default');
+
+ $jobs = [];
+
+ for ($i = 0; $i < 1050; ++$i) {
+ $jobs[] = new RedisQueueIntegrationTestJob($i);
+ }
+
+ $this->queue->bulk($jobs, '', 'bulk-many');
+
+ $redisKey = $this->getQueueRedisKey('bulk-many');
+
+ $this->assertSame(1050, $this->queue->size('bulk-many'));
+ $this->assertSame(1050, $this->redisConnection()->llen("{$redisKey}:notify"));
+ }
+
+ public function testAllQueueNamesReturnsQueuesAcrossMultipleQueues(): void
+ {
+ $default = $this->defaultQueueName();
+ $this->setQueue($default);
+
+ $this->queue->push(new RedisQueueIntegrationTestJob(1));
+ $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2));
+ $this->queue->pushOn('notifications', new RedisQueueIntegrationTestJob(3));
+
+ $names = (new ReflectionMethod($this->queue, 'allQueueNames'))
+ ->invoke($this->queue)
+ ->sort()
+ ->values()
+ ->all();
+
+ $this->assertSame([$default, 'emails', 'notifications'], $names);
+ }
+
+ #[DataProvider('scanRetryOptions')]
+ public function testScanningQueueNamesDoesNotDoublePrefixTheMatchPattern(bool $retryScan): void
+ {
+ $connectionName = $this->createRedisConnectionWithOptions('queue-scan-prefix', [
+ 'prefix' => 'test_',
+ 'scan' => PhpRedis::SCAN_PREFIX,
+ ]);
+ $this->setQueue('default', $connectionName);
+ $redis = Redis::connection($connectionName);
+
+ $redis->withPinnedConnection(function () use ($redis, $retryScan): void {
+ if ($retryScan) {
+ $redis->withConnection(function (RedisConnection $connection): void {
+ $connection->setOption(PhpRedis::OPT_SCAN, PhpRedis::SCAN_RETRY);
+ });
+ }
+
+ $this->queue->push(new RedisQueueIntegrationTestJob(1));
+
+ $this->assertSame(
+ ['default'],
+ (new ReflectionMethod($this->queue, 'allQueueNames'))->invoke($this->queue)->all(),
+ );
+ });
+ }
+
+ /**
+ * Provide the retry settings used alongside scan prefixing.
+ */
+ public static function scanRetryOptions(): array
+ {
+ return [
+ 'prefix only' => [false],
+ 'prefix and retry' => [true],
+ ];
+ }
+
+ public function testTotalSizesPreserveQueueNamesAcrossEveryState(): void
+ {
+ $this->setQueue();
+
+ foreach (['reports:high', '0'] as $name) {
+ $this->queue->pushOn($name, new RedisQueueIntegrationTestJob(1));
+ $this->queue->pop($name);
+ $this->queue->pushOn($name, new RedisQueueIntegrationTestJob(2));
+ $this->queue->laterOn($name, 60, new RedisQueueIntegrationTestJob(3));
+ }
+
+ $this->assertSame(6, $this->queue->totalSize());
+ $this->assertSame(2, $this->queue->totalPendingSize());
+ $this->assertSame(2, $this->queue->totalDelayedSize());
+ $this->assertSame(2, $this->queue->totalReservedSize());
+ }
+
public function testInvalidInspectedPayloadRetainsItsRedisRemovalMember(): void
{
$this->setQueue('poison');
@@ -772,6 +948,25 @@ public function handle(): void
}
}
+#[Delay(60)]
+class RedisQueueIntegrationTestDelayedJob
+{
+ /**
+ * Create a delayed test job.
+ */
+ public function __construct(
+ public int $i,
+ ) {
+ }
+
+ /**
+ * Handle the job.
+ */
+ public function handle(): void
+ {
+ }
+}
+
class RedisQueueIntegrationDelayedJob extends RedisQueueIntegrationTestJob
{
public function __construct(int $i, public int $delay)
diff --git a/tests/Integration/Redis/SafeScanIntegrationTest.php b/tests/Integration/Redis/SafeScanIntegrationTest.php
index 5bc1b4f45..6e9051b70 100644
--- a/tests/Integration/Redis/SafeScanIntegrationTest.php
+++ b/tests/Integration/Redis/SafeScanIntegrationTest.php
@@ -8,6 +8,8 @@
use Hypervel\Redis\RedisConnection;
use Hypervel\Support\Facades\Redis;
use Hypervel\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use Redis as PhpRedis;
/**
* Integration tests for SafeScan and FlushByPattern operations.
@@ -20,7 +22,8 @@ class SafeScanIntegrationTest extends TestCase
{
use InteractsWithRedis;
- public function testSafeScanYieldsKeysWithoutPrefix()
+ #[DataProvider('scanPrefixOptions')]
+ public function testSafeScanYieldsKeysWithoutPrefix(bool $prefixScan, bool $retryScan): void
{
$prefix = 'safescan_test:';
$connectionName = $this->createRedisConnectionWithPrefix($prefix);
@@ -33,8 +36,16 @@ public function testSafeScanYieldsKeysWithoutPrefix()
$redis->set('key3', 'val3');
// safeScan should yield keys WITHOUT the prefix
- $keys = $redis->withConnection(function (RedisConnection $connection) {
- return iterator_to_array($connection->safeScan('key*'));
+ $keys = $redis->withConnection(function (RedisConnection $connection) use ($prefix, $prefixScan, $retryScan): array {
+ $connection->setOption(PhpRedis::OPT_SCAN, $retryScan ? PhpRedis::SCAN_RETRY : PhpRedis::SCAN_NORETRY);
+ $connection->setOption(PhpRedis::OPT_SCAN, $prefixScan ? PhpRedis::SCAN_PREFIX : PhpRedis::SCAN_NOPREFIX);
+ $options = $connection->getOption(PhpRedis::OPT_SCAN);
+ $keys = iterator_to_array($connection->safeScan('key*'));
+
+ $this->assertEqualsCanonicalizing($keys, iterator_to_array($connection->safeScan($prefix . 'key*')));
+ $this->assertSame($options, $connection->getOption(PhpRedis::OPT_SCAN));
+
+ return $keys;
}, transform: false);
sort($keys);
@@ -45,6 +56,18 @@ public function testSafeScanYieldsKeysWithoutPrefix()
$this->assertSame('val1', $redis->get('key1'));
}
+ /**
+ * Provide native scan prefix and retry settings.
+ */
+ public static function scanPrefixOptions(): array
+ {
+ return [
+ 'no prefixing' => [false, false],
+ 'prefixing' => [true, false],
+ 'prefixing and retry' => [true, true],
+ ];
+ }
+
public function testSafeScanWithoutPrefix()
{
$connectionName = $this->createRedisConnectionWithPrefix('');
diff --git a/tests/Queue/FailoverQueueTest.php b/tests/Queue/FailoverQueueTest.php
index 11b057790..4bfc87fdb 100644
--- a/tests/Queue/FailoverQueueTest.php
+++ b/tests/Queue/FailoverQueueTest.php
@@ -537,7 +537,11 @@ public function testInspectionDelegatesToTheFirstConnection(): void
$redis = m::mock(RedisQueue::class);
$queue = new FailoverQueue($manager, $events, ['redis', 'sync']);
- $manager->shouldReceive('connection')->times(6)->with('redis')->andReturn($redis);
+ $manager->shouldReceive('connection')->times(10)->with('redis')->andReturn($redis);
+ $redis->shouldReceive('totalSize')->once()->withNoArgs()->andReturn(9);
+ $redis->shouldReceive('totalPendingSize')->once()->withNoArgs()->andReturn(2);
+ $redis->shouldReceive('totalDelayedSize')->once()->withNoArgs()->andReturn(3);
+ $redis->shouldReceive('totalReservedSize')->once()->withNoArgs()->andReturn(4);
$redis->shouldReceive('pendingJobs')->once()->with('emails')->andReturn($pending = new Collection(['pending']));
$redis->shouldReceive('delayedJobs')->once()->with('emails')->andReturn($delayed = new Collection(['delayed']));
$redis->shouldReceive('reservedJobs')->once()->with('emails')->andReturn($reserved = new Collection(['reserved']));
@@ -545,6 +549,10 @@ public function testInspectionDelegatesToTheFirstConnection(): void
$redis->shouldReceive('allDelayedJobs')->once()->andReturn($allDelayed = new Collection(['all-delayed']));
$redis->shouldReceive('allReservedJobs')->once()->andReturn($allReserved = new Collection(['all-reserved']));
+ $this->assertSame(9, $queue->totalSize());
+ $this->assertSame(2, $queue->totalPendingSize());
+ $this->assertSame(3, $queue->totalDelayedSize());
+ $this->assertSame(4, $queue->totalReservedSize());
$this->assertSame($pending, $queue->pendingJobs('emails'));
$this->assertSame($delayed, $queue->delayedJobs('emails'));
$this->assertSame($reserved, $queue->reservedJobs('emails'));
diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php
index 49a763bb5..122b4b940 100644
--- a/tests/Queue/QueueBeanstalkdQueueTest.php
+++ b/tests/Queue/QueueBeanstalkdQueueTest.php
@@ -20,6 +20,7 @@
use Pheanstalk\Contract\PheanstalkSubscriberInterface;
use Pheanstalk\Pheanstalk;
use Pheanstalk\Values\Job;
+use Pheanstalk\Values\ServerStats;
use Pheanstalk\Values\TubeList;
use Pheanstalk\Values\TubeName;
use Pheanstalk\Values\TubeStats;
@@ -73,6 +74,74 @@ public function testSizeIncludesPendingDelayedAndReservedJobsWithOneStatsRequest
$this->assertSame(12, $this->queue->size('stack'));
}
+ public function testTotalSizesUseOneServerStatsRequestPerCount(): void
+ {
+ $this->setQueue('default', 60);
+
+ $this->queue->getPheanstalk()
+ ->shouldReceive('stats')
+ ->times(4)
+ ->withNoArgs()
+ ->andReturn(new ServerStats(
+ currentJobsUrgent: 1,
+ currentJobsReady: 3,
+ currentJobsReserved: 5,
+ currentJobsDelayed: 4,
+ currentJobsBuried: 6,
+ cmdPut: 0,
+ cmdPeek: 0,
+ cmdPeekReady: 0,
+ cmdPeekDelayed: 0,
+ cmdReserveWithTimeout: 0,
+ cmdPeekBuried: 0,
+ cmdReserve: 0,
+ cmdUse: 0,
+ cmdWatch: 0,
+ cmdIgnore: 0,
+ cmdDelete: 0,
+ cmdRelease: 0,
+ cmdBury: 0,
+ cmdKick: 0,
+ cmdStats: 0,
+ cmdStatsJob: 0,
+ cmdStatsTube: 0,
+ cmdListTubes: 0,
+ cmdListTubeUsed: 0,
+ cmdListTubesWatched: 0,
+ cmdPauseTube: 0,
+ jobTimeouts: 0,
+ totalJobs: 18,
+ maxJobSize: 65535,
+ currentTubes: 2,
+ currentConnections: 1,
+ currentProducers: 0,
+ currentWorkers: 0,
+ currentWaiting: 0,
+ totalConnections: 1,
+ pid: 1,
+ version: '1.13',
+ rusageUtime: 0.0,
+ rusageStime: 0.0,
+ binlogOldestIndex: 0,
+ binlogCurrentIndex: 0,
+ binlogMaxSize: 0,
+ binlogRecordsWritten: 0,
+ draining: false,
+ id: 'test-server',
+ hostname: 'localhost',
+ os: 'Linux',
+ platform: 'x86_64',
+ cmdTouch: 0,
+ uptime: 0,
+ binlogRecordsMigrated: 0,
+ ));
+
+ $this->assertSame(12, $this->queue->totalSize());
+ $this->assertSame(3, $this->queue->totalPendingSize());
+ $this->assertSame(4, $this->queue->totalDelayedSize());
+ $this->assertSame(5, $this->queue->totalReservedSize());
+ }
+
public function testInspectionReturnsEmptyCollections(): void
{
$this->setQueue('default', 60);
diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php
index 1ef287161..85cd25777 100644
--- a/tests/Queue/QueueDatabaseQueueUnitTest.php
+++ b/tests/Queue/QueueDatabaseQueueUnitTest.php
@@ -365,8 +365,7 @@ public function testBulkTreatsAnExplicitFalseInsertAsFailure(): void
$connection->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(Builder::class));
$query->shouldReceive('insert')->once()->andReturnFalse();
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Unable to insert queued jobs into the database.');
+ $this->expectExceptionObject(new RuntimeException('Unable to insert queued jobs into the database.'));
$queue->bulk(['job']);
}
@@ -867,6 +866,71 @@ public function testAllReservedJobs(): void
$this->assertInspectedJob($jobs->last(), 'SecondReservedJob', 'emails', 2, 42);
}
+ public function testTotalSize(): void
+ {
+ $resolver = m::mock(ConnectionResolverInterface::class);
+ $database = m::mock(ConnectionInterface::class);
+ $resolver->expects('connection')->with(null)->andReturn($database);
+ $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704);
+ $queue->setContainer(m::spy(Container::class));
+
+ $query = m::mock(Builder::class);
+ $database->expects('table')->with('table')->andReturn($query);
+ $query->expects('count')->andReturn(9);
+
+ $this->assertSame(9, $queue->totalSize());
+ }
+
+ public function testTotalPendingSize(): void
+ {
+ $resolver = m::mock(ConnectionResolverInterface::class);
+ $database = m::mock(ConnectionInterface::class);
+ $resolver->expects('connection')->with(null)->andReturn($database);
+ $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704);
+ $queue->setContainer(m::spy(Container::class));
+
+ $query = m::mock(Builder::class);
+ $database->expects('table')->with('table')->andReturn($query);
+ $query->expects('whereNull')->with('reserved_at')->andReturnSelf();
+ $query->expects('where')->with('available_at', '<=', 1732502704)->andReturnSelf();
+ $query->expects('count')->andReturn(2);
+
+ $this->assertSame(2, $queue->totalPendingSize());
+ }
+
+ public function testTotalDelayedSize(): void
+ {
+ $resolver = m::mock(ConnectionResolverInterface::class);
+ $database = m::mock(ConnectionInterface::class);
+ $resolver->expects('connection')->with(null)->andReturn($database);
+ $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704);
+ $queue->setContainer(m::spy(Container::class));
+
+ $query = m::mock(Builder::class);
+ $database->expects('table')->with('table')->andReturn($query);
+ $query->expects('whereNull')->with('reserved_at')->andReturnSelf();
+ $query->expects('where')->with('available_at', '>', 1732502704)->andReturnSelf();
+ $query->expects('count')->andReturn(3);
+
+ $this->assertSame(3, $queue->totalDelayedSize());
+ }
+
+ public function testTotalReservedSize(): void
+ {
+ $resolver = m::mock(ConnectionResolverInterface::class);
+ $database = m::mock(ConnectionInterface::class);
+ $resolver->expects('connection')->with(null)->andReturn($database);
+ $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704);
+ $queue->setContainer(m::spy(Container::class));
+
+ $query = m::mock(Builder::class);
+ $database->expects('table')->with('table')->andReturn($query);
+ $query->expects('whereNotNull')->with('reserved_at')->andReturnSelf();
+ $query->expects('count')->andReturn(4);
+
+ $this->assertSame(4, $queue->totalReservedSize());
+ }
+
public function testInvalidInspectedPayloadIdentifiesItsQueueAndRecord(): void
{
[$queue, $query] = $this->createInspectionQueue();
diff --git a/tests/Queue/QueuePoolProxyTest.php b/tests/Queue/QueuePoolProxyTest.php
index 1419d1edc..4328d4ba0 100644
--- a/tests/Queue/QueuePoolProxyTest.php
+++ b/tests/Queue/QueuePoolProxyTest.php
@@ -59,6 +59,10 @@ public function testEnumeratedSynchronousSurfaceUsesBorrowScopedInvocation(): vo
$queue->shouldReceive('pendingSize')->once()->with('queue')->andReturn(2);
$queue->shouldReceive('delayedSize')->once()->with('queue')->andReturn(3);
$queue->shouldReceive('reservedSize')->once()->with('queue')->andReturn(4);
+ $queue->shouldReceive('totalSize')->once()->withNoArgs()->andReturn(18);
+ $queue->shouldReceive('totalPendingSize')->once()->withNoArgs()->andReturn(5);
+ $queue->shouldReceive('totalDelayedSize')->once()->withNoArgs()->andReturn(6);
+ $queue->shouldReceive('totalReservedSize')->once()->withNoArgs()->andReturn(7);
$queue->shouldReceive('pendingJobs')->once()->with('queue')->andReturn($pending = new Collection(['pending']));
$queue->shouldReceive('delayedJobs')->once()->with('queue')->andReturn($delayed = new Collection(['delayed']));
$queue->shouldReceive('reservedJobs')->once()->with('queue')->andReturn($reserved = new Collection(['reserved']));
@@ -78,6 +82,10 @@ public function testEnumeratedSynchronousSurfaceUsesBorrowScopedInvocation(): vo
$this->assertSame(2, $proxy->pendingSize('queue'));
$this->assertSame(3, $proxy->delayedSize('queue'));
$this->assertSame(4, $proxy->reservedSize('queue'));
+ $this->assertSame(18, $proxy->totalSize());
+ $this->assertSame(5, $proxy->totalPendingSize());
+ $this->assertSame(6, $proxy->totalDelayedSize());
+ $this->assertSame(7, $proxy->totalReservedSize());
$this->assertSame($pending, $proxy->pendingJobs('queue'));
$this->assertSame($delayed, $proxy->delayedJobs('queue'));
$this->assertSame($reserved, $proxy->reservedJobs('queue'));
diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php
index f6c0eb615..5df64a76b 100644
--- a/tests/Queue/QueueRedisQueueTest.php
+++ b/tests/Queue/QueueRedisQueueTest.php
@@ -24,14 +24,65 @@
use Hypervel\Queue\RedisQueue;
use Hypervel\Redis\RedisProxy;
use Hypervel\Support\CarbonImmutable;
+use Hypervel\Support\Collection;
use Hypervel\Support\Str;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Symfony\Component\Uid\Uuid;
class QueueRedisQueueTest extends TestCase
{
+ #[DataProvider('totalSizeMethods')]
+ public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $totalMethod, string $sizeMethod): void
+ {
+ $pinned = false;
+ $connection = m::mock(RedisProxy::class);
+ $connection->expects('withPinnedConnection')->andReturnUsing(function (callable $callback) use (&$pinned): int {
+ $pinned = true;
+
+ try {
+ return $callback();
+ } finally {
+ $pinned = false;
+ }
+ });
+ $redis = m::mock(Redis::class);
+ $redis->expects('connection')->with(null)->andReturn($connection);
+ $queue = m::mock(RedisQueue::class, [$redis, 'default'])
+ ->makePartial()
+ ->shouldAllowMockingProtectedMethods();
+ $queue->expects('allQueueNames')->andReturnUsing(function () use (&$pinned): Collection {
+ $this->assertTrue($pinned);
+
+ return new Collection(['emails', 'reports:high']);
+ });
+ $queue->shouldReceive($sizeMethod)->twice()->andReturnUsing(function (string $name) use (&$pinned): int {
+ $this->assertTrue($pinned);
+
+ return match ($name) {
+ 'emails' => 5,
+ 'reports:high' => 7,
+ };
+ });
+
+ $this->assertSame(12, $queue->{$totalMethod}());
+ }
+
+ /**
+ * Provide aggregate methods and their per-queue extension points.
+ */
+ public static function totalSizeMethods(): array
+ {
+ return [
+ 'all jobs' => ['totalSize', 'size'],
+ 'pending jobs' => ['totalPendingSize', 'pendingSize'],
+ 'delayed jobs' => ['totalDelayedSize', 'delayedSize'],
+ 'reserved jobs' => ['totalReservedSize', 'reservedSize'],
+ ];
+ }
+
public function testBulkUsesOneLuaCallAndHonorsJobDelays(): void
{
CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000'));
diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php
index cc1f59218..25d088862 100644
--- a/tests/Redis/RedisConnectionTest.php
+++ b/tests/Redis/RedisConnectionTest.php
@@ -2355,6 +2355,63 @@ public function testCompressedReturnsFalseWhenNoCompression(): void
$this->assertFalse($connection->compressed());
}
+ #[DataProvider('scanPrefixOptions')]
+ public function testWithoutScanPrefixPreservesOtherOptionsAndRestores(bool $retry, bool $prefix): void
+ {
+ $redis = new Redis;
+ $redis->setOption(Redis::OPT_PREFIX, 'app:');
+ $redis->setOption(Redis::OPT_SCAN, $retry ? Redis::SCAN_RETRY : Redis::SCAN_NORETRY);
+ $redis->setOption(Redis::OPT_SCAN, $prefix ? Redis::SCAN_PREFIX : Redis::SCAN_NOPREFIX);
+ $originalOptions = $redis->getOption(Redis::OPT_SCAN);
+ $connection = (new PhpRedisConnectionStub)->setActiveConnection($redis);
+
+ $result = $connection->withoutScanPrefix(function () use ($redis, $retry): string {
+ $this->assertSame($retry ? Redis::SCAN_RETRY : Redis::SCAN_NORETRY, $redis->getOption(Redis::OPT_SCAN));
+ $this->assertSame('app:', $redis->getOption(Redis::OPT_PREFIX));
+
+ return 'callback-result';
+ });
+
+ $this->assertSame('callback-result', $result);
+ $this->assertSame($originalOptions, $redis->getOption(Redis::OPT_SCAN));
+ }
+
+ /**
+ * Provide independent retry and prefix settings.
+ */
+ public static function scanPrefixOptions(): array
+ {
+ return [
+ 'neither' => [false, false],
+ 'retry' => [true, false],
+ 'prefix' => [false, true],
+ 'retry and prefix' => [true, true],
+ ];
+ }
+
+ public function testWithoutScanPrefixRestoresOptionsWhenCallbackThrows(): void
+ {
+ $redis = new Redis;
+ $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
+ $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_PREFIX);
+ $originalOptions = $redis->getOption(Redis::OPT_SCAN);
+ $connection = (new PhpRedisConnectionStub)->setActiveConnection($redis);
+ $failure = new RuntimeException('Callback failed');
+
+ try {
+ $connection->withoutScanPrefix(function () use ($redis, $failure): never {
+ $this->assertSame(Redis::SCAN_RETRY, $redis->getOption(Redis::OPT_SCAN));
+
+ throw $failure;
+ });
+ $this->fail('Expected the callback exception.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame($failure, $exception);
+ }
+
+ $this->assertSame($originalOptions, $redis->getOption(Redis::OPT_SCAN));
+ }
+
public function testWithoutSerializationOrCompressionDisablesSerializerAndRestores(): void
{
$connection = $this->mockRedisConnection();
diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php
index 4c51f1e5a..2feb4f866 100644
--- a/tests/Support/SupportTestingQueueFakeTest.php
+++ b/tests/Support/SupportTestingQueueFakeTest.php
@@ -14,6 +14,7 @@
use Hypervel\Contracts\Queue\Queue;
use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Foundation\Application;
+use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\CallQueuedClosure;
use Hypervel\Queue\Jobs\InspectedJob;
use Hypervel\Queue\QueueManager;
@@ -235,6 +236,31 @@ public function testAssertPushedUsingBulk(): void
$this->fake->assertPushed(JobStub::class, 2);
}
+ public function testBulkRespectsDelayAttribute(): void
+ {
+ $this->fake->bulk([
+ new JobWithDelayAttributeStub,
+ new JobStub,
+ ], ['foo' => 'bar'], 'redis');
+
+ $this->assertSame(1, $this->fake->delayedSize('redis'));
+ $this->fake->assertPushedOn('redis', JobWithDelayAttributeStub::class);
+ $this->fake->assertPushed(JobWithDelayAttributeStub::class, function (JobWithDelayAttributeStub $job, ?string $queue, mixed $data): bool {
+ return $queue === 'redis' && $data === ['foo' => 'bar'];
+ });
+ $this->fake->assertPushedOn('redis', JobStub::class);
+ }
+
+ public function testBulkRespectsRuntimeDelay(): void
+ {
+ $job = (new JobWithRuntimeDelayStub)->delay(30);
+
+ $this->fake->bulk([$job], '', 'redis');
+
+ $this->assertSame(1, $this->fake->delayedSize('redis'));
+ $this->fake->assertPushedOn('redis', JobWithRuntimeDelayStub::class);
+ }
+
public function testPushOnAndLaterOnAcceptUnitEnums(): void
{
$this->fake->pushOn(QueueNameEnumStub::Foo, $this->job);
@@ -641,6 +667,23 @@ public function testAllPendingJobs(): void
$this->assertTrue($pending->contains(fn ($job) => $job->name === JobToFakeStub::class));
}
+ public function testTotalSize(): void
+ {
+ $this->fake->push($this->job, '', 'foo');
+ $this->fake->later(10, new JobToFakeStub, '', 'bar');
+ $this->fake->reserve(new JobToFakeStub, 'baz');
+
+ $this->assertSame(3, $this->fake->totalSize());
+ }
+
+ public function testTotalPendingSize(): void
+ {
+ $this->fake->push($this->job, '', 'foo');
+ $this->fake->push(new JobToFakeStub, '', 'bar');
+
+ $this->assertSame(2, $this->fake->totalPendingSize());
+ }
+
public function testDelayedJobs(): void
{
$this->fake->later(10, $this->job, '', 'foo');
@@ -668,6 +711,14 @@ public function testAllDelayedJobs(): void
$this->assertTrue($delayed->contains(fn ($job) => $job->name === JobToFakeStub::class));
}
+ public function testTotalDelayedSize(): void
+ {
+ $this->fake->later(10, $this->job, '', 'foo');
+ $this->fake->later(10, new JobToFakeStub, '', 'bar');
+
+ $this->assertSame(2, $this->fake->totalDelayedSize());
+ }
+
public function testDelayedSize(): void
{
$this->fake->later(10, $this->job, '', 'foo');
@@ -695,6 +746,10 @@ public function testPendingDelayedAndReservedJobsAreDisjoint(): void
$this->assertSame(1, $this->fake->delayedSize('foo'));
$this->assertSame(1, $this->fake->reservedSize('foo'));
$this->assertSame(3, $this->fake->size('foo'));
+ $this->assertSame(1, $this->fake->totalPendingSize());
+ $this->assertSame(1, $this->fake->totalDelayedSize());
+ $this->assertSame(1, $this->fake->totalReservedSize());
+ $this->assertSame(3, $this->fake->totalSize());
$this->fake->assertCount(2);
}
@@ -756,6 +811,14 @@ public function testAllReservedJobs(): void
$this->assertTrue($reserved->contains(fn ($job) => $job->name === JobToFakeStub::class));
}
+ public function testTotalReservedSize(): void
+ {
+ $this->fake->reserve($this->job, 'foo');
+ $this->fake->reserve(new JobToFakeStub, 'bar');
+
+ $this->assertSame(2, $this->fake->totalReservedSize());
+ }
+
public function testReservedSize(): void
{
$this->fake->reserve($this->job, 'foo');
@@ -964,6 +1027,31 @@ public function handle(): void
}
}
+#[Delay(15)]
+class JobWithDelayAttributeStub
+{
+ use Queueable;
+
+ /**
+ * Handle the job.
+ */
+ public function handle(): void
+ {
+ }
+}
+
+class JobWithRuntimeDelayStub
+{
+ use Queueable;
+
+ /**
+ * Handle the job.
+ */
+ public function handle(): void
+ {
+ }
+}
+
class JobWithConnectionStub
{
use Queueable;
From ada8ba2d49c3d1bb3b458976de1c6c8c88d885db Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 10:17:34 +0000
Subject: [PATCH 13/22] Port application configuration cache memoization
Application::configurationIsCached() now honors the existing
config_loaded_from_cache binding and stores the first filesystem result.
This reflects the configuration actually loaded by bootstrap and avoids
repeated disk checks without adding another cache or invalidation mechanism.
Port Laravel PR https://github.com/laravel/framework/pull/57665 using the
current 13.x implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Preserve Hypervel's named container API and native boolean return type.
Verify bound true/false state and both filesystem memoization outcomes with
real applications and isolated files. Extend cached-state integration
coverage to assert the public helper agrees with the bootstrap flag.
Use the existing parallel temp-directory pattern and replace deprecated
exception expectations in the touched application tests.
WithCachedConfig test boots now take the environment bootstrap's cached
configuration early return and skip .env loading, matching Laravel and the
existing configuration documentation. Global test cleanup still clears
dotenv state between test methods; no environment-persistence assumption
or workaround is introduced.
Validation: Foundation suite (1412 tests), service providers, Testbench
cached state, config cache/clear commands, both changed test files,
full composer analyse, scoped composer lint:fix and git diff --check pass.
Reviewed and approved by claude-laravel-parity.
---
src/foundation/src/Application.php | 6 +-
.../Foundation/FoundationApplicationTest.php | 100 ++++++++++++------
.../Testing/WithCachedStateTest.php | 1 +
3 files changed, 73 insertions(+), 34 deletions(-)
diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php
index 5b4e89098..65455cf3c 100644
--- a/src/foundation/src/Application.php
+++ b/src/foundation/src/Application.php
@@ -659,7 +659,11 @@ public function environmentFilePath(): string
*/
public function configurationIsCached(): bool
{
- return is_file($this->getCachedConfigPath());
+ if ($this->bound('config_loaded_from_cache')) {
+ return (bool) $this->make('config_loaded_from_cache');
+ }
+
+ return $this->instance('config_loaded_from_cache', is_file($this->getCachedConfigPath()));
}
/**
diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php
index 008ef69a0..01c01137f 100644
--- a/tests/Foundation/FoundationApplicationTest.php
+++ b/tests/Foundation/FoundationApplicationTest.php
@@ -32,12 +32,18 @@ class FoundationApplicationTest extends TestCase
{
protected ?string $namespaceApplicationPath = null;
+ protected ?string $configurationApplicationPath = null;
+
protected function tearDown(): void
{
try {
if ($this->namespaceApplicationPath !== null) {
(new Filesystem)->deleteDirectory($this->namespaceApplicationPath);
}
+
+ if ($this->configurationApplicationPath !== null) {
+ (new Filesystem)->deleteDirectory($this->configurationApplicationPath);
+ }
} finally {
parent::tearDown();
}
@@ -523,8 +529,7 @@ public function testGetNamespaceRejectsMissingComposerFile(): void
{
$app = $this->makeNamespaceApplication(null);
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Unable to detect application namespace.');
+ $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.'));
$app->getNamespace();
}
@@ -535,8 +540,7 @@ public function testGetNamespaceRejectsUnreadableComposerPath(): void
unlink($this->namespaceApplicationPath . '/composer.json');
mkdir($this->namespaceApplicationPath . '/composer.json');
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Unable to detect application namespace.');
+ $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.'));
$app->getNamespace();
}
@@ -559,8 +563,7 @@ public function testGetNamespaceRejectsNonArrayComposerJson(): void
{
$app = $this->makeNamespaceApplication('null');
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Unable to detect application namespace.');
+ $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.'));
$app->getNamespace();
}
@@ -571,8 +574,7 @@ public function testGetNamespaceRejectsInvalidPsrFourMap(): void
'autoload' => ['psr-4' => 'app/'],
], JSON_THROW_ON_ERROR));
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Unable to detect application namespace.');
+ $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.'));
$app->getNamespace();
}
@@ -583,8 +585,7 @@ public function testGetNamespaceRejectsInvalidPsrFourPath(): void
'autoload' => ['psr-4' => ['App\\' => [123]]],
], JSON_THROW_ON_ERROR));
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Unable to detect application namespace.');
+ $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.'));
$app->getNamespace();
}
@@ -595,8 +596,7 @@ public function testGetNamespaceDoesNotMatchTwoMissingPaths(): void
'autoload' => ['psr-4' => ['App\\' => 'missing/']],
], JSON_THROW_ON_ERROR), createAppPath: false);
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Unable to detect application namespace.');
+ $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.'));
$app->getNamespace();
}
@@ -817,19 +817,17 @@ protected function assertExpectationCount(int $times): void
$this->assertSame($times, m::getContainer()->mockery_getExpectationCount());
}
- public function testAbortThrowsNotFoundHttpException()
+ public function testAbortThrowsNotFoundHttpException(): void
{
- $this->expectException(NotFoundHttpException::class);
- $this->expectExceptionMessage('Page was not found');
+ $this->expectExceptionObject(new NotFoundHttpException('Page was not found'));
$app = new Application;
$app->abort(404, 'Page was not found');
}
- public function testAbortThrowsHttpException()
+ public function testAbortThrowsHttpException(): void
{
- $this->expectException(HttpException::class);
- $this->expectExceptionMessage('Request is bad');
+ $this->expectExceptionObject(new HttpException(400, 'Request is bad'));
$app = new Application;
$app->abort(400, 'Request is bad');
@@ -859,30 +857,52 @@ public function testMethodAfterLoadingEnvironmentAddsClosure(): void
$this->assertArrayHasKey(0, $listeners);
}
- public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile()
+ public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile(): void
{
- $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid());
+ $app = $this->makeConfigurationApplication();
$this->assertFalse($app->configurationIsCached());
}
- public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists()
+ public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists(): void
{
- $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid();
- $cachePath = $basePath . '/bootstrap/cache/config.php';
+ $app = $this->makeConfigurationApplication();
+ file_put_contents($app->getCachedConfigPath(), 'assertTrue($app->configurationIsCached());
+ }
+
+ public function testConfigurationIsCachedUsesBoundState(): void
+ {
+ $app = $this->makeConfigurationApplication();
+ $app->instance('config_loaded_from_cache', true);
+
+ $this->assertTrue($app->configurationIsCached());
+
+ file_put_contents($app->getCachedConfigPath(), 'instance('config_loaded_from_cache', false);
+
+ $this->assertFalse($app->configurationIsCached());
+ }
+
+ public function testConfigurationIsCachedMemoizesFilesystemResult(): void
+ {
+ $app = $this->makeConfigurationApplication();
+ $cachePath = $app->getCachedConfigPath();
+
+ $this->assertFalse($app->configurationIsCached());
- mkdir(dirname($cachePath), 0755, true);
file_put_contents($cachePath, 'assertTrue($app->configurationIsCached());
- } finally {
- unlink($cachePath);
- rmdir(dirname($cachePath));
- rmdir(dirname($cachePath, 2));
- rmdir($basePath);
- }
+ $this->assertFalse($app->configurationIsCached());
+
+ $freshApp = new Application($this->configurationApplicationPath);
+
+ $this->assertTrue($freshApp->configurationIsCached());
+
+ unlink($cachePath);
+
+ $this->assertTrue($freshApp->configurationIsCached());
}
public function testRoutesAreCachedReturnsFalseWhenNoCacheFile()
@@ -956,6 +976,20 @@ public function testAddAbsoluteCachePathPrefixReturnsSelf()
$this->assertSame($app, $app->addAbsoluteCachePathPrefix('s3:'));
}
+ /**
+ * Create an application with an isolated configuration cache directory.
+ */
+ private function makeConfigurationApplication(): Application
+ {
+ $this->configurationApplicationPath = ParallelTesting::tempDir('FoundationApplicationConfigTest');
+
+ $files = new Filesystem;
+ $files->deleteDirectory($this->configurationApplicationPath);
+ $files->makeDirectory($this->configurationApplicationPath . '/bootstrap/cache', 0755, true);
+
+ return new Application($this->configurationApplicationPath);
+ }
+
private function makeNamespaceApplication(?string $composerContents, bool $createAppPath = true): Application
{
$this->namespaceApplicationPath = ParallelTesting::tempDir('FoundationApplicationNamespaceTest');
diff --git a/tests/Foundation/Testing/WithCachedStateTest.php b/tests/Foundation/Testing/WithCachedStateTest.php
index 1ec7b5c55..e6923f6ae 100644
--- a/tests/Foundation/Testing/WithCachedStateTest.php
+++ b/tests/Foundation/Testing/WithCachedStateTest.php
@@ -115,6 +115,7 @@ public function testCachedStateIsRearmedBeforeSuccessiveFoundationApplicationBoo
$second->runSetUp();
$this->assertTrue($second->configLoadedFromCache());
+ $this->assertTrue($second->application()->configurationIsCached());
$this->assertTrue($second->application()->routesAreCached());
$this->assertInstanceOf(CompiledRouteCollection::class, $second->routeCollection());
$this->assertNotNull($second->routeCollection()->getByName('cached-state'));
From 1732de045ae452d9386086f039dfc7694366fcf0 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 10:34:39 +0000
Subject: [PATCH 14/22] Honor configured application paths in app_path
app_path() always appended app to the base directory, ignoring the
application's useAppPath() configuration. Model suggestions, generator
namespace selection, pruning and provider publish destinations could
therefore use the wrong directory.
Delegate to Application::path() when the application is available, matching
Laravel's helper. Preserve Hypervel's pre-bootstrap BASE_PATH fallback and
its existing failure message using the same pattern as sibling path helpers.
No new state, cache or consumer-specific workaround is introduced.
Discovered while revalidating the complete model-discovery port from
https://github.com/laravel/framework/pull/57671 against Laravel 13.x source
01d008c9b5f32cb7c5e50a9a22273113d810b2a2. All six source changes in that PR
were already present; this fixes the shared Hypervel helper they consume.
Add real-application coverage for default/custom paths and suffixes, plus
the pre-bootstrap fallback with exception-safe container restoration.
Correct the pruning discovery test's false positive: its invalid class
names previously yielded no models and satisfied only negative assertions.
Exclude its database-dependent soft-delete fixture, which has dedicated
tests, and assert a real model and pruning count alongside every upstream
negative assertion. This also removes reliance on earlier tests' database
state. Modernize the touched file's deprecated exception expectation.
Validation: changed helper tests 41/75; pruning tests 10/27; affected
Foundation, Console and generator tests via ParaTest 2030/6350 (one skip);
SQLite model inspection 2/192; full composer analyse, scoped formatting,
missing-BASE_PATH failure probe and git diff --check pass.
Reviewed and approved by claude-laravel-parity. Tracks AI-026.
---
src/foundation/src/helpers.php | 8 ++++++-
tests/Database/PruneCommandTest.php | 20 ++++++++++++-----
tests/Foundation/FoundationHelpersTest.php | 25 ++++++++++++++++++++++
3 files changed, 47 insertions(+), 6 deletions(-)
diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php
index 4db3a3afc..a1e2fcb57 100644
--- a/src/foundation/src/helpers.php
+++ b/src/foundation/src/helpers.php
@@ -177,7 +177,13 @@ function app_id(): string
*/
function app_path(string $path = ''): string
{
- return join_paths(base_path('app'), $path);
+ if (! Container::getInstance()->has(Application::class)) {
+ return defined('BASE_PATH')
+ ? join_paths(BASE_PATH, 'app', $path)
+ : throw new RuntimeException('BASE_PATH constant is not defined.');
+ }
+
+ return app()->path($path);
}
}
diff --git a/tests/Database/PruneCommandTest.php b/tests/Database/PruneCommandTest.php
index 07d033445..7c8c027b1 100644
--- a/tests/Database/PruneCommandTest.php
+++ b/tests/Database/PruneCommandTest.php
@@ -49,10 +49,9 @@ protected function setUp(): void
$container->instance('env', 'development');
}
- public function testPrunableModelAndExceptWithEachOther()
+ public function testPrunableModelAndExceptWithEachOther(): void
{
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The --model and --except options cannot be combined.');
+ $this->expectExceptionObject(new InvalidArgumentException('The --model and --except options cannot be combined.'));
$this->artisan([
'--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class,
@@ -159,12 +158,23 @@ public function testNonPrunableTestWithATrait()
);
}
- public function testNonModelFilesAreIgnoredTest()
+ public function testNonModelFilesAreIgnoredTest(): void
{
- $output = $this->artisan(['--path' => 'Models']);
+ $output = $this->artisan([
+ '--path' => 'Models',
+ // The soft-delete fixture needs a database; its dedicated tests set one up.
+ '--except' => [Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords::class],
+ ]);
$output = $output->fetch();
+ $this->assertStringContainsString(
+ 'Hypervel\Tests\Database\Pruning\Models\PrunableTestModelWithPrunableRecords',
+ $output,
+ );
+
+ $this->assertStringContainsString('20 records', $output);
+
$this->assertStringNotContainsString(
'No prunable [Hypervel\Tests\Database\Pruning\Models\AbstractPrunableModel] records found.',
$output,
diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php
index c421ccd86..4174f9074 100644
--- a/tests/Foundation/FoundationHelpersTest.php
+++ b/tests/Foundation/FoundationHelpersTest.php
@@ -8,6 +8,7 @@
use Hypervel\Broadcasting\FakePendingBroadcast;
use Hypervel\Broadcasting\PendingBroadcast;
use Hypervel\Cache\CacheManager;
+use Hypervel\Container\Container;
use Hypervel\Context\RequestContext;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Support\Responsable;
@@ -45,6 +46,30 @@ enum UnitEnum
class FoundationHelpersTest extends TestCase
{
+ public function testAppPathUsesTheConfiguredApplicationDirectory(): void
+ {
+ $this->assertSame($this->app->basePath('app'), app_path());
+ $this->assertSame($this->app->basePath('app/Models'), app_path('Models'));
+
+ $path = $this->app->basePath('custom-app');
+ $this->app->useAppPath($path);
+
+ $this->assertSame($path, app_path());
+ $this->assertSame($path . '/Models', app_path('Models'));
+ }
+
+ public function testAppPathUsesBasePathBeforeApplicationBootstrap(): void
+ {
+ Container::setInstance(new Container);
+
+ try {
+ $this->assertSame(BASE_PATH . '/app', app_path());
+ $this->assertSame(BASE_PATH . '/app/Models', app_path('Models'));
+ } finally {
+ Container::setInstance($this->app);
+ }
+ }
+
public function testNowReturnsCarbonImmutableByDefault(): void
{
$result = now();
From 5fc76654702651774e09d8d155aa3efde95892ca Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 11:17:38 +0000
Subject: [PATCH 15/22] Port factory insertion fixes and preserve binary bulk
bindings
Complete the current Laravel factory insertion changes, including the
zero-count early return and test, generic model annotations, and current
hidden-attribute and array-cast assertions. Preserve the existing factory
relationship, callback, connection, and custom Eloquent builder behavior.
Fix two defects in the upstream insertion path: serializing made models can
omit visible-filtered attributes, and filling those values again applies
mutators twice. Prepare the made models directly, generate their unique IDs,
and merge one batch of timestamp defaults underneath their raw attributes.
Supplied timestamps, including null and values equal to model defaults,
remain intact. Retain virtual Eloquent insert dispatch for custom builders.
Share Hypervel's existing binary binding preparation with factories and all
three fill-and-insert methods. Without PDO LOB binding, PostgreSQL truncates
binary UUID values and SQLite binary lookups miss inserted rows. Expose the
existing Hypervel-owned helper without changing Laravel protected APIs or
the explicit BinaryParameter contract of raw query builder operations.
Add targeted regressions for setter and visibility corruption, timestamps,
unique IDs, custom builder dispatch and one-query insertion. Verify bulk
UUID/ULID writes and generated/supplied binary primary keys on SQLite and
PostgreSQL. Document the public factory insert behavior and binary support.
Upstream PRs:
https://github.com/laravel/framework/pull/57670
https://github.com/laravel/framework/pull/57600
https://github.com/laravel/framework/pull/57722
https://github.com/laravel/framework/pull/57794
https://github.com/laravel/framework/pull/59780
https://github.com/laravel/framework/pull/60911
Port source: Laravel 13.x 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Additional issue records: AI-027 and AI-028.
Validation: Database ParaTest 3507 tests; final factory file 78 tests;
binary integration 6 tests each on SQLite and PostgreSQL; full composer
analyse, scoped formatting, and git diff --check pass. Independently
reviewed and approved by claude-laravel-parity.
---
src/database/src/Eloquent/Builder.php | 8 +-
.../src/Eloquent/Concerns/HasAttributes.php | 2 +-
.../src/Eloquent/Factories/Factory.php | 33 +++-
src/docs/eloquent-factories.md | 8 +
src/docs/eloquent-mutators.md | 2 +-
.../Database/DatabaseEloquentFactoryTest.php | 159 +++++++++++++++++-
...atabaseEloquentAsBinaryIntegrationTest.php | 76 +++++++++
7 files changed, 267 insertions(+), 21 deletions(-)
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index 695973352..567f6c612 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -482,10 +482,10 @@ public function fillForInsert(array $values): array
$this->model->unguarded(function () use (&$values) {
foreach ($values as $key => $rowValues) {
- $values[$key] = tap(
- $this->newModelInstance($rowValues),
- fn ($model) => $model->setUniqueIds()
- )->getAttributes();
+ $model = $this->newModelInstance($rowValues);
+ $model->setUniqueIds();
+
+ $values[$key] = $model->prepareBinaryAttributesForDatabase($model->getAttributes());
}
});
diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php
index 9bf4a6587..fac63cc9d 100644
--- a/src/database/src/Eloquent/Concerns/HasAttributes.php
+++ b/src/database/src/Eloquent/Concerns/HasAttributes.php
@@ -1920,7 +1920,7 @@ private function isBinaryCast(?string $cast): bool
* @param array $attributes
* @return array
*/
- protected function prepareBinaryAttributesForDatabase(array $attributes): array
+ public function prepareBinaryAttributesForDatabase(array $attributes): array
{
$casts = $this->getCasts();
diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php
index 22fb5deb2..c17583d4d 100644
--- a/src/database/src/Eloquent/Factories/Factory.php
+++ b/src/database/src/Eloquent/Factories/Factory.php
@@ -323,7 +323,7 @@ public function lazy(array $attributes = [], ?Model $parent = null): Closure
/**
* Set the connection name on the results and store them.
*
- * @param \Hypervel\Support\Collection $results
+ * @param \Hypervel\Support\Collection $results
*/
protected function store(Collection $results): void
{
@@ -346,6 +346,8 @@ protected function store(Collection $results): void
/**
* Create the children for the given model.
+ *
+ * @param TModel $model
*/
protected function createChildren(Model $model): void
{
@@ -444,6 +446,10 @@ public function insert(array $attributes = [], ?Model $parent = null): void
? $made
: $this->newModel()->newCollection([$made]);
+ if ($madeCollection->isEmpty()) {
+ return;
+ }
+
$model = $madeCollection->first();
if (isset($this->connection)) {
@@ -452,12 +458,25 @@ public function insert(array $attributes = [], ?Model $parent = null): void
$query = $model->newQueryWithoutScopes();
- $query->fillAndInsert(
- $madeCollection->withoutAppends()
- ->setHidden([])
- ->map(static fn (Model $model) => $model->attributesToArray())
- ->all()
- );
+ $timestamps = [];
+
+ if ($model->usesTimestamps()) {
+ $timestamp = $model->freshTimestampString();
+
+ foreach (array_filter([$model->getCreatedAtColumn(), $model->getUpdatedAtColumn()]) as $column) {
+ $timestamps[$column] = $timestamp;
+ }
+ }
+
+ // These models have already applied their casts and mutators. Serializing or
+ // filling them again can drop attributes or transform stored values twice.
+ $values = $madeCollection->map(static function (Model $model) use ($timestamps): array {
+ $model->setUniqueIds();
+
+ return array_merge($timestamps, $model->prepareBinaryAttributesForDatabase($model->getAttributes()));
+ })->all();
+
+ $query->insert($values);
}
/**
diff --git a/src/docs/eloquent-factories.md b/src/docs/eloquent-factories.md
index 611d6030c..d5cd8e8ce 100644
--- a/src/docs/eloquent-factories.md
+++ b/src/docs/eloquent-factories.md
@@ -302,6 +302,14 @@ $user = User::factory()->create([
]);
```
+To insert multiple models in a single query, you may use the `insert` method:
+
+```php
+User::factory()->count(100)->insert();
+```
+
+The `insert` method applies attribute casts, generates unique IDs, and adds timestamps. It does not return model instances, dispatch model events, run `afterCreating` callbacks, or create child relationships.
+
### Sequences
diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md
index ce49dc2ac..d49afced7 100644
--- a/src/docs/eloquent-mutators.md
+++ b/src/docs/eloquent-mutators.md
@@ -626,7 +626,7 @@ return $user->uuid;
// "6e8cdeed-2f32-40bd-b109-1e4405be2140"
```
-Normal model saves apply the cast automatically. Direct query builder operations, including `where`, bulk `update`, and `upsert` calls, do not apply model casts. When those operations receive already-encoded binary strings, wrap them in a [binary parameter](/docs/{{version}}/database#binding-binary-values).
+Model saves, `fillAndInsert`, `fillAndInsertOrIgnore`, `fillAndInsertGetId`, and [factory inserts](/docs/{{version}}/eloquent-factories#persisting-models) apply the cast automatically. Direct query builder operations, including `where`, bulk `update`, and `upsert` calls, do not apply model casts. When those operations receive already-encoded binary strings, wrap them in a [binary parameter](/docs/{{version}}/database#binding-binary-values).
A non-incrementing primary key may also use `AsBinary` for model-instance writes and reloads. Lookups from already-encoded key bytes must use a binary parameter with `find`, `whereKey`, or `whereKeyNot`. Canonical UUID / ULID text passed directly to `find`, route or queue model binding, and relationship queries is not encoded automatically.
diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php
index 18b2991d5..6e3a793fd 100644
--- a/tests/Database/DatabaseEloquentFactoryTest.php
+++ b/tests/Database/DatabaseEloquentFactoryTest.php
@@ -5,13 +5,17 @@
namespace Hypervel\Tests\Database\DatabaseEloquentFactoryTest;
use BadMethodCallException;
+use Faker\Factory as FakerFactory;
use Faker\Generator;
use Hypervel\Container\Container;
use Hypervel\Contracts\Foundation\Application;
use Hypervel\Database\Capsule\Manager as DB;
+use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder;
use Hypervel\Database\Eloquent\Attributes\UseFactory;
+use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Casts\Attribute;
use Hypervel\Database\Eloquent\Collection;
+use Hypervel\Database\Eloquent\Concerns\HasUuids;
use Hypervel\Database\Eloquent\Factories\Attributes\UseModel;
use Hypervel\Database\Eloquent\Factories\CrossJoinSequence;
use Hypervel\Database\Eloquent\Factories\Factory;
@@ -20,6 +24,7 @@
use Hypervel\Database\Eloquent\Model as Eloquent;
use Hypervel\Database\Eloquent\Relations\Pivot;
use Hypervel\Database\Eloquent\SoftDeletes;
+use Hypervel\Database\Schema\Blueprint;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Str;
use Hypervel\Tests\Database\Fixtures\Models\Money\Price;
@@ -31,9 +36,11 @@ class DatabaseEloquentFactoryTest extends TestCase
{
protected function setUp(): void
{
+ parent::setUp();
+
$container = Container::getInstance();
$container->singleton(Generator::class, function ($app, $parameters) {
- return \Faker\Factory::create('en_US');
+ return FakerFactory::create('en_US');
});
$container->instance(Application::class, $app = m::mock(Application::class));
$app->shouldReceive('getNamespace')->andReturn('App\\');
@@ -1155,7 +1162,7 @@ public function testFactoryModelMorphManyRelationshipHasPendingAttributesOverrid
$this->assertEquals('other body', Comment::first()->body);
}
- public function testFactoryCanInsert()
+ public function testFactoryCanInsert(): void
{
(new PostFactory)
->count(5)
@@ -1167,24 +1174,31 @@ public function testFactoryCanInsert()
->insert();
$this->assertCount(5, $posts = Post::query()->where('title', 'hello')->get());
$this->assertEquals(strtoupper($posts[0]->user->name), $posts[0]->upper_case_name);
- $this->assertEquals(
+ $this->assertCount(
2,
- ($users = User::query()->get())->count()
+ $users = User::query()->get()
);
$this->assertCount(1, $users->where('name', 'totwell'));
$this->assertCount(1, $users->where('name', 'shaedrich'));
}
- public function testFactoryCanInsertWithHidden()
+ public function testFactoryCanInsertZeroModels(): void
+ {
+ (new PostFactory)->count(0)->insert();
+
+ $this->assertCount(0, Post::all());
+ }
+
+ public function testFactoryCanInsertWithHidden(): void
{
(new UserFactory)->forEachSequence(['name' => Name::Taylor, 'options' => 'abc'])->insert();
$user = DB::table('users')->sole();
- $this->assertEquals('abc', $user->options);
+ $this->assertSame('abc', $user->options);
$userModel = User::query()->sole();
- $this->assertEquals('abc', $userModel->options);
+ $this->assertSame('abc', $userModel->options);
}
- public function testFactoryCanInsertWithArrayCasts()
+ public function testFactoryCanInsertWithArrayCasts(): void
{
(new UserWithArrayFactory)->count(2)->insert();
$users = DB::table('users')->get();
@@ -1196,6 +1210,80 @@ public function testFactoryCanInsertWithArrayCasts()
}
}
+ public function testFactoryInsertDoesNotApplyMutatorsTwice(): void
+ {
+ (new UserFactory)->useModel(UserWithMutator::class)->insert(['name' => 'Taylor']);
+
+ $user = DB::table('users')->sole();
+
+ $this->assertSame('prefix:Taylor', $user->name);
+ $this->assertNull($user->created_at);
+ $this->assertNull($user->updated_at);
+ }
+
+ public function testFactoryInsertPreservesAttributesExcludedFromSerialization(): void
+ {
+ (new UserFactory)
+ ->afterMaking(fn (User $user) => $user->setVisible(['options']))
+ ->insert(['name' => 'Taylor', 'options' => 'abc']);
+
+ $user = DB::table('users')->sole();
+
+ $this->assertSame('Taylor', $user->name);
+ $this->assertSame('abc', $user->options);
+ }
+
+ public function testFactoryInsertGeneratesUniqueIdsAndPreservesTimestampValues(): void
+ {
+ $this->schema()->table('users', function (Blueprint $table): void {
+ $table->timestamp('joined_at')->nullable();
+ $table->timestamp('changed_at')->nullable();
+ });
+
+ CarbonImmutable::setTestNow('2026-09-06 12:00:00');
+ $suppliedId = '11111111-0000-7000-8000-000000000000';
+ $factory = (new UserFactory)->useModel(UserWithUniqueIds::class);
+
+ $factory->forEachSequence(
+ ['name' => 'generated'],
+ ['name' => 'supplied', 'options' => $suppliedId, 'joined_at' => '2020-01-01 00:00:00', 'changed_at' => null],
+ ['name' => 'another generated'],
+ )->insert();
+
+ $users = DB::table('users')->get()->keyBy('name');
+
+ $this->assertCount(3, $users);
+ foreach (['generated', 'another generated'] as $name) {
+ $this->assertTrue(Str::isUuid($users[$name]->options));
+ $this->assertSame('2026-09-06 12:00:00', $users[$name]->joined_at);
+ $this->assertSame($users[$name]->joined_at, $users[$name]->changed_at);
+ }
+ $this->assertNotSame($users['generated']->options, $users['another generated']->options);
+ $this->assertSame($suppliedId, $users['supplied']->options);
+ $this->assertSame('2020-01-01 00:00:00', $users['supplied']->joined_at);
+ $this->assertNull($users['supplied']->changed_at);
+
+ // Values equal to model defaults are clean, but bulk insertion must retain them.
+ $factory->useModel(UserWithTimestampDefaults::class)->insert([
+ 'name' => 'defaults', 'joined_at' => null, 'changed_at' => '2020-01-01 00:00:00',
+ ]);
+
+ $defaults = DB::table('users')->where('name', 'defaults')->sole();
+
+ $this->assertNull($defaults->joined_at);
+ $this->assertSame('2020-01-01 00:00:00', $defaults->changed_at);
+ }
+
+ public function testFactoryInsertUsesTheCustomEloquentBuilder(): void
+ {
+ DB::enableQueryLog();
+
+ (new UserFactory)->useModel(UserWithInsertBuilder::class)->count(2)->insert();
+
+ $this->assertCount(1, DB::getQueryLog());
+ $this->assertSame(['custom builder', 'custom builder'], DB::table('users')->pluck('options')->all());
+ }
+
/**
* Get a database connection instance.
*
@@ -1503,6 +1591,61 @@ public function definition(): array
}
}
+class UserWithMutator extends User
+{
+ public bool $timestamps = false;
+
+ /**
+ * Prefix the stored name.
+ */
+ protected function name(): Attribute
+ {
+ return Attribute::set(fn (string $value): string => 'prefix:' . $value);
+ }
+}
+
+class UserWithUniqueIds extends User
+{
+ use HasUuids;
+
+ public const ?string CREATED_AT = 'joined_at';
+
+ public const ?string UPDATED_AT = 'changed_at';
+
+ /**
+ * Get the columns that should receive a unique identifier.
+ */
+ public function uniqueIds(): array
+ {
+ return ['options'];
+ }
+}
+
+class UserWithTimestampDefaults extends UserWithUniqueIds
+{
+ protected array $attributes = ['joined_at' => null, 'changed_at' => '2020-01-01 00:00:00'];
+}
+
+#[UseEloquentBuilder(FactoryInsertBuilder::class)]
+class UserWithInsertBuilder extends User
+{
+}
+
+class FactoryInsertBuilder extends Builder
+{
+ /**
+ * Apply custom attributes to the inserted rows.
+ */
+ public function insert(array $values): bool
+ {
+ foreach ($values as &$row) {
+ $row['options'] = 'custom builder';
+ }
+
+ return $this->toBase()->insert($values);
+ }
+}
+
class UserWithCallbacksFactory extends Factory
{
protected ?string $model = User::class;
diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
index 870bd9180..64b2cb1b2 100644
--- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
+++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
@@ -6,12 +6,16 @@
use Hypervel\Database\BinaryParameter;
use Hypervel\Database\Eloquent\Casts\AsBinary;
+use Hypervel\Database\Eloquent\Concerns\HasUuids;
+use Hypervel\Database\Eloquent\Factories\Factory;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Foundation\Testing\RefreshDatabase;
use Hypervel\Support\Facades\Schema;
+use Hypervel\Support\Str;
use Hypervel\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
@@ -148,6 +152,60 @@ public function testBinaryIdentifiersRoundTripAcrossModelAndQueryBuilderWritePat
$this->assertSame($upsertUlid, $upserted->ulid);
}
+ #[DataProvider('fillAndInsertMethods')]
+ public function testBinaryIdentifiersRoundTripThroughFillAndInsert(string $method): void
+ {
+ $uuid = '00ff7f80-4048-43c2-b80b-40491d165946';
+ $ulid = '2WBHE5RQ2WBHE5RQ2WBHE5RQ2W';
+ $attributes = ['uuid' => $uuid, 'ulid' => $ulid];
+
+ AsBinaryIdentifierModel::query()->{$method}(
+ $method === 'fillAndInsertGetId' ? $attributes : [$attributes]
+ );
+
+ $model = AsBinaryIdentifierModel::query()
+ ->where('uuid', new BinaryParameter(Uuid::fromString($uuid)->toBinary()))
+ ->where('ulid', new BinaryParameter(Ulid::fromString($ulid)->toBinary()))
+ ->sole();
+
+ $this->assertSame($uuid, $model->uuid);
+ $this->assertSame($ulid, $model->ulid);
+ }
+
+ /**
+ * Provide the insert methods that prepare model attributes.
+ */
+ public static function fillAndInsertMethods(): array
+ {
+ return [
+ 'insert' => ['fillAndInsert'],
+ 'insert or ignore' => ['fillAndInsertOrIgnore'],
+ 'insert and get ID' => ['fillAndInsertGetId'],
+ ];
+ }
+
+ public function testFactoryInsertPreparesGeneratedAndSuppliedBinaryPrimaryKeys(): void
+ {
+ $suppliedId = '00ff7f80-4048-43c2-b80b-40491d165946';
+
+ (new AsBinaryPrimaryKeyFactory)->forEachSequence(
+ ['name' => 'generated'],
+ ['name' => 'supplied', 'id' => $suppliedId],
+ )->insert();
+
+ $models = AsBinaryFactoryPrimaryKeyModel::query()->get()->keyBy('name');
+
+ $this->assertCount(2, $models);
+ $this->assertTrue(Str::isUuid($models['generated']->id));
+ $this->assertSame($suppliedId, $models['supplied']->id);
+
+ foreach ($models as $name => $model) {
+ $key = new BinaryParameter(Uuid::fromString($model->id)->toBinary());
+
+ $this->assertSame($name, AsBinaryFactoryPrimaryKeyModel::query()->whereKey($key)->sole()->name);
+ }
+ }
+
public function testBinaryPrimaryKeysPreserveBindingIntentAcrossModelOperations(): void
{
$primaryId = '21107c1e-6448-43c2-b80b-40491d165946';
@@ -297,6 +355,24 @@ protected function casts(): array
}
}
+class AsBinaryFactoryPrimaryKeyModel extends AsBinaryPrimaryKeyModel
+{
+ use HasUuids;
+}
+
+class AsBinaryPrimaryKeyFactory extends Factory
+{
+ protected ?string $model = AsBinaryFactoryPrimaryKeyModel::class;
+
+ /**
+ * Define the model's default state.
+ */
+ public function definition(): array
+ {
+ return [];
+ }
+}
+
class SoftDeletingAsBinaryPrimaryKeyModel extends AsBinaryPrimaryKeyModel
{
use SoftDeletes;
From 3454fa0faeed8624c0735754dd226b9271dc690e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 11:38:44 +0000
Subject: [PATCH 16/22] Port cached-route detection memoization and test parity
Remember the route-cache filesystem result on the application so repeated
bootstrap consumers use the same cached state. Preserve explicit true and
false bindings and Hypervel's native is_file() check, subprocess compilation,
and cached-route loading lifecycle.
Port the upstream bound-state test and cover both memoized outcomes across
filesystem changes and fresh application instances. Retain existing real-file
and path coverage, and share the isolated cache-directory fixture between
configuration and route tests with exception-safe cleanup.
Upstream:
https://github.com/laravel/framework/pull/57623
https://github.com/laravel/framework/pull/57687
Porting source: Laravel 13.x at
01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Related cached-config, nullable-state, cleanup, and parallel-database
follow-ups (#57663, #57708, #57785, #57826) were fully investigated and are
already covered by Hypervel's existing implementations and tests.
Validation: Application PHPUnit 60 tests / 197 assertions; affected
cached-state, route compilation/loading/reload, and parallel-database
ParaTest coverage 78 tests / 295 assertions. Full composer analyse passes
for source and type fixtures; scoped formatting and git diff --check pass.
Self-reviewed and approved by claude-laravel-parity.
---
src/foundation/src/Application.php | 2 +-
.../Foundation/FoundationApplicationTest.php | 80 ++++++++++++-------
2 files changed, 52 insertions(+), 30 deletions(-)
diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php
index 65455cf3c..18a8119bf 100644
--- a/src/foundation/src/Application.php
+++ b/src/foundation/src/Application.php
@@ -691,7 +691,7 @@ public function routesAreCached(): bool
return (bool) $this->make('routes.cached');
}
- return is_file($this->getCachedRoutesPath());
+ return $this->instance('routes.cached', is_file($this->getCachedRoutesPath()));
}
/**
diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php
index 01c01137f..a21293d58 100644
--- a/tests/Foundation/FoundationApplicationTest.php
+++ b/tests/Foundation/FoundationApplicationTest.php
@@ -32,7 +32,7 @@ class FoundationApplicationTest extends TestCase
{
protected ?string $namespaceApplicationPath = null;
- protected ?string $configurationApplicationPath = null;
+ protected ?string $cacheApplicationPath = null;
protected function tearDown(): void
{
@@ -41,8 +41,8 @@ protected function tearDown(): void
(new Filesystem)->deleteDirectory($this->namespaceApplicationPath);
}
- if ($this->configurationApplicationPath !== null) {
- (new Filesystem)->deleteDirectory($this->configurationApplicationPath);
+ if ($this->cacheApplicationPath !== null) {
+ (new Filesystem)->deleteDirectory($this->cacheApplicationPath);
}
} finally {
parent::tearDown();
@@ -859,14 +859,14 @@ public function testMethodAfterLoadingEnvironmentAddsClosure(): void
public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile(): void
{
- $app = $this->makeConfigurationApplication();
+ $app = $this->makeCacheApplication();
$this->assertFalse($app->configurationIsCached());
}
public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists(): void
{
- $app = $this->makeConfigurationApplication();
+ $app = $this->makeCacheApplication();
file_put_contents($app->getCachedConfigPath(), 'assertTrue($app->configurationIsCached());
@@ -874,7 +874,7 @@ public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists(): void
public function testConfigurationIsCachedUsesBoundState(): void
{
- $app = $this->makeConfigurationApplication();
+ $app = $this->makeCacheApplication();
$app->instance('config_loaded_from_cache', true);
$this->assertTrue($app->configurationIsCached());
@@ -887,7 +887,7 @@ public function testConfigurationIsCachedUsesBoundState(): void
public function testConfigurationIsCachedMemoizesFilesystemResult(): void
{
- $app = $this->makeConfigurationApplication();
+ $app = $this->makeCacheApplication();
$cachePath = $app->getCachedConfigPath();
$this->assertFalse($app->configurationIsCached());
@@ -896,7 +896,7 @@ public function testConfigurationIsCachedMemoizesFilesystemResult(): void
$this->assertFalse($app->configurationIsCached());
- $freshApp = new Application($this->configurationApplicationPath);
+ $freshApp = new Application($this->cacheApplicationPath);
$this->assertTrue($freshApp->configurationIsCached());
@@ -905,30 +905,52 @@ public function testConfigurationIsCachedMemoizesFilesystemResult(): void
$this->assertTrue($freshApp->configurationIsCached());
}
- public function testRoutesAreCachedReturnsFalseWhenNoCacheFile()
+ public function testRoutesAreCachedReturnsFalseWhenNoCacheFile(): void
{
- $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid());
+ $app = $this->makeCacheApplication();
$this->assertFalse($app->routesAreCached());
}
- public function testRoutesAreCachedReturnsTrueWhenCacheFileExists()
+ public function testRoutesAreCachedReturnsTrueWhenCacheFileExists(): void
{
- $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid();
- $cachePath = $basePath . '/bootstrap/cache/routes-v7.php';
+ $app = $this->makeCacheApplication();
+ file_put_contents($this->cacheApplicationPath . '/bootstrap/cache/routes-v7.php', 'assertTrue($app->routesAreCached());
+ }
+
+ public function testRoutesAreCachedUsesBoundState(): void
+ {
+ $app = $this->makeCacheApplication();
+ $app->instance('routes.cached', true);
+
+ $this->assertTrue($app->routesAreCached());
+
+ file_put_contents($app->getCachedRoutesPath(), 'instance('routes.cached', false);
+
+ $this->assertFalse($app->routesAreCached());
+ }
+
+ public function testRoutesAreCachedMemoizesFilesystemResult(): void
+ {
+ $app = $this->makeCacheApplication();
+ $cachePath = $app->getCachedRoutesPath();
+
+ $this->assertFalse($app->routesAreCached());
- mkdir(dirname($cachePath), 0755, true);
file_put_contents($cachePath, 'assertTrue($app->routesAreCached());
- } finally {
- unlink($cachePath);
- rmdir(dirname($cachePath));
- rmdir(dirname($cachePath, 2));
- rmdir($basePath);
- }
+ $this->assertFalse($app->routesAreCached());
+
+ $freshApp = new Application($this->cacheApplicationPath);
+
+ $this->assertTrue($freshApp->routesAreCached());
+
+ unlink($cachePath);
+
+ $this->assertTrue($freshApp->routesAreCached());
}
public function testEventsAreCachedReturnsFalseWhenNoCacheFile()
@@ -977,17 +999,17 @@ public function testAddAbsoluteCachePathPrefixReturnsSelf()
}
/**
- * Create an application with an isolated configuration cache directory.
+ * Create an application with an isolated cache directory.
*/
- private function makeConfigurationApplication(): Application
+ private function makeCacheApplication(): Application
{
- $this->configurationApplicationPath = ParallelTesting::tempDir('FoundationApplicationConfigTest');
+ $this->cacheApplicationPath = ParallelTesting::tempDir('FoundationApplicationCacheTest');
$files = new Filesystem;
- $files->deleteDirectory($this->configurationApplicationPath);
- $files->makeDirectory($this->configurationApplicationPath . '/bootstrap/cache', 0755, true);
+ $files->deleteDirectory($this->cacheApplicationPath);
+ $files->makeDirectory($this->cacheApplicationPath . '/bootstrap/cache', 0755, true);
- return new Application($this->configurationApplicationPath);
+ return new Application($this->cacheApplicationPath);
}
private function makeNamespaceApplication(?string $composerContents, bool $createAppPath = true): Application
From 37a3576e96a12718a65686d8b103ff5523559dba Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 11:56:59 +0000
Subject: [PATCH 17/22] Complete ucwords separator parity and handle empty
separators
Reconcile Laravel's original Unicode ucwords helper and its fluent
separator argument against current 13.x source. Both APIs and all six
upstream assertions per API are already present in Hypervel.
Correct the shared upstream defect where an empty separator string builds
an invalid regex character class. Delegate this supported no-delimiter
case to the existing UTF-8 ucfirst helper, capitalizing only the first
character without adding state or changing the normal separator path.
Extend the existing static and fluent tests with Unicode-sentence and
empty-input regressions. Add concise custom-separator examples to both
public documentation sections after checking the current Laravel docs.
Upstream PRs:
https://github.com/laravel/framework/pull/57581
https://github.com/laravel/framework/pull/57688
Source: laravel/framework 13.x at
01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Validation: both changed PHPUnit classes pass (354 tests, 1951 assertions);
Support ParaTest passes (2630 tests, 8568 assertions); full PHPStan source
and type checks, scoped formatting and git diff checks pass. Self-reviewed
and approved by claude-laravel-parity. Tracks parity issue AI-029.
---
src/docs/strings.md | 16 ++++++++++++++++
src/support/src/Str.php | 4 ++++
tests/Support/SupportStrTest.php | 2 ++
tests/Support/SupportStringableTest.php | 4 +++-
4 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/src/docs/strings.md b/src/docs/strings.md
index 618ca9735..30483766e 100644
--- a/src/docs/strings.md
+++ b/src/docs/strings.md
@@ -1943,6 +1943,14 @@ $string = Str::ucwords('hypervel framework');
// Hypervel Framework
```
+You may pass custom word separators as the second argument:
+
+```php
+$string = Str::ucwords('hypervel-framework', '-');
+
+// Hypervel-Framework
+```
+
#### `Str::upper()` {.collection-method}
@@ -4087,6 +4095,14 @@ $string = Str::of('hypervel framework')->ucwords();
// Hypervel Framework
```
+You may pass custom word separators to the method:
+
+```php
+$string = Str::of('hypervel-framework')->ucwords('-');
+
+// Hypervel-Framework
+```
+
#### `unwrap` {.collection-method}
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index a0b10ccbf..d4a5be695 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -1608,6 +1608,10 @@ public static function ucfirst(string $string): string
*/
public static function ucwords(string $string, string $separators = " \t\r\n\f\v"): string
{
+ if ($separators === '') {
+ return static::ucfirst($string);
+ }
+
$pattern = '/(^|[' . preg_quote($separators, '/') . '])(\p{Ll})/u';
return preg_replace_callback($pattern, function ($matches) {
diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php
index 9d28fbc1b..e8f702906 100644
--- a/tests/Support/SupportStrTest.php
+++ b/tests/Support/SupportStrTest.php
@@ -1643,6 +1643,8 @@ public function testUcwords(): void
$this->assertSame('Мама', Str::ucwords('мама'));
$this->assertSame('Мама Мыла Раму', Str::ucwords('мама мыла раму'));
$this->assertSame('JJ Watt', Str::ucwords('JJ watt'));
+ $this->assertSame('Мама мыла раму', Str::ucwords('мама мыла раму', ''));
+ $this->assertSame('', Str::ucwords('', ''));
}
public function testUcsplit(): void
diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php
index 63d56d781..4ee600a3e 100644
--- a/tests/Support/SupportStringableTest.php
+++ b/tests/Support/SupportStringableTest.php
@@ -218,7 +218,7 @@ public function testCanBeLimitedByWords()
$this->assertSame('Taylor Otwell', (string) $this->stringable('Taylor Otwell')->words(3));
}
- public function testUcwords()
+ public function testUcwords(): void
{
$this->assertSame('Hypervel', (string) $this->stringable('hypervel')->ucwords());
$this->assertSame('Hypervel Framework', (string) $this->stringable('hypervel framework')->ucwords());
@@ -226,6 +226,8 @@ public function testUcwords()
$this->assertSame('Мама', (string) $this->stringable('мама')->ucwords());
$this->assertSame('Мама Мыла Раму', (string) $this->stringable('мама мыла раму')->ucwords());
$this->assertSame('JJ Watt', (string) $this->stringable('JJ watt')->ucwords());
+ $this->assertSame('Мама мыла раму', (string) $this->stringable('мама мыла раму')->ucwords(''));
+ $this->assertSame('', (string) $this->stringable('')->ucwords(''));
}
public function testUnless()
From 01101809a9ea51c9b698f95cd812b7702e0c0958 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 12:42:37 +0000
Subject: [PATCH 18/22] Complete string helper parity and correct Unicode
wrapping
Reconcile the APA and word-splitting helpers with Laravel 13.x at
01d008c9b5f32cb7c5e50a9a22273113d810b2a2, including the complete current
Str and Stringable test additions discovered through that history.
Port Unicode-aware word wrapping, native PHP 8.4 first-letter casing,
and the public resetFactoryState API. Preserve Hypervel's native types,
Symfony UID factories, separate finite-input StrCache, and authoritative
test cleanup: flushState delegates to the narrow reset before flushing
macros. Document the reset's worker-wide effect and its testing usage.
Correct two defects shared by current Laravel:
- Hyphenated title and subtitle openings must receive the same APA
capitalization exception as nonhyphenated opening words.
- Unicode word wrapping must encode existing separators before wrapping,
and escape literal NUL/SUB bytes so the native break token cannot be
confused with input. Keep the ASCII fast path and native wrapping
primitive; remove the variable-length token search.
Merge all sixteen upstream test methods, the missing headline/studly
symbol assertions, and focused regressions. Restore the previous dump
handler in finally and use valid typed Symfony UUID/ULID fixtures.
Correct the missing semicolon in the documented word-wrap example.
Upstream PRs:
https://github.com/laravel/framework/pull/49572
https://github.com/laravel/framework/pull/56338
https://github.com/laravel/framework/pull/60012
https://github.com/laravel/framework/pull/60814
https://github.com/laravel/framework/pull/60864
https://github.com/laravel/framework/pull/57296
https://github.com/laravel/framework/pull/61260
Verified existing follow-up source and test coverage:
https://github.com/laravel/framework/pull/50114
https://github.com/laravel/framework/pull/50335
https://github.com/laravel/framework/pull/51428
https://github.com/laravel/framework/pull/56796
https://github.com/laravel/framework/pull/57254
https://github.com/laravel/framework/pull/60056
Validation: changed test files pass 370 tests / 2,015 assertions;
Support, Translation, and Validation ParaTest pass 4,499 tests / 14,900
assertions. Full source and type-fixture PHPStan, scoped formatting,
and git diff --check pass. Independently reviewed and signed off by
claude-laravel-parity.
---
src/docs/strings.md | 8 ++-
src/support/src/Str.php | 57 +++++++++++++---
tests/Support/SupportStrTest.php | 76 ++++++++++++++++++++-
tests/Support/SupportStringableTest.php | 88 +++++++++++++++++++++++++
4 files changed, 216 insertions(+), 13 deletions(-)
diff --git a/src/docs/strings.md b/src/docs/strings.md
index 30483766e..70dabfcf2 100644
--- a/src/docs/strings.md
+++ b/src/docs/strings.md
@@ -1453,6 +1453,12 @@ To instruct the `random` method to return to generating random strings normally,
Str::createRandomStringsNormally();
```
+You may reset the random string, UUID, and ULID factories together using the `resetFactoryState` method:
+
+```php
+Str::resetFactoryState();
+```
+
#### `Str::remove()` {.collection-method}
@@ -2118,7 +2124,7 @@ The `Str::wordWrap` method wraps a string to a given number of characters:
```php
use Hypervel\Support\Str;
-$text = "The quick brown fox jumped over the lazy dog."
+$text = "The quick brown fox jumped over the lazy dog.";
Str::wordWrap($text, characters: 20, break: "
\n");
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index d4a5be695..47906ae55 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -1248,16 +1248,20 @@ public static function apa(string $value): string
$hyphenatedWords = explode('-', $lowercaseWord);
$hyphenatedWords = array_map(function ($part) use ($minorWords) {
- return (in_array($part, $minorWords) && mb_strlen($part) <= 3)
+ return (in_array($part, $minorWords, true) && mb_strlen($part) <= 3)
? $part
: mb_strtoupper(mb_substr($part, 0, 1)) . mb_substr($part, 1);
}, $hyphenatedWords);
$words[$i] = implode('-', $hyphenatedWords);
+
+ if ($i === 0 || in_array(mb_substr($words[$i - 1], -1), $endPunctuation, true)) {
+ $words[$i] = static::ucfirst($words[$i]);
+ }
} else {
- if (in_array($lowercaseWord, $minorWords)
+ if (in_array($lowercaseWord, $minorWords, true)
&& mb_strlen($lowercaseWord) <= 3
- && ! ($i === 0 || in_array(mb_substr($words[$i - 1], -1), $endPunctuation))) {
+ && ! ($i === 0 || in_array(mb_substr($words[$i - 1], -1), $endPunctuation, true))) {
$words[$i] = $lowercaseWord;
} else {
$words[$i] = mb_strtoupper(mb_substr($lowercaseWord, 0, 1)) . mb_substr($lowercaseWord, 1);
@@ -1588,7 +1592,7 @@ public static function fromBase64(string $string, bool $strict = false): string|
*/
public static function lcfirst(string $string): string
{
- return static::lower(static::substr($string, 0, 1)) . static::substr($string, 1);
+ return mb_lcfirst($string, 'UTF-8');
}
/**
@@ -1598,7 +1602,7 @@ public static function lcfirst(string $string): string
*/
public static function ucfirst(string $string): string
{
- return static::upper(static::substr($string, 0, 1)) . static::substr($string, 1);
+ return mb_ucfirst($string, 'UTF-8');
}
/**
@@ -1644,7 +1648,33 @@ public static function wordCount(string $string, ?string $characters = null): in
*/
public static function wordWrap(string $string, int $characters = 75, string $break = "\n", bool $cutLongWords = false): string
{
- return wordwrap($string, $characters, $break, $cutLongWords);
+ if (static::isAscii($string)) {
+ return wordwrap($string, $characters, $break, $cutLongWords);
+ }
+
+ if ($break === '') {
+ return wordwrap($string, $characters, $break, $cutLongWords);
+ }
+
+ $replaced = [];
+ $breakToken = "\0";
+
+ // Encode existing breaks so the native wrapper resets its line width.
+ $skeleton = implode($breakToken, array_map(function ($segment) use (&$replaced) {
+ return preg_replace_callback('/[\x80-\xFF][\x80-\xBF]*|[\x00\x1A]/', function ($match) use (&$replaced) {
+ $replaced[] = $match[0];
+
+ return "\x1A";
+ }, $segment);
+ }, explode($break, $string)));
+
+ $index = 0;
+
+ return implode($break, array_map(function ($segment) use (&$replaced, &$index) {
+ return preg_replace_callback('/\x1A/', function () use (&$replaced, &$index) {
+ return $replaced[$index++];
+ }, $segment);
+ }, explode($breakToken, wordwrap($skeleton, $characters, $breakToken, $cutLongWords))));
}
/**
@@ -1890,15 +1920,24 @@ public static function flushCache(): void
}
/**
- * Flush all static state.
+ * Return all factory functions to their default state.
+ *
+ * Tests only. Clears the worker-wide random string, UUID, and ULID
+ * factories, affecting generation in every coroutine.
*/
- public static function flushState(): void
+ public static function resetFactoryState(): void
{
- // Return all factory functions to their default state.
static::createRandomStringsNormally();
static::createUlidsNormally();
static::createUuidsNormally();
+ }
+ /**
+ * Flush all static state.
+ */
+ public static function flushState(): void
+ {
+ static::resetFactoryState();
static::flushMacros();
}
}
diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php
index e8f702906..a238edbb6 100644
--- a/tests/Support/SupportStrTest.php
+++ b/tests/Support/SupportStrTest.php
@@ -113,6 +113,8 @@ public function testStringHeadline(): void
$this->assertSame('Orwell 1984', Str::headline('-orwell-1984 -'));
$this->assertSame('Orwell 1984', Str::headline(' orwell_- 1984 '));
+ $this->assertSame('❤ Multi Byte ☆', Str::headline('❤_multiByte-☆'));
+
$nbsp = chr(0xC2) . chr(0xA0);
$this->assertSame('Hypervel Rocks!', Str::headline('hypervel' . $nbsp . 'rocks!'));
@@ -162,6 +164,10 @@ public function testStringApa(): void
$this->assertSame('Self-Report', Str::apa('Self-report'));
$this->assertSame('Self-Report', Str::apa('SELF-REPORT'));
+ $this->assertSame('On-Call Work', Str::apa('on-call work'));
+ $this->assertSame('A Guide: On-Call Work', Str::apa('a guide: on-call work'));
+ $this->assertSame('A Guide to on-Call Work', Str::apa('a guide to on-call work'));
+
$this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('as the world turns, so are the days of our lives'));
$this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('AS THE WORLD TURNS, SO ARE THE DAYS OF OUR LIVES'));
$this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('As The World Turns, So Are The Days Of Our Lives'));
@@ -899,6 +905,17 @@ public static function uuidVersionList(): array
];
}
+ public function testIsUlid(): void
+ {
+ $this->assertTrue(Str::isUlid((string) Str::ulid()));
+ $this->assertTrue(Str::isUlid('01ARZ3NDEKTSV4RRFFQ69G5FAV'));
+
+ $this->assertFalse(Str::isUlid('not-a-ulid'));
+ $this->assertFalse(Str::isUlid('01ARZ3NDEKTSV4RRFFQ69G5FA'));
+ $this->assertFalse(Str::isUlid(null));
+ $this->assertFalse(Str::isUlid(['not', 'a', 'ulid']));
+ }
+
public function testIsJson(): void
{
$this->assertTrue(Str::isJson('1'));
@@ -1381,6 +1398,8 @@ public function testStudly(): void
$this->assertSame('ÖffentlicheÜberraschungen', Str::studly('öffentliche-überraschungen'));
+ $this->assertSame('❤MultiByte☆', Str::studly('❤ multi-byte☆'));
+
$nbsp = chr(0xC2) . chr(0xA0);
$this->assertSame('HypervelRocks!', Str::studly('hypervel' . $nbsp . 'rocks!'));
@@ -1633,6 +1652,8 @@ public function testUcfirst(): void
$this->assertSame('Hypervel framework', Str::ucfirst('hypervel framework'));
$this->assertSame('Мама', Str::ucfirst('мама'));
$this->assertSame('Мама мыла раму', Str::ucfirst('мама мыла раму'));
+ $this->assertSame('Džungla', Str::ucfirst('džungla'));
+ $this->assertSame('Sseta', Str::ucfirst('ßeta'));
}
public function testUcwords(): void
@@ -1761,10 +1782,22 @@ public function testWordCount(): void
public function testWordWrap(): void
{
- $this->assertEquals('Hello
World', Str::wordWrap('Hello World', 3, '
'));
- $this->assertEquals('Hel
lo
Wor
ld', Str::wordWrap('Hello World', 3, '
', true));
+ $this->assertSame('Hello
World', Str::wordWrap('Hello World', 3, '
'));
+ $this->assertSame('Hel
lo
Wor
ld', Str::wordWrap('Hello World', 3, '
', true));
+
+ $this->assertSame('❤Multi
Byte☆❤☆❤☆❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
'));
+
+ $this->assertSame('žltý kôň', Str::wordWrap('žltý kôň', 8, "\n"));
+ $this->assertSame("žltý\nkôň", Str::wordWrap('žltý kôň', 4, "\n", true));
+ $this->assertSame("žl\ntý", Str::wordWrap('žltý', 2, "\n", true));
+ $this->assertSame("😀😀\n😀😀", Str::wordWrap('😀😀😀😀', 2, "\n", true));
+ $this->assertSame("éA\x1ABé", Str::wordWrap('é é', 1, "A\x1AB"));
+ $this->assertSame('❤Mu
lti
Byt
e☆❤
☆❤☆
❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
', true));
- $this->assertEquals('❤Multi
Byte☆❤☆❤☆❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
'));
+ $this->assertSame("éé\néé éé", Str::wordWrap("éé\néé éé", 5, "\n"));
+ $this->assertSame('éé
éé éé', Str::wordWrap('éé
éé éé', 5, '
', true));
+ $this->assertSame('éé☆éé éé', Str::wordWrap('éé☆éé éé', 5, '☆'));
+ $this->assertSame("é\0\n\x1Aé", Str::wordWrap("é\0\x1Aé", 2, "\n", true));
}
public function testMarkdown(): void
@@ -2090,6 +2123,28 @@ public function testUlidSequenceIsRestoredWhenNormalGenerationThrows(): void
ThrowingSequenceStr::createUlidsNormally();
}
+ public function testResetFactoryState(): void
+ {
+ $uuid = Uuid::fromString('00000000-0000-0000-0000-000000000000');
+ $ulid = new Ulid('01ARZ3NDEKTSV4RRFFQ69G5FAV');
+
+ Str::macro('factoryResetMacro', fn (): bool => true);
+ Str::createRandomStringsUsing(fn (int $length): string => 'random:' . $length);
+ Str::createUuidsUsing(fn (): Uuid => $uuid);
+ Str::createUlidsUsing(fn (): Ulid => $ulid);
+
+ $this->assertSame('random:7', Str::random(7));
+ $this->assertSame((string) $uuid, (string) Str::uuid());
+ $this->assertSame((string) $ulid, (string) Str::ulid());
+
+ Str::resetFactoryState();
+
+ $this->assertNotSame('random:7', Str::random(7));
+ $this->assertNotSame((string) $uuid, (string) Str::uuid());
+ $this->assertNotSame((string) $ulid, (string) Str::ulid());
+ $this->assertTrue(Str::hasMacro('factoryResetMacro'));
+ }
+
public function testPasswordCreation(): void
{
$this->assertTrue(strlen(Str::password()) === 32);
@@ -2265,6 +2320,21 @@ public function count(): int
$this->assertSame('UserGroups', Str::pluralPascal('UserGroup', $countable));
}
+
+ public function testPluralStudly(): void
+ {
+ $this->assertSame('VerifiedHumans', Str::pluralStudly('VerifiedHuman'));
+ $this->assertSame('UserFeedback', Str::pluralStudly('UserFeedback'));
+ $this->assertSame('VerifiedHuman', Str::pluralStudly('VerifiedHuman', 1));
+ $this->assertSame('VerifiedHumans', Str::pluralStudly('VerifiedHuman', 2));
+ }
+
+ public function testSingular(): void
+ {
+ $this->assertSame('child', Str::singular('children'));
+ $this->assertSame('mouse', Str::singular('mice'));
+ $this->assertSame('Laracon', Str::singular('Laracons'));
+ }
}
class ThrowingSequenceStr extends Str
diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php
index 4ee600a3e..ea86fdf2c 100644
--- a/tests/Support/SupportStringableTest.php
+++ b/tests/Support/SupportStringableTest.php
@@ -18,6 +18,7 @@
use Hypervel\Tests\TestCase;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
+use Symfony\Component\VarDumper\VarDumper;
class SupportStringableTest extends TestCase
{
@@ -301,6 +302,13 @@ public function testDirname()
$this->assertSame(DIRECTORY_SEPARATOR, (string) $this->stringable('/')->dirname());
}
+ public function testBasename(): void
+ {
+ $this->assertSame('Support', (string) $this->stringable('/framework/tests/Support')->basename());
+ $this->assertSame('Str.php', (string) $this->stringable('/framework/src/Str.php')->basename());
+ $this->assertSame('Str', (string) $this->stringable('/framework/src/Str.php')->basename('.php'));
+ }
+
public function testUcsplitOnStringable()
{
$this->assertSame(['Taylor', 'Otwell'], $this->stringable('TaylorOtwell')->ucsplit()->toArray());
@@ -648,6 +656,55 @@ public function testTitle()
$this->assertSame('Jefferson Costella', (string) $this->stringable('jefFErson coSTella')->title());
}
+ public function testHeadline(): void
+ {
+ $this->assertSame('Jefferson Costella', (string) $this->stringable('jefferson costella')->headline());
+ $this->assertSame('Hypervel Php Framework', (string) $this->stringable('hypervel_php_framework')->headline());
+ $this->assertSame('Foo Bar Baz', (string) $this->stringable('foo-barBaz')->headline());
+ }
+
+ public function testApa(): void
+ {
+ $this->assertSame('Back to the Future', (string) $this->stringable('back to the future')->apa());
+ $this->assertSame('Self-Report', (string) $this->stringable('self-report')->apa());
+ }
+
+ public function testLcfirst(): void
+ {
+ $this->assertSame('hypervel', (string) $this->stringable('Hypervel')->lcfirst());
+ $this->assertSame('hypervel framework', (string) $this->stringable('Hypervel framework')->lcfirst());
+ }
+
+ public function testUcfirst(): void
+ {
+ $this->assertSame('Hypervel', (string) $this->stringable('hypervel')->ucfirst());
+ $this->assertSame('Hypervel framework', (string) $this->stringable('hypervel framework')->ucfirst());
+ }
+
+ public function testConvertCase(): void
+ {
+ $this->assertSame('HELLO', (string) $this->stringable('hello')->convertCase(MB_CASE_UPPER));
+ $this->assertSame('hello', (string) $this->stringable('HELLO')->convertCase(MB_CASE_LOWER));
+ }
+
+ public function testWordWrap(): void
+ {
+ $this->assertSame('Hello
World', (string) $this->stringable('Hello World')->wordWrap(3, '
'));
+ $this->assertSame('Hel
lo
Wor
ld', (string) $this->stringable('Hello World')->wordWrap(3, '
', true));
+ }
+
+ public function testPlural(): void
+ {
+ $this->assertSame('Laracons', (string) $this->stringable('Laracon')->plural(3));
+ $this->assertSame('Laracon', (string) $this->stringable('Laracon')->plural(1));
+ }
+
+ public function testSingular(): void
+ {
+ $this->assertSame('child', (string) $this->stringable('children')->singular());
+ $this->assertSame('mouse', (string) $this->stringable('mice')->singular());
+ }
+
public function testWithoutWordsDoesntProduceError()
{
$nbsp = chr(0xC2) . chr(0xA0);
@@ -1051,6 +1108,20 @@ public function testKebab()
$this->assertSame('hypervel-php-framework', (string) $this->stringable('HypervelPhpFramework')->kebab());
}
+ public function testChopStart(): void
+ {
+ $this->assertSame('hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart('http://'));
+ $this->assertSame('http://hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart('https://'));
+ $this->assertSame('hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart(['https://', 'http://']));
+ }
+
+ public function testChopEnd(): void
+ {
+ $this->assertSame('path/to/file', (string) $this->stringable('path/to/file.php')->chopEnd('.php'));
+ $this->assertSame('path/to/file.php', (string) $this->stringable('path/to/file.php')->chopEnd('.html'));
+ $this->assertSame('path/to/file', (string) $this->stringable('path/to/file.php')->chopEnd(['.html', '.php']));
+ }
+
public function testLower()
{
$this->assertSame('foo bar baz', (string) $this->stringable('FOO BAR BAZ')->lower());
@@ -1665,4 +1736,21 @@ public function testEncryptAndDecrypt()
$this->assertNotSame('foo', $encrypted->value());
$this->assertSame('foo', $encrypted->decrypt()->value());
}
+
+ public function testDump(): void
+ {
+ $log = new Collection;
+
+ $previousHandler = VarDumper::setHandler(function (mixed $value) use ($log): void {
+ $log->add($value);
+ });
+
+ try {
+ $this->stringable('foo')->dump('one', 'two');
+
+ $this->assertSame(['foo', 'one', 'two'], $log->all());
+ } finally {
+ VarDumper::setHandler($previousHandler);
+ }
+ }
}
From 513669f659e4a281f92e7420c43545bf0d64b41f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 13:52:47 +0000
Subject: [PATCH 19/22] Complete null-safe query comparison parity across
supported databases
Port the current Laravel 13.x implementation and tests identified by:
https://github.com/laravel/framework/pull/57698
https://github.com/laravel/framework/pull/58962
Source revision: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Route the <=> operator through each driver's null-safe equality compiler
and remove SQLite's obsolete basic-clause override. Preserve all supported
morph relationship cases and merge the seven applicable upstream query
builder tests with existing null and raw-expression coverage.
Correct two upstream gaps during adaptation: RHS subqueries bypassed the
null-safe compiler, and JSON booleans either lost their driver-specific
casts or compared against an incorrectly bound scalar. Reuse the existing
dialect compilers with prepared expressions, preserving Hypervel's child
grammar, embedded timeout checks and binding order. Normalize SQLite's
literal booleans to 1/0 so IS compares equality instead of truthiness.
Keep Laravel public/protected APIs and Hypervel's pooled architecture;
no new worker state or database round trips. SQL Server remains unsupported.
Validation: Database ParaTest 3518 tests / 12464 assertions; both changed
files pass individually; full source/type PHPStan, scoped formatter and
diff check pass. Real SQLite execution also passes (1 test / 8 assertions).
The owner deferred committing that additional integration test to the next
checkpoint to keep this PR at 100 files; its source is preserved in
/tmp/hypervel-laravel-parity-57698/NullSafeEqualityTest.php.
Reviewed and approved by claude-laravel-parity.
---
src/database/src/Query/Builder.php | 9 ++
src/database/src/Query/Grammars/Grammar.php | 14 ++
.../src/Query/Grammars/SQLiteGrammar.php | 40 +++---
.../Database/DatabaseEloquentBuilderTest.php | 2 +
tests/Database/DatabaseQueryBuilderTest.php | 127 +++++++++++++++++-
5 files changed, 168 insertions(+), 24 deletions(-)
diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php
index d4f5bc737..fe99ae563 100644
--- a/src/database/src/Query/Builder.php
+++ b/src/database/src/Query/Builder.php
@@ -858,6 +858,11 @@ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|a
$type = 'Bitwise';
}
+ // JSON booleans need their driver's casts before applying null-safe equality.
+ if ($operator === '<=>' && $type !== 'JsonBoolean') {
+ $type = 'NullSafeEquals';
+ }
+
// Now that we are working with just a simple query we can put the elements
// in our array and add the query binding to our array of bindings that
// will be bound to each SQL statements when it is finally executed.
@@ -1143,6 +1148,10 @@ public function orWhereNotLike(ExpressionContract|string $column, string $value,
*/
public function whereNullSafeEquals(ExpressionContract|string $column, mixed $value, string $boolean = 'and'): static
{
+ if (is_bool($value) && is_string($column) && str_contains($column, '->')) {
+ return $this->where($column, '<=>', $value, $boolean);
+ }
+
$type = 'NullSafeEquals';
$this->wheres[] = compact('type', 'column', 'value', 'boolean');
diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php
index 39602b8e0..94776b4e1 100755
--- a/src/database/src/Query/Grammars/Grammar.php
+++ b/src/database/src/Query/Grammars/Grammar.php
@@ -9,6 +9,7 @@
use Hypervel\Database\Concerns\CompilesJsonPaths;
use Hypervel\Database\Grammar as BaseGrammar;
use Hypervel\Database\Query\Builder;
+use Hypervel\Database\Query\Expression as QueryExpression;
use Hypervel\Database\Query\JoinClause;
use Hypervel\Database\Query\JoinLateralClause;
use Hypervel\Support\Arr;
@@ -512,6 +513,12 @@ protected function whereSub(Builder $query, array $where): string
$subquery = $where['query'];
$select = $subquery->getGrammar()->compileSelectQuery($subquery);
+ if ($where['operator'] === '<=>') {
+ $where['value'] = new QueryExpression("({$select})");
+
+ return $this->whereNullSafeEquals($query, $where);
+ }
+
return $this->wrap($where['column']) . ' ' . $where['operator'] . " ({$select})";
}
@@ -558,6 +565,13 @@ protected function whereJsonBoolean(Builder $query, array $where): string
$this->parameter($where['value'])
);
+ if ($where['operator'] === '<=>') {
+ $where['column'] = new QueryExpression($column);
+ $where['value'] = new QueryExpression($value);
+
+ return $this->whereNullSafeEquals($query, $where);
+ }
+
return $column . ' ' . $where['operator'] . ' ' . $value;
}
diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php
index f42acf39f..af075f253 100755
--- a/src/database/src/Query/Grammars/SQLiteGrammar.php
+++ b/src/database/src/Query/Grammars/SQLiteGrammar.php
@@ -41,29 +41,6 @@ protected function wrapUnion(string $sql): string
return 'select * from (' . $sql . ')';
}
- /**
- * Compile a basic where clause.
- */
- protected function whereBasic(Builder $query, array $where): string
- {
- if ($where['operator'] === '<=>') {
- $column = $this->wrap($where['column']);
- $value = $this->parameter($where['value']);
-
- return "{$column} IS {$value}";
- }
-
- return parent::whereBasic($query, $where);
- }
-
- /**
- * Compile a "where null safe equals" clause.
- */
- protected function whereNullSafeEquals(Builder $query, array $where): string
- {
- return $this->wrap($where['column']) . ' is ' . $this->parameter($where['value']);
- }
-
/**
* Compile a "where like" clause.
*/
@@ -89,6 +66,23 @@ public function prepareWhereLikeBinding(string $value, bool $caseSensitive): str
);
}
+ /**
+ * Compile a "where null safe equals" clause.
+ */
+ protected function whereNullSafeEquals(Builder $query, array $where): string
+ {
+ $value = $this->parameter($where['value']);
+
+ // SQLite's IS TRUE and IS FALSE test truthiness instead of equality.
+ $value = match ($value) {
+ 'true' => '1',
+ 'false' => '0',
+ default => $value,
+ };
+
+ return $this->wrap($where['column']) . ' is ' . $value;
+ }
+
/**
* Compile a "where date" clause.
*/
diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php
index 22de23af5..a08104d15 100755
--- a/tests/Database/DatabaseEloquentBuilderTest.php
+++ b/tests/Database/DatabaseEloquentBuilderTest.php
@@ -2567,6 +2567,8 @@ public function testWhereNotMorphedToClassUsesPostgresNullSafeEquality(): void
$this->assertSame([ModelCloseRelatedStub::class], $builder->getBindings());
}
+ // REMOVED: SQL Server whereNotMorphedTo tests; SQL Server is not supported.
+
public function testWhereMorphedToAlias(): void
{
$model = new ModelParentStub;
diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php
index 4decaa72c..14a98b641 100755
--- a/tests/Database/DatabaseQueryBuilderTest.php
+++ b/tests/Database/DatabaseQueryBuilderTest.php
@@ -932,11 +932,29 @@ public function testWhereTimeOperatorOptionalSqlite()
public function testWhereNullSafeEquals(): void
{
+ $builder = $this->getBuilder();
+ $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar');
+ $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+
+ $builder = $this->getBuilder();
+ $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar')->whereNullSafeEquals('baz', 'qux');
+ $this->assertSame('select * from "users" where "foo" is not distinct from ? and "baz" is not distinct from ?', $builder->toSql());
+ $this->assertSame(['bar', 'qux'], $builder->getBindings());
+
$builder = $this->getBuilder();
$builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar')->whereNullSafeEquals('baz', null);
$this->assertSame('select * from "users" where "foo" is not distinct from ? and "baz" is not distinct from ?', $builder->toSql());
$this->assertSame(['bar', null], $builder->getBindings());
+ }
+
+ public function testOrWhereNullSafeEquals(): void
+ {
+ $builder = $this->getBuilder();
+ $builder->select('*')->from('users')->where('foo', 'bar')->orWhereNullSafeEquals('baz', 'qux');
+ $this->assertSame('select * from "users" where "foo" = ? or "baz" is not distinct from ?', $builder->toSql());
+ $this->assertSame(['bar', 'qux'], $builder->getBindings());
$builder = $this->getBuilder();
$builder->select('*')->from('users')->where('foo', 'bar')->orWhereNullSafeEquals('baz', new Raw('qux'));
@@ -945,19 +963,126 @@ public function testWhereNullSafeEquals(): void
$this->assertSame(['bar'], $builder->getBindings());
}
- public function testWhereNullSafeEqualsUsesSupportedDriverSyntax(): void
+ public function testWhereNullSafeEqualsViaNullSafeOperator(): void
+ {
+ $builder = $this->getBuilder();
+ $builder->select('*')->from('users')->where('foo', '<=>', 'bar');
+ $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+ }
+
+ public function testWhereNullSafeEqualsWithNullViaOperator(): void
+ {
+ $builder = $this->getBuilder();
+ $builder->select('*')->from('users')->where('foo', '<=>', null);
+ $this->assertSame('select * from "users" where "foo" is null', $builder->toSql());
+ }
+
+ public function testWhereNullSafeEqualsMySql(): void
{
$builder = $this->getMySqlBuilder();
$builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar');
$this->assertSame('select * from `users` where `foo` <=> ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+
+ $builder = $this->getMySqlBuilder();
+ $builder->select('*')->from('users')->where('foo', '<=>', 'bar');
+ $this->assertSame('select * from `users` where `foo` <=> ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+ }
+ public function testWhereNullSafeEqualsSQLite(): void
+ {
$builder = $this->getSQLiteBuilder();
$builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar');
$this->assertSame('select * from "users" where "foo" is ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+ $builder = $this->getSQLiteBuilder();
+ $builder->select('*')->from('users')->where('foo', '<=>', 'bar');
+ $this->assertSame('select * from "users" where "foo" is ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+ }
+
+ public function testWhereNullSafeEqualsPostgres(): void
+ {
$builder = $this->getPostgresBuilder();
$builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar');
$this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+
+ $builder = $this->getPostgresBuilder();
+ $builder->select('*')->from('users')->where('foo', '<=>', 'bar');
+ $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql());
+ $this->assertSame(['bar'], $builder->getBindings());
+ }
+
+ // REMOVED: SQL Server null-safe equality tests; SQL Server is not supported.
+
+ public function testWhereNullSafeEqualsWithJsonBooleansMySql(): void
+ {
+ $builder = $this->getMySqlBuilder();
+ $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false);
+ $this->assertSame('select * from `users` where `id` = ? and json_extract(`options`, \'$."enabled"\') <=> true or json_extract(`options`, \'$."archived"\') <=> false', $builder->toSql());
+ $this->assertSame([1], $builder->getBindings());
+
+ $builder = $this->getMySqlBuilder();
+ $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false);
+ $this->assertSame('select * from `users` where `id` = ? and json_extract(`options`, \'$."enabled"\') <=> true or json_extract(`options`, \'$."archived"\') <=> false', $builder->toSql());
+ $this->assertSame([1], $builder->getBindings());
+ }
+
+ public function testWhereNullSafeEqualsWithJsonBooleansSQLite(): void
+ {
+ $builder = $this->getSQLiteBuilder();
+ $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false);
+ $this->assertSame('select * from "users" where "id" = ? and json_extract("options", \'$."enabled"\') is 1 or json_extract("options", \'$."archived"\') is 0', $builder->toSql());
+ $this->assertSame([1], $builder->getBindings());
+
+ $builder = $this->getSQLiteBuilder();
+ $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false);
+ $this->assertSame('select * from "users" where "id" = ? and json_extract("options", \'$."enabled"\') is 1 or json_extract("options", \'$."archived"\') is 0', $builder->toSql());
+ $this->assertSame([1], $builder->getBindings());
+ }
+
+ public function testWhereNullSafeEqualsWithJsonBooleansPostgres(): void
+ {
+ $builder = $this->getPostgresBuilder();
+ $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false);
+ $this->assertSame('select * from "users" where "id" = ? and ("options"->\'enabled\')::jsonb is not distinct from \'true\'::jsonb or ("options"->\'archived\')::jsonb is not distinct from \'false\'::jsonb', $builder->toSql());
+ $this->assertSame([1], $builder->getBindings());
+
+ $builder = $this->getPostgresBuilder();
+ $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false);
+ $this->assertSame('select * from "users" where "id" = ? and ("options"->\'enabled\')::jsonb is not distinct from \'true\'::jsonb or ("options"->\'archived\')::jsonb is not distinct from \'false\'::jsonb', $builder->toSql());
+ $this->assertSame([1], $builder->getBindings());
+ }
+
+ public function testWhereNullSafeEqualsWithSubqueryMySql(): void
+ {
+ $builder = $this->getMySqlBuilder();
+ $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar']));
+
+ $this->assertSame('select * from `users` where `id` = ? or `foo` <=> (select ?)', $builder->toSql());
+ $this->assertSame([1, 'bar'], $builder->getBindings());
+ }
+
+ public function testWhereNullSafeEqualsWithSubquerySQLite(): void
+ {
+ $builder = $this->getSQLiteBuilder();
+ $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar']));
+
+ $this->assertSame('select * from "users" where "id" = ? or "foo" is (select ?)', $builder->toSql());
+ $this->assertSame([1, 'bar'], $builder->getBindings());
+ }
+
+ public function testWhereNullSafeEqualsWithSubqueryPostgres(): void
+ {
+ $builder = $this->getPostgresBuilder();
+ $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar']));
+
+ $this->assertSame('select * from "users" where "id" = ? or "foo" is not distinct from (select ?)', $builder->toSql());
+ $this->assertSame([1, 'bar'], $builder->getBindings());
}
public function testWhereBetweens()
From 79c0351bf0591c320275674ec5f29d202b0fe9b8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 14:48:03 +0000
Subject: [PATCH 20/22] Fix Redis scan patterns and consume startup context
transport
Preserve logical SafeScan patterns even when their first segment matches
OPT_PREFIX. Only prepend the connection prefix when phpredis does not do
so; continue stripping physical result prefixes for subsequent commands.
The old input guess could scan and delete unrelated keys. Correct its
unit expectation and cover overlapping prefixes, option preservation and
wrong-key deletion on standalone Redis and Redis Cluster.
Consume __HYPERVEL_CONTEXT during console boot so descendants cannot
inherit a stale startup payload. Clear both configured environment
adapters and the native environment, including when PutenvAdapter is
disabled. Preserve initial-command hydration, callback reentry handling
and coroutine isolation; verify inheritance using a real subprocess.
Clarify logical scan patterns and the topology-specific Redis queue-name
storage suffixes. Document the reproduced PhpRedis 6.3.0 Cluster
tcp_keepalive crash without adding a Hypervel workaround. Its upstream
fix is 997d564521b3b95866c7747a971c4e357d8046e4.
Restore the real SQLite null-safe JSON comparison regression previously
held outside the checkpoint for the initial review's file limit. It
checks both APIs, selector forms, boolean/integer equality and null or
missing values. The owner lifted the limit for incremental review fixes.
Follow-up to these Laravel ports:
https://github.com/laravel/framework/pull/61198
https://github.com/laravel/framework/pull/57918
https://github.com/laravel/framework/pull/61419
https://github.com/laravel/framework/pull/57698
https://github.com/laravel/framework/pull/58962
Reviewed by claude-laravel-parity after both external bot reviews.
Validation: full source/type PHPStan and formatting; 1456 focused unit
tests (2 skips), 238 standalone Redis consumer tests (8 skips), 90
Cluster consumer tests, and the restored SQLite regression. No committed
tests were skipped or weakened for the native extension failure found
when a standalone-only connector test was initially selected on Cluster.
---
src/console/src/ConsoleServiceProvider.php | 13 +++-
src/docs/queues.md | 2 +
src/docs/redis.md | 5 +-
src/redis/src/Operations/SafeScan.php | 14 ++--
tests/Console/ConsoleServiceProviderTest.php | 35 +++++++++-
.../Database/Sqlite/NullSafeEqualityTest.php | 66 +++++++++++++++++++
.../Redis/SafeScanIntegrationTest.php | 16 +++--
tests/Redis/Operations/SafeScanTest.php | 13 ++--
8 files changed, 137 insertions(+), 27 deletions(-)
create mode 100644 tests/Integration/Database/Sqlite/NullSafeEqualityTest.php
diff --git a/src/console/src/ConsoleServiceProvider.php b/src/console/src/ConsoleServiceProvider.php
index 152954c22..23e76b59c 100644
--- a/src/console/src/ConsoleServiceProvider.php
+++ b/src/console/src/ConsoleServiceProvider.php
@@ -40,8 +40,17 @@ public function register(): void
*/
public function boot(Dispatcher $events): void
{
- if ($this->app->runningInConsole()
- && is_string($encoded = Env::get('__HYPERVEL_CONTEXT'))
+ if (! $this->app->runningInConsole()
+ || ($encoded = Env::get('__HYPERVEL_CONTEXT')) === null) {
+ return;
+ }
+
+ // Consume startup transport at boot so child processes cannot inherit stale context.
+ Env::deleteMany(['__HYPERVEL_CONTEXT']);
+ // The native environment also needs clearing when Env's putenv adapter is disabled.
+ putenv('__HYPERVEL_CONTEXT');
+
+ if (is_string($encoded)
&& is_array($context = unserialize(base64_decode($encoded, true), ['allowed_classes' => false]))) {
$events->listen(BeforeHandle::class, static function () use (&$context): void {
if ($context !== null) {
diff --git a/src/docs/queues.md b/src/docs/queues.md
index 9ef390c1c..bba6ea549 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -204,6 +204,8 @@ If your Redis queue connection uses a [Redis Cluster](https://redis.io/docs/late
You may still include your own hash tag, such as `{mail}:high`, when you need several queue names to share a specific Redis Cluster slot. Hypervel leaves explicit hash tags unchanged.
+On standalone Redis, or when supplying an explicit Cluster hash tag, avoid queue names ending in `:delayed`, `:reserved`, or `:notify`, which overlap with the queue driver's storage keys. Automatically tagged Cluster queue names do not have this restriction.
+
##### Blocking
diff --git a/src/docs/redis.md b/src/docs/redis.md
index 3959ffcc5..06961e831 100644
--- a/src/docs/redis.md
+++ b/src/docs/redis.md
@@ -120,6 +120,9 @@ A non-zero `read_timeout` is applied both when the Redis socket is opened and as
The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies to standalone and Cluster connections. Connection options override shared options, while a non-null top-level connection `prefix` takes final precedence.
+> [!WARNING]
+> PhpRedis 6.3.0 crashes when `tcp_keepalive` is applied to a Redis Cluster connection; leave this option unset for Cluster connections when using that version.
+
#### Retry and Backoff Configuration
@@ -608,7 +611,7 @@ PhpRedis does not support pipelining on Redis Cluster connections. Pipelining re
### Advanced Helpers
-If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. The `safeScan` method applies your configured prefix exactly once, including when PhpRedis' `SCAN_PREFIX` option is enabled, and removes it from returned keys:
+If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. Pass a logical key pattern without adding the connection prefix, just as you would pass a key to `get`. The `safeScan` method adds the connection prefix, including when PhpRedis' `SCAN_PREFIX` option is enabled, and removes it from returned keys:
```php
use Hypervel\Redis\RedisConnection;
diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php
index 907beff47..1f2d40bc1 100644
--- a/src/redis/src/Operations/SafeScan.php
+++ b/src/redis/src/Operations/SafeScan.php
@@ -105,16 +105,12 @@ public function execute(string $pattern, int $count = 1000): Generator
{
$prefixLen = strlen($this->optPrefix);
- // Apply OPT_PREFIX exactly once, whether phpredis adds it or not.
+ // Patterns are logical keys, even when they start with OPT_PREFIX.
+ // Prepend the connection prefix only when phpredis will not add it.
$scanPattern = $pattern;
- if ($prefixLen > 0) {
- if (str_starts_with($scanPattern, $this->optPrefix)) {
- $scanPattern = substr($scanPattern, $prefixLen);
- }
-
- if (($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) {
- $scanPattern = $this->optPrefix . $scanPattern;
- }
+ if ($prefixLen > 0
+ && ($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) {
+ $scanPattern = $this->optPrefix . $pattern;
}
// Route to cluster or standard implementation
diff --git a/tests/Console/ConsoleServiceProviderTest.php b/tests/Console/ConsoleServiceProviderTest.php
index 01e30b031..f351bd928 100644
--- a/tests/Console/ConsoleServiceProviderTest.php
+++ b/tests/Console/ConsoleServiceProviderTest.php
@@ -19,11 +19,13 @@
use Hypervel\Engine\Channel;
use Hypervel\Log\Context\Events\ContextHydrated;
use Hypervel\Log\Context\Repository;
+use Hypervel\Support\Env;
use Hypervel\Support\Facades\Context;
use Hypervel\Testbench\Attributes\WithEnv;
use Hypervel\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Process\Process;
class ConsoleServiceProviderTest extends TestCase
{
@@ -49,7 +51,8 @@ public function testScheduleCommandsAreRegistered()
}
}
- public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void
+ #[DataProvider('putenvAdapters')]
+ public function testProcessContextIsHydratedOnlyForTheInitialCommand(bool $putenvEnabled): void
{
$payload = [
'data' => ['task' => serialize('concurrency')],
@@ -58,9 +61,22 @@ public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void
$restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app);
try {
+ if (! $putenvEnabled) {
+ Env::disablePutenv();
+ }
+
$events = $this->app->make('events');
(new ConsoleServiceProvider($this->app))->boot($events);
+ $this->assertNull(Env::get('__HYPERVEL_CONTEXT'));
+ $this->assertFalse(getenv('__HYPERVEL_CONTEXT'));
+ $this->assertArrayNotHasKey('__HYPERVEL_CONTEXT', $_ENV);
+ $this->assertArrayNotHasKey('__HYPERVEL_CONTEXT', $_SERVER);
+
+ $process = new Process([PHP_BINARY, '-r', 'echo json_encode(getenv("__HYPERVEL_CONTEXT"));']);
+ $process->mustRun();
+ $this->assertSame('false', $process->getOutput());
+
$command = new BeforeHandle(new Command('context:test'), new ArrayInput([]));
$hydrations = 0;
$received = null;
@@ -95,10 +111,22 @@ public function testProcessContextIsHydratedOnlyForTheInitialCommand(): void
$this->assertSame([false], $result->pop(1));
$this->assertSame(1, $hydrations);
} finally {
+ Env::enablePutenv();
$restoreEnvironment();
}
}
+ /**
+ * Provide environment adapter configurations.
+ */
+ public static function putenvAdapters(): array
+ {
+ return [
+ 'putenv enabled' => [true],
+ 'putenv disabled' => [false],
+ ];
+ }
+
#[DataProvider('contextsWithoutProcessHydration')]
public function testProcessContextIsNotHydratedWithoutAConsolePayload(bool $runningInConsole, ?array $payload): void
{
@@ -110,6 +138,11 @@ public function testProcessContextIsNotHydratedWithoutAConsolePayload(bool $runn
$events = $this->app->make('events');
(new ConsoleServiceProvider($this->app))->boot($events);
+ if ($runningInConsole) {
+ $this->assertNull(Env::get('__HYPERVEL_CONTEXT'));
+ $this->assertFalse(getenv('__HYPERVEL_CONTEXT'));
+ }
+
$hydrations = 0;
$events->listen(ContextHydrated::class, static function () use (&$hydrations): void {
++$hydrations;
diff --git a/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php b/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php
new file mode 100644
index 000000000..8f821a5df
--- /dev/null
+++ b/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php
@@ -0,0 +1,66 @@
+make('config');
+ $config->set('database.default', 'null_safe');
+ $config->set('database.connections.null_safe', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ]);
+ }
+
+ public function testJsonBooleansUseNullSafeEqualityInsteadOfTruthiness(): void
+ {
+ Schema::create('null_safe_values', function (Blueprint $table): void {
+ $table->integer('id')->primary();
+ $table->jsonb('options');
+ });
+
+ DB::table('null_safe_values')->insert([
+ ['id' => 1, 'options' => '{"enabled":true}'],
+ ['id' => 2, 'options' => '{"enabled":false}'],
+ ['id' => 3, 'options' => '{"enabled":1}'],
+ ['id' => 4, 'options' => '{"enabled":0}'],
+ ['id' => 5, 'options' => '{"enabled":2}'],
+ ['id' => 6, 'options' => '{"enabled":"hello"}'],
+ ['id' => 7, 'options' => '{"enabled":null}'],
+ ['id' => 8, 'options' => '{}'],
+ ]);
+
+ foreach ([true, false] as $value) {
+ // SQLite exposes JSON booleans as the integers 1 and 0.
+ $expected = $value ? [1, 3] : [2, 4];
+
+ foreach (['options->enabled', new Expression("options->>'enabled'")] as $column) {
+ $this->assertSame($expected, DB::table('null_safe_values')
+ ->whereNullSafeEquals($column, $value)->orderBy('id')->pluck('id')->all());
+
+ $this->assertSame($expected, DB::table('null_safe_values')
+ ->where($column, '<=>', $value)->orderBy('id')->pluck('id')->all());
+ }
+ }
+ }
+}
diff --git a/tests/Integration/Redis/SafeScanIntegrationTest.php b/tests/Integration/Redis/SafeScanIntegrationTest.php
index 6e9051b70..4ad0a3b65 100644
--- a/tests/Integration/Redis/SafeScanIntegrationTest.php
+++ b/tests/Integration/Redis/SafeScanIntegrationTest.php
@@ -34,6 +34,7 @@ public function testSafeScanYieldsKeysWithoutPrefix(bool $prefixScan, bool $retr
$redis->set('key1', 'val1');
$redis->set('key2', 'val2');
$redis->set('key3', 'val3');
+ $redis->set($prefix . 'key4', 'val4');
// safeScan should yield keys WITHOUT the prefix
$keys = $redis->withConnection(function (RedisConnection $connection) use ($prefix, $prefixScan, $retryScan): array {
@@ -42,7 +43,7 @@ public function testSafeScanYieldsKeysWithoutPrefix(bool $prefixScan, bool $retr
$options = $connection->getOption(PhpRedis::OPT_SCAN);
$keys = iterator_to_array($connection->safeScan('key*'));
- $this->assertEqualsCanonicalizing($keys, iterator_to_array($connection->safeScan($prefix . 'key*')));
+ $this->assertSame([$prefix . 'key4'], iterator_to_array($connection->safeScan($prefix . 'key*')));
$this->assertSame($options, $connection->getOption(PhpRedis::OPT_SCAN));
return $keys;
@@ -167,20 +168,23 @@ public function testFlushByPatternReturnsZeroWhenNoKeysMatch()
$this->assertSame(0, $deleted);
}
- public function testFlushByPatternWithPrefixHandlesDoublePrefix()
+ #[DataProvider('scanPrefixOptions')]
+ public function testFlushByPatternPreservesOverlappingLogicalPrefixes(bool $prefixScan, bool $retryScan): void
{
- $prefix = 'flushprefix:';
+ $prefix = 'cache:';
$connectionName = $this->createRedisConnectionWithPrefix($prefix);
$redis = Redis::connection($connectionName);
$redis->flushdb();
- // Create keys via prefixed connection (stored as "flushprefix:cache:1" in Redis)
+ // The logical cache prefix and the connection prefix both belong in the stored keys.
$redis->set('cache:1', 'a');
$redis->set('cache:2', 'b');
$redis->set('other:1', 'c');
- // flushByPattern should handle OPT_PREFIX correctly — no double prefix
- $deleted = $redis->withConnection(function (RedisConnection $connection) {
+ $deleted = $redis->withConnection(function (RedisConnection $connection) use ($prefixScan, $retryScan): int {
+ $connection->setOption(PhpRedis::OPT_SCAN, $retryScan ? PhpRedis::SCAN_RETRY : PhpRedis::SCAN_NORETRY);
+ $connection->setOption(PhpRedis::OPT_SCAN, $prefixScan ? PhpRedis::SCAN_PREFIX : PhpRedis::SCAN_NOPREFIX);
+
return $connection->flushByPattern('cache:*');
}, transform: false);
diff --git a/tests/Redis/Operations/SafeScanTest.php b/tests/Redis/Operations/SafeScanTest.php
index 1f257d8af..8e2c93747 100644
--- a/tests/Redis/Operations/SafeScanTest.php
+++ b/tests/Redis/Operations/SafeScanTest.php
@@ -35,7 +35,7 @@ public function testScanRejectsTransformedConnections(): void
$connection->shouldTransform();
$this->expectException(InvalidRedisConnectionException::class);
- $this->expectExceptionMessage('SafeScan requires a raw Redis connection.');
+ $this->expectExceptionMessageIsOrContains('SafeScan requires a raw Redis connection.');
new SafeScan($connection, '');
}
@@ -136,25 +136,22 @@ public function testScanIteratesMultipleBatches(): void
$this->assertSame(2, $client->getScanCallCount());
}
- public function testScanDoesNotDoublePrefixWhenPatternAlreadyHasPrefix(): void
+ public function testScanPreservesLogicalPatternsStartingWithTheConnectionPrefix(): void
{
$client = new FakeRedisClient(
scanResults: [
- ['keys' => ['myapp:cache:key1'], 'iterator' => 0],
+ ['keys' => ['myapp:myapp:cache:key1'], 'iterator' => 0],
],
optPrefix: 'myapp:',
);
$safeScan = new SafeScan($this->createConnection($client), 'myapp:');
- // Pattern already has prefix - should NOT add it again
$keys = iterator_to_array($safeScan->execute('myapp:cache:*'));
- // Should strip prefix from result
- $this->assertSame(['cache:key1'], $keys);
+ $this->assertSame(['myapp:cache:key1'], $keys);
- // Pattern should NOT be double-prefixed
- $this->assertSame('myapp:cache:*', $client->getScanCalls()[0]['pattern']);
+ $this->assertSame('myapp:myapp:cache:*', $client->getScanCalls()[0]['pattern']);
}
public function testScanReturnsKeyAsIsWhenItDoesNotHavePrefix(): void
From b56af4bf5a224028b894fb0399eb83d6adea4760 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 15:13:35 +0000
Subject: [PATCH 21/22] Test gRPC bootstrap against a real cached HTTP route
The route-cache memoization port correctly retains the application boot decision, but the gRPC provider test created a placeholder cache file only after Testbench had booted without cached routes. This caused deterministic failures in both PHP 8.4 and PHP 8.5 CI.
Use Testbench defineCacheRoutes() to compile HTTP routes and reload the application before registering the gRPC provider. Preserve all assertions for bootstrap-owned isolated gRPC routes and verify the cached HTTP route still dispatches afterward. Let the existing helper own cache files and cleanup; remove the manual environment mutation and scratch directory.
Follow-up to the route-cache detection ports: https://github.com/laravel/framework/pull/57623 and https://github.com/laravel/framework/pull/57687. No framework source behavior changes.
Validation: changed class passes 26 tests / 78 assertions. Full composer test:parallel passes 34,814 tests / 125,524 assertions with 2,238 service/platform skips on PHP 8.4. Formatting and git diff --check pass. Self-reviewed and approved by claude-laravel-parity.
---
tests/Grpc/GrpcServiceProviderTest.php | 57 +++++++++++---------------
1 file changed, 25 insertions(+), 32 deletions(-)
diff --git a/tests/Grpc/GrpcServiceProviderTest.php b/tests/Grpc/GrpcServiceProviderTest.php
index 597d194c4..a3a27ef51 100644
--- a/tests/Grpc/GrpcServiceProviderTest.php
+++ b/tests/Grpc/GrpcServiceProviderTest.php
@@ -4,7 +4,6 @@
namespace Hypervel\Tests\Grpc;
-use Hypervel\Filesystem\Filesystem;
use Hypervel\Grpc\GrpcServiceProvider;
use Hypervel\Grpc\Health\HealthStatusProvider;
use Hypervel\Grpc\Health\ServingHealthStatusProvider;
@@ -23,7 +22,6 @@
use Hypervel\Server\ServerInterface;
use Hypervel\Support\ServiceProvider;
use Hypervel\Testbench\TestCase;
-use Hypervel\Testing\ParallelTesting;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -206,36 +204,31 @@ public function testPublishesConfigurationAndCanonicalRoutes(): void
public function testLoadsIsolatedRoutesDuringServerBootstrapEvenWhenApplicationRoutesAreCached(): void
{
- $cacheDirectory = ParallelTesting::tempDir('GrpcServiceProviderTest-route-cache');
- $cachePath = $cacheDirectory . '/routes.php';
- $files = new Filesystem;
- $files->deleteDirectory($cacheDirectory);
- $files->ensureDirectoryExists($cacheDirectory);
- $files->put($cachePath, 'registerEnabledProvider();
-
- $this->assertTrue($this->app->routesAreCached());
-
- $provider->boot();
-
- $this->assertCount(0, $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes());
-
- $this->app->make(Server::class)->bootstrapForServer('grpc');
-
- $routes = $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes();
- $this->assertCount(3, $routes);
- $this->assertSame([
- 'grpc.health.v1.Health/Check',
- 'grpc.health.v1.Health/List',
- 'grpc.health.v1.Health/Watch',
- ], array_map(static fn ($route): string => $route->uri(), $routes));
- } finally {
- unset($_SERVER['APP_ROUTES_CACHE']);
- $files->deleteDirectory($cacheDirectory);
- }
+ $this->defineCacheRoutes(<<<'PHP'
+ 'cached HTTP');
+PHP);
+
+ $provider = $this->registerEnabledProvider();
+
+ $this->assertTrue($this->app->routesAreCached());
+
+ $provider->boot();
+
+ $this->assertCount(0, $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes());
+
+ $this->app->make(Server::class)->bootstrapForServer('grpc');
+
+ $routes = $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes();
+ $this->assertCount(3, $routes);
+ $this->assertSame([
+ 'grpc.health.v1.Health/Check',
+ 'grpc.health.v1.Health/List',
+ 'grpc.health.v1.Health/Watch',
+ ], array_map(static fn ($route): string => $route->uri(), $routes));
+
+ $this->get('/cached-http')->assertOk()->assertContent('cached HTTP');
}
public function testFinalServerConfigurationRejectsAListenerNameAddedByAnotherProvider(): void
From da0579d8a449301ebc517217e79c72f45b914b52 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 15:13:35 +0000
Subject: [PATCH 22/22] Clarify the SafeScan logical pattern parameter contract
Describe execute() input as a logical key pattern whose bytes are preserved, including when they match OPT_PREFIX. The connection prefix is added separately. Replace ambiguous wording that could be read as forbidding supported overlapping logical prefixes.
Addresses the CodeRabbit outside-diff follow-up on backup PR #35. Documentation only; checked against execute() and the existing overlapping-prefix regression test. Scoped formatting and git diff --check pass.
---
src/redis/src/Operations/SafeScan.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php
index 1f2d40bc1..9f35b7089 100644
--- a/src/redis/src/Operations/SafeScan.php
+++ b/src/redis/src/Operations/SafeScan.php
@@ -95,8 +95,8 @@ public function __construct(
/**
* Execute the scan operation.
*
- * @param string $pattern The pattern to match (e.g., "cache:users:*").
- * Should NOT include OPT_PREFIX - it will be added automatically.
+ * @param string $pattern The logical key pattern (e.g., "cache:users:*"). OPT_PREFIX is added automatically;
+ * the pattern is preserved even when it starts with the same bytes as OPT_PREFIX.
* @param int $count The COUNT hint for SCAN (not a limit, just a hint to Redis)
* @return Generator yields keys with OPT_PREFIX stripped, safe for use with
* other phpredis commands that auto-add the prefix