From 96b35bad7221d10108388edda965981dbac7fc86 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:16:28 +0000
Subject: [PATCH 01/20] Keep Unicode trailing-whitespace trimming linear
Restrict trailing matches to the beginning of a whitespace run, avoiding repeated backtracking through long interior runs. Preserve the ASCII fast path and invalid-UTF-8 fallback.
Reconcile Laravel direct commits 44836016d8a1f92ecd2959d9861514c21965c5e8 and 7d104b24974f4eb7140488c446712d9c31fb346e against framework target 5d7797528723107cd4f495326838a7d7d67fbb20. Port every assertion from the upstream long-whitespace regression.
Verified the complete Str test file, affected string suites, formatting and static analysis. No performance benchmark was run.
---
src/support/src/Str.php | 10 ++++++++--
tests/Support/SupportStrTest.php | 12 ++++++++++++
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index 4d763d1dd..860d1188b 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -1505,7 +1505,10 @@ public static function trim(string $value, ?string $charlist = null): string
$trimDefaultCharacters = " \n\r\t\v\0";
- return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+|[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+$~u', '', $value) ?? $trimmed;
+ $whitespace = '[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']';
+
+ // The trailing match may only begin at the first character of a whitespace run, keeping this linear...
+ return preg_replace('~^' . $whitespace . '+|' . $whitespace . '(?assertSame("[{$run}x", Str::trim("{$run}[{$run}x{$run}"));
+ $this->assertSame("{$run}[{$run}x", Str::rtrim("{$run}[{$run}x{$run}"));
+ $this->assertSame('', Str::trim($run));
+ $this->assertSame('', Str::rtrim($run));
+ $this->assertSame('a b c', Str::trim(" a b c\u{00A0}\u{FEFF}\n"));
+ $this->assertSame(' a b', Str::rtrim(" a b \u{3000}\r\n"));
+ }
+
public function testSquish(): void
{
$this->assertSame('hypervel php framework', Str::squish(' hypervel php framework '));
From 35d063b7d456b3a58bff72d2143adfe999e7eece Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:16:41 +0000
Subject: [PATCH 02/20] Reconcile Redis limiter metadata and remaining-header
coverage
Return numeric remaining capacity from the fresh and expired Lua branches while retaining the existing correct decay/remaining order and PHP clamping.
Port Laravel framework #61748 (5086cce013b55b57b47d008e1c14755541f0f30b) at target 5d7797528723107cd4f495326838a7d7d67fbb20. Adapt the HTTP regression to the shared store-selectable throttle middleware. Restore the corrected upstream duration-status test and consolidate overlapping cases while retaining absent-key, decay-bound and stored-count assertions.
Verified both Redis integration files on an isolated Redis instance, affected suites, formatting and static analysis.
---
src/redis/src/Limiters/DurationLimiter.php | 4 +--
.../Redis/ThrottleRequestsRedisStoreTest.php | 14 ++++++++++
.../Redis/DurationLimiterIntegrationTest.php | 26 ++++++++++---------
3 files changed, 30 insertions(+), 14 deletions(-)
diff --git a/src/redis/src/Limiters/DurationLimiter.php b/src/redis/src/Limiters/DurationLimiter.php
index df06f46a5..f19f5592e 100644
--- a/src/redis/src/Limiters/DurationLimiter.php
+++ b/src/redis/src/Limiters/DurationLimiter.php
@@ -163,7 +163,7 @@ protected function tooManyAttemptsLuaScript(): string
return <<<'LUA'
if redis.call('EXISTS', KEYS[1]) == 0 then
- return {ARGV[2] + ARGV[3], ARGV[4]}
+ return {ARGV[2] + ARGV[3], tonumber(ARGV[4])}
end
if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then
@@ -173,7 +173,7 @@ protected function tooManyAttemptsLuaScript(): string
}
end
-return {ARGV[2] + ARGV[3], ARGV[4]}
+return {ARGV[2] + ARGV[3], tonumber(ARGV[4])}
LUA;
}
}
diff --git a/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php b/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php
index 48f542fe9..b6131e815 100644
--- a/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php
+++ b/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php
@@ -60,4 +60,18 @@ public function testResponseBasedLimitUsesRedisInspectionAndConditionalConsumpti
$this->get('/')->assertTooManyRequests();
$this->get('/?missing=yes')->assertTooManyRequests();
}
+
+ public function testItReturnsRemainingAttemptsHeaderWhenResponseIsNotCounted(): void
+ {
+ $manager = $this->app->make(RateLimiter::class);
+ $manager->for('throttle-not-found', fn () => Limit::perMinute(5)
+ ->after(fn (Response $response): bool => $response->getStatusCode() === 404), store: 'redis');
+
+ Route::get('/', fn (): string => 'ok')->middleware(ThrottleRequests::using('throttle-not-found'));
+
+ $this->get('/')
+ ->assertOk()
+ ->assertHeader('X-RateLimit-Limit', 5)
+ ->assertHeader('X-RateLimit-Remaining', 5);
+ }
}
diff --git a/tests/Integration/Redis/DurationLimiterIntegrationTest.php b/tests/Integration/Redis/DurationLimiterIntegrationTest.php
index 1d9fba94b..f77ffdc51 100644
--- a/tests/Integration/Redis/DurationLimiterIntegrationTest.php
+++ b/tests/Integration/Redis/DurationLimiterIntegrationTest.php
@@ -108,31 +108,33 @@ public function testAcquireSetsDecaysAtAndRemaining(): void
$this->assertSame(0, $limiter->remaining);
}
- public function testTooManyAttemptsReportsFreshWindowMetadataWithoutCreatingAKey(): void
+ public function testTooManyAttemptsReportsCorrectly(): void
{
$redis = $this->redis();
- $limiter = new DurationLimiter($redis, 'fresh-key', 2, 60);
+ $limiter = new DurationLimiter($redis, 'too-many-key', 2, 1);
$before = time();
+ // Initially, should not have too many attempts
$this->assertFalse($limiter->tooManyAttempts());
- $this->assertGreaterThanOrEqual($before + 60, $limiter->decaysAt);
- $this->assertLessThanOrEqual(time() + 60, $limiter->decaysAt);
+ $this->assertGreaterThanOrEqual($before + 1, $limiter->decaysAt);
+ $this->assertLessThanOrEqual(time() + 1, $limiter->decaysAt);
$this->assertSame(2, $limiter->remaining);
- $this->assertSame(0, $redis->exists('fresh-key'));
- }
-
- public function testTooManyAttemptsReportsOccupiedWindowMetadata(): void
- {
- $redis = $this->redis();
- $limiter = new DurationLimiter($redis, 'occupied-key', 2, 60);
+ $this->assertSame(0, $redis->exists('too-many-key'));
+ // Use up the available slots
+ $this->waitForNextSecond();
$this->assertTrue($limiter->acquire());
$this->assertTrue($limiter->acquire());
+ // Now, too many attempts within the same window
$this->assertTrue($limiter->tooManyAttempts());
$this->assertGreaterThan(time(), $limiter->decaysAt);
$this->assertSame(0, $limiter->remaining);
- $this->assertSame('2', $redis->hget('occupied-key', 'count'));
+ $this->assertSame('2', $redis->hget('too-many-key', 'count'));
+
+ // After decay window, attempts should be allowed again
+ sleep(1);
+ $this->assertFalse($limiter->tooManyAttempts());
}
public function testTooManyAttemptsClampsOverLimitRemainingCount(): void
From f3aa684d2a7d0a46ce7177c980c893c6523a03b6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:16:53 +0000
Subject: [PATCH 03/20] Recognize every callable form before resolving SQS
provider names
Normalize the effective credential provider before distinguishing callables from named AWS providers. Callable strings previously reached the invalid-name error, while callable arrays bypassed the serialization that keeps shared providers in their calling coroutine. Apply the same handling when a callable is supplied through the provider option.
Extend the existing pooled S3, SQS and SES consumer tests with string and static-array forms and an SQS nested-string regression. Preserve caller-specific signing and single-provider execution. Clarify that serializable callable forms do not require an explicit pool fingerprint.
Reproduced the SQS rejection and concurrent invocation failures before correction. Verified the consumer matrix, SQS connector tests, formatting and static analysis.
---
src/docs/queues.md | 2 +-
src/queue/src/Connectors/SqsConnector.php | 9 +++-
tests/Support/AwsCredentialConsumerTest.php | 46 +++++++++++++++++++--
3 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/src/docs/queues.md b/src/docs/queues.md
index 772b87129..94b47fe73 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -166,7 +166,7 @@ Configure a connection pool inside its queue connection definition:
Hypervel uses a complete `key` and `secret` pair when both are configured, including the optional `token` as a temporary AWS session credential. When both static values are null, the AWS SDK's default credential chain is used. Configure both static values together.
-The optional `credentials` setting takes precedence and may contain an AWS credential value or a supported `ecs` or `instance` provider. If you supply callable or object credentials, set `pool.fingerprint` because these values cannot form an automatic pool identity. The optional `version` setting defaults to `latest`. Within the optional `http` array, `timeout` and `connect_timeout` each default to 60 seconds, and additional AWS SDK HTTP options are preserved.
+The optional `credentials` setting takes precedence and may contain an AWS credential value or a supported `ecs` or `instance` provider. If your configuration contains objects or closures, set `pool.fingerprint` because these values cannot form an automatic pool identity. String callables and static-method arrays can form an automatic identity. The optional `version` setting defaults to `latest`. Within the optional `http` array, `timeout` and `connect_timeout` each default to 60 seconds, and additional AWS SDK HTTP options are preserved.
Calls to the same custom credential provider run one at a time, including when it is shared with S3 or SES. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants.
diff --git a/src/queue/src/Connectors/SqsConnector.php b/src/queue/src/Connectors/SqsConnector.php
index ceb872a6f..f070bfd08 100644
--- a/src/queue/src/Connectors/SqsConnector.php
+++ b/src/queue/src/Connectors/SqsConnector.php
@@ -140,11 +140,16 @@ protected function withCredentials(array $config): array
protected function resolveCredentialProvider(array $config): mixed
{
$credentials = $config['credentials'];
+ $provider = is_callable($credentials)
+ ? $credentials
+ : (is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials);
- $provider = is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials;
+ if (is_callable($provider)) {
+ return new SerializedCredentialProvider($provider);
+ }
if (! is_string($provider)) {
- return is_callable($provider) ? new SerializedCredentialProvider($provider) : $provider;
+ return $provider;
}
$options = is_array($credentials) ? Arr::except($credentials, ['provider']) : [];
diff --git a/tests/Support/AwsCredentialConsumerTest.php b/tests/Support/AwsCredentialConsumerTest.php
index 1a845dcb2..537b1e061 100644
--- a/tests/Support/AwsCredentialConsumerTest.php
+++ b/tests/Support/AwsCredentialConsumerTest.php
@@ -6,6 +6,7 @@
use Aws\Credentials\CredentialProvider;
use Aws\Credentials\Credentials;
+use Closure;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\PromiseInterface;
@@ -24,9 +25,18 @@
class AwsCredentialConsumerTest extends TestCase
{
- #[DataProvider('consumers')]
- public function testPooledConsumersSerializeSharedProvidersWithoutSharingCallerCredentials(string $consumer): void
+ protected function tearDown(): void
{
+ AwsCredentialConsumerTestProvider::$callback = null;
+
+ parent::tearDown();
+ }
+
+ #[DataProvider('consumers')]
+ public function testPooledConsumersSerializeSharedProvidersWithoutSharingCallerCredentials(
+ string $consumer,
+ string $providerForm,
+ ): void {
$active = 0;
$maximumActive = 0;
$authorization = [];
@@ -41,6 +51,13 @@ public function testPooledConsumersSerializeSharedProvidersWithoutSharingCallerC
return $promise;
};
+ AwsCredentialConsumerTestProvider::$callback = $provider;
+ $provider = match ($providerForm) {
+ 'string' => AwsCredentialConsumerTestProvider::class . '::resolve',
+ 'array' => [AwsCredentialConsumerTestProvider::class, 'resolve'],
+ 'nested string' => ['provider' => AwsCredentialConsumerTestProvider::class . '::resolve'],
+ default => $provider,
+ };
$configuration = [
'region' => 'us-east-1',
'version' => 'latest',
@@ -125,7 +142,17 @@ static function () use ($second, $operation): mixed {
*/
public static function consumers(): array
{
- return ['SQS' => ['sqs'], 'S3' => ['s3'], 'SES v2' => ['ses']];
+ $cases = [];
+
+ foreach (['sqs', 's3', 'ses'] as $consumer) {
+ foreach (['closure', 'string', 'array'] as $providerForm) {
+ $cases["{$consumer} {$providerForm}"] = [$consumer, $providerForm];
+ }
+ }
+
+ $cases['sqs nested string'] = ['sqs', 'nested string'];
+
+ return $cases;
}
#[DataProvider('credentialExpirations')]
@@ -180,3 +207,16 @@ public static function credentialExpirations(): array
return ['cached' => [3600], 'refreshing' => [30]];
}
}
+
+class AwsCredentialConsumerTestProvider
+{
+ public static ?Closure $callback = null;
+
+ /**
+ * Resolve credentials through the current test's shared provider.
+ */
+ public static function resolve(): PromiseInterface
+ {
+ return (self::$callback)();
+ }
+}
From ba99cf84bf33646f60e27dff7601ab42e2a18924 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:17:01 +0000
Subject: [PATCH 04/20] Preserve explicit SES credential providers over
inherited keys
Mail transport configuration inherits services.ses values, including ambient AWS keys. Preserve an explicitly supplied callable provider before applying that static pair so an assume-role provider is not silently replaced by the base identity.
Keep existing static-array and false credential behavior. Document callable precedence and the pool fingerprint requirements for object-valued configuration.
The new transport regression reproduced base-key selection before the fix. Verified the complete SES transport tests, mail manager tests, shared AWS consumer tests, formatting and static analysis.
---
src/docs/mail.md | 2 +-
src/mail/src/MailManager.php | 2 +-
tests/Mail/MailSesV2TransportTest.php | 19 +++++++++++++++++++
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/src/docs/mail.md b/src/docs/mail.md
index c89a98b6f..a53585113 100644
--- a/src/docs/mail.md
+++ b/src/docs/mail.md
@@ -215,7 +215,7 @@ To utilize AWS [temporary credentials](https://docs.aws.amazon.com/IAM/latest/Us
],
```
-If you supply a callable `credentials` provider instead of static keys, calls to that provider run one at a time, including when it is shared with SQS or S3. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. For pooled mailers, configure `pool.fingerprint` as described under [transport pooling](#transport-pooling).
+If you supply a callable `credentials` provider, it takes precedence over `key` and `secret`. Calls to that provider run one at a time, including when it is shared with SQS or S3. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. For pooled mailers, configuration containing objects or closures requires `pool.fingerprint`, as described under [transport pooling](#transport-pooling). String callables and static-method arrays can form an automatic pool identity.
To interact with SES's [subscription management features](https://docs.aws.amazon.com/ses/latest/dg/sending-email-subscription-management.html), you may return the `X-Ses-List-Management-Options` header in the array returned by the [headers](#headers) method of a mail message:
diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php
index 2a4900f46..8ed77ffb6 100644
--- a/src/mail/src/MailManager.php
+++ b/src/mail/src/MailManager.php
@@ -502,7 +502,7 @@ protected function createSesV2Transport(array $config): SesV2Transport
*/
protected function addSesCredentials(array $config): array
{
- if (! empty($config['key']) && ! empty($config['secret'])) {
+ if (! is_callable($config['credentials'] ?? null) && ! empty($config['key']) && ! empty($config['secret'])) {
$config['credentials'] = Arr::only($config, ['key', 'secret']);
if (! empty($config['token'])) {
diff --git a/tests/Mail/MailSesV2TransportTest.php b/tests/Mail/MailSesV2TransportTest.php
index 873454d81..f6b7d2798 100644
--- a/tests/Mail/MailSesV2TransportTest.php
+++ b/tests/Mail/MailSesV2TransportTest.php
@@ -5,9 +5,12 @@
namespace Hypervel\Tests\Mail;
use Aws\Command;
+use Aws\Credentials\Credentials;
use Aws\Exception\AwsException;
use Aws\Result;
use Aws\SesV2\SesV2Client;
+use GuzzleHttp\Promise\Create;
+use GuzzleHttp\Promise\PromiseInterface;
use Hypervel\Contracts\View\Factory as ViewFactory;
use Hypervel\Mail\Mailer;
use Hypervel\Mail\MailManager;
@@ -55,6 +58,22 @@ public function testGetTransport(): void
$this->assertSame('ses-v2', (string) $transport);
}
+ public function testCallableCredentialsTakePrecedenceOverServiceKeys(): void
+ {
+ config(['services.ses' => [
+ 'key' => 'base-key',
+ 'secret' => 'base-secret',
+ 'region' => 'us-east-1',
+ ]]);
+
+ $transport = (new MailManager($this->app))->createSymfonyTransport([
+ 'transport' => 'ses-v2',
+ 'credentials' => static fn (): PromiseInterface => Create::promiseFor(new Credentials('role-key', 'role-secret')),
+ ]);
+
+ $this->assertSame('role-key', $transport->ses()->getCredentials()->wait()->getAccessKeyId());
+ }
+
public function testSend(): void
{
$message = new Email;
From 5d2a8d1fee09ebf07a77b2645761ef71f49b23e2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:17:15 +0000
Subject: [PATCH 05/20] Support named S3 credential providers with pooled
clients
Accept ECS and instance-profile provider names and provider options. Keep named configuration as data until pool identity is calculated, then resolve and memoize the provider once for each client. Preserve explicit callable precedence, shared-callable serialization and client-block overrides without constructing unused providers on every disk operation.
Reconcile Laravel framework #61676 (6e7a952d2a56be5e43b30952ee088c8a255e48f3) and #61757 (20fef43f54f684b582b176b34320044ddcd0f1e6) at target 5d7797528723107cd4f495326838a7d7d67fbb20. Port all applicable filesystem assertions; Laravel Cloud remains unsupported. Retain the real unsupported-driver regression overwritten by an upstream merge.
Cover endpoint selection, provider options and memoization, stable and distinct client pools, direct and nested callables, and legacy-key precedence. Document named-provider configuration and the protected extension point for per-client resolution.
Verified the complete filesystem manager tests, affected filesystem and AWS consumer suites, formatting and full static analysis. Tests use mocked credentials or disabled metadata and make no production AWS requests.
---
src/docs/filesystem.md | 13 +-
src/docs/porting-from-laravel.md | 2 +
src/filesystem/README.md | 2 +
src/filesystem/src/FilesystemManager.php | 41 ++-
tests/Filesystem/FilesystemManagerTest.php | 306 +++++++++++++++++++--
5 files changed, 337 insertions(+), 27 deletions(-)
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index f680976fc..8988423a3 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -111,7 +111,18 @@ The credential variables use the AWS SDK's standard names, while the region foll
The optional `AWS_ROOT` value scopes the disk to a key prefix within the bucket. When it is empty, the disk operates from the bucket root.
-If you supply a callable `credentials` provider, calls to that provider run one at a time, including when it is shared with SQS or SES. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants.
+To use an IAM role, set the disk's `credentials` option to `ecs` for container credentials or `instance` for EC2 instance credentials. You may also provide an array containing the provider name and its AWS SDK options:
+
+```php
+'credentials' => [
+ 'provider' => 'ecs',
+ 'timeout' => 7,
+],
+```
+
+Named providers take precedence over the disk's `key` and `secret`. Each pooled client fetches and reuses its credentials until they need refreshing. Provider names and options are part of the [client pool identity](#driver-pools), so equivalent configurations share a pool without an explicit fingerprint.
+
+You may also supply a callable `credentials` provider, which takes precedence over `key` and `secret`. Calls to that provider run one at a time, including when it is shared with SQS or SES. Hypervel does not cache custom provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. Configuration containing objects or closures requires an explicit `pool.fingerprint`.
#### FTP Driver Configuration
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index 00df1e972..c50b4515a 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -768,6 +768,8 @@ Custom filesystem contract implementations must also provide `fileExists()` and
S3, Google Cloud Storage, FTP, SFTP, and poolable custom disks are pooled, so they do not hand out objects that outlive an operation. Replace `getAdapter()` and `getDriver()` with `withAdapter()` and `withDriver()`, and an S3 or Google Cloud Storage disk's `getClient()` with `withClient()`; each callback receives the borrowed object. Methods a pooled disk does not support throw instead of forwarding to the driver. See [driver pools](/docs/{{version}}/filesystem#driver-pools).
+If you override `FilesystemManager::formatS3Config()`, named credential providers remain configuration data there. Customize their resolution through `createS3Client()` instead.
+
Unlike Laravel, Hypervel honors `read-only` on scoped disk records. Remove that option from any scoped disk that must accept writes.
Rename any configured disk called `ondemand`; Hypervel reserves that name for [on-demand disk fakes](/docs/{{version}}/filesystem#on-demand-disks).
diff --git a/src/filesystem/README.md b/src/filesystem/README.md
index 47746c4f1..02e6833dc 100644
--- a/src/filesystem/README.md
+++ b/src/filesystem/README.md
@@ -21,6 +21,8 @@ On read-through disks, raw `listContents()` results from pooled, S3 and Google C
Filesystem construction differs from Laravel in how it carries logical disk identity. `callCustomCreator()` accepts the logical disk name as an optional second parameter, so existing one-argument calls remain valid while overrides must adopt the parameter. The public `build()` method uses a logical-name-aware construction path rather than the protected `resolve()` method because anonymous builds must pass a null name to creators; `resolve()` remains the configured-disk seam. `createScopedDriver()` resolves its prepared descriptor directly rather than through `build()`, so override `createScopedDriver()` for scoped construction customization. Customize on-demand construction through `Storage::extend()` or the public driver creator methods. Creator callbacks may accept the nullable name as a third argument after the application and configuration, while existing two-argument callbacks remain valid. Hypervel carries the name through scoped reconstruction and whole-driver pool fingerprints. A matching explicit fingerprint declares that every construction detail is equivalent, including serving-route ownership.
+`formatS3Config()` leaves named credential providers as configuration data so they can define pool identity. Override `createS3Client()` to customize provider resolution; each client resolves and memoizes its own named provider when it is created.
+
Hypervel registers signed file-serving routes for any configured disk whose `serve` option is exactly `true`, while Laravel limits these routes to local disks and accepts truthy values. Every served disk must use a unique URL or application boot will fail. Custom drivers that opt in must provide the filesystem response methods used by these routes.
Hypervel also provides `ScopedFilesystemProxy` and `ScopedCloudFilesystemProxy` for prefixes resolved independently on every operation. The underlying disk may be fixed or resolved once per operation when its configuration varies with the current context. These decorators fail closed on empty prefixes and reject unmapped calls so request- or tenant-scoped boundaries cannot be bypassed.
diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php
index 93c870591..1be29b824 100644
--- a/src/filesystem/src/FilesystemManager.php
+++ b/src/filesystem/src/FilesystemManager.php
@@ -4,6 +4,7 @@
namespace Hypervel\Filesystem;
+use Aws\Credentials\CredentialProvider;
use Aws\Middleware;
use Aws\S3\S3Client;
use Closure;
@@ -561,11 +562,30 @@ protected static function s3ArgumentNames(): array
/**
* Create an S3 client from normalized client config.
+ *
+ * @throws InvalidArgumentException
*/
protected function createS3Client(array $clientConfig): S3Client
{
- if (is_callable($clientConfig['credentials'] ?? null)) {
- $clientConfig['credentials'] = new SerializedCredentialProvider($clientConfig['credentials']);
+ $credentials = $clientConfig['credentials'] ?? null;
+ $provider = $this->s3CredentialProvider($credentials);
+
+ if (is_callable($provider)) {
+ $provider = new SerializedCredentialProvider($provider);
+ } elseif (is_string($provider)) {
+ $options = is_array($credentials) ? Arr::except($credentials, ['provider']) : [];
+
+ $provider = CredentialProvider::memoize(match ($provider) {
+ 'ecs' => CredentialProvider::ecsCredentials($options),
+ 'instance' => CredentialProvider::instanceProfile($options),
+ default => throw new InvalidArgumentException(
+ "Invalid credential provider [{$provider}]."
+ ),
+ });
+ }
+
+ if ($provider) {
+ $clientConfig['credentials'] = $provider;
}
$client = new S3Client($clientConfig);
@@ -612,7 +632,12 @@ protected function formatS3Config(array $config): array
'version' => 'latest',
];
- if (! empty($config['key']) && ! empty($config['secret'])) {
+ $provider = $this->s3CredentialProvider($config['credentials'] ?? null);
+
+ // Named providers stay as data for pool identity; createS3Client() resolves them per client.
+ if ($provider && (! is_string($provider) || is_callable($provider))) {
+ $config['credentials'] = $provider;
+ } elseif (! $provider && ! empty($config['key']) && ! empty($config['secret'])) {
$config['credentials'] = Arr::only($config, ['key', 'secret']);
if (! empty($config['token'])) {
@@ -623,6 +648,16 @@ protected function formatS3Config(array $config): array
return Arr::except($config, ['token']);
}
+ /**
+ * Get the provider described by an S3 credentials value.
+ */
+ protected function s3CredentialProvider(mixed $credentials): mixed
+ {
+ return is_callable($credentials)
+ ? $credentials
+ : (is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials);
+ }
+
/**
* Create an instance of the Google Cloud Storage driver.
*/
diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php
index 91c6d6fd6..92ba69b64 100644
--- a/tests/Filesystem/FilesystemManagerTest.php
+++ b/tests/Filesystem/FilesystemManagerTest.php
@@ -5,6 +5,8 @@
namespace Hypervel\Tests\Filesystem;
use Aws\CommandInterface;
+use Aws\Credentials\Credentials;
+use Aws\Exception\CredentialsException;
use Aws\MockHandler;
use Aws\Result;
use Aws\S3\S3Client;
@@ -12,6 +14,9 @@
use DateTimeInterface;
use Google\Cloud\Storage\Bucket;
use Google\Cloud\Storage\StorageClient as GcsClient;
+use GuzzleHttp\Promise\Create;
+use GuzzleHttp\Promise\PromiseInterface;
+use GuzzleHttp\Psr7\Response;
use Hypervel\Config\Repository;
use Hypervel\Container\Container;
use Hypervel\Contracts\Container\Container as ContainerContract;
@@ -45,11 +50,14 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresOperatingSystem;
use PHPUnit\Framework\Attributes\TestWith;
+use Psr\Http\Message\RequestInterface;
use ReflectionProperty;
use RuntimeException;
use stdClass;
use Swoole\Coroutine\CanceledException;
+use function Aws\serialize;
+
enum FilesystemTestStringBackedDisk: string
{
case Local = 'local';
@@ -100,6 +108,191 @@ protected function tearDown(): void
parent::tearDown();
}
+ public function testEcsS3DiskUsesExplicitEndpointAndIgnoresStaticCredentials(): void
+ {
+ $environment = [
+ 'AWS_ACCESS_KEY_ID' => 'ambient-r2-key',
+ 'AWS_SECRET_ACCESS_KEY' => 'ambient-r2-secret',
+ 'AWS_ENDPOINT_URL' => 'https://r2.example.com',
+ 'AWS_ENDPOINT_URL_S3' => 'https://r2.example.com',
+ 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '',
+ 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => 'http://192.0.2.1/credentials',
+ ];
+ $previous = [];
+
+ foreach ($environment as $key => $value) {
+ $previous[$key] = getenv($key);
+ putenv($key . '=' . $value);
+ }
+
+ try {
+ $disk = (new FilesystemManager($this->getContainer()))->build([
+ 'driver' => 's3',
+ 'credentials' => 'ecs',
+ 'region' => 'us-east-2',
+ 'bucket' => 'arn:aws:s3:us-east-2:123456789012:accesspoint/environment-bucket',
+ 'key' => 'disk-r2-key',
+ 'secret' => 'disk-r2-secret',
+ 'endpoint' => 'https://s3-accesspoint.us-east-2.amazonaws.com',
+ ]);
+
+ // An invalid container host fails before HTTP, rather than falling back to R2 keys.
+ $this->expectException(CredentialsException::class);
+ $this->expectExceptionMessage('unsupported host');
+
+ $disk->withClient(function (S3Client $client): void {
+ $this->assertSame('us-east-2', $client->getRegion());
+ $this->assertSame('https://s3-accesspoint.us-east-2.amazonaws.com', (string) $client->getEndpoint());
+ $client->getCredentials()->wait();
+ });
+ } finally {
+ foreach ($previous as $key => $value) {
+ putenv($value === false ? $key : $key . '=' . $value);
+ }
+ }
+ }
+
+ public function testS3DisksRouteAccessPointsAndR2ToTheirOwnEndpoints(): void
+ {
+ $previous = getenv('AWS_ENDPOINT_URL');
+ putenv('AWS_ENDPOINT_URL=https://ambient.example.com');
+ $manager = new FilesystemManager($this->getContainer());
+
+ try {
+ $aws = $manager->build([
+ 'driver' => 's3',
+ 'region' => 'us-east-2',
+ 'endpoint' => 'https://s3-accesspoint.us-east-2.amazonaws.com',
+ 'bucket' => 'arn:aws:s3:us-east-2:123456789012:accesspoint/environment-bucket',
+ 'credentials' => false,
+ ]);
+ $r2 = $manager->build([
+ 'driver' => 's3',
+ 'region' => 'auto',
+ 'endpoint' => 'https://account.r2.cloudflarestorage.com',
+ 'bucket' => 'archive',
+ 'key' => 'r2-key',
+ 'secret' => 'r2-secret',
+ ]);
+
+ // Serialize requests without sending them to either provider.
+ foreach (['GetObject', 'PutObject', 'DeleteObject'] as $operation) {
+ $awsUri = $aws->withClient(static fn (S3Client $client): string => (string) serialize($client->getCommand($operation, [
+ 'Bucket' => $aws->getConfig()['bucket'], 'Key' => 'hello.txt',
+ ]))->getUri());
+ $r2Uri = $r2->withClient(static fn (S3Client $client): string => (string) serialize($client->getCommand($operation, [
+ 'Bucket' => $r2->getConfig()['bucket'], 'Key' => 'hello.txt',
+ ]))->getUri());
+
+ $this->assertSame('https://environment-bucket-123456789012.s3-accesspoint.us-east-2.amazonaws.com/hello.txt', $awsUri);
+ $this->assertSame('https://archive.account.r2.cloudflarestorage.com/hello.txt', $r2Uri);
+ }
+ } finally {
+ putenv($previous === false ? 'AWS_ENDPOINT_URL' : 'AWS_ENDPOINT_URL=' . $previous);
+ }
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testS3CredentialProviderOptionsAndMemoization(bool $clientOverride): void
+ {
+ $previous = getenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI');
+ putenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/credentials');
+ $requests = 0;
+
+ try {
+ $config = [
+ ...$this->s3DiskConfig('documents'),
+ 'credentials' => [
+ 'provider' => 'ecs',
+ 'timeout' => 7,
+ 'client' => function (RequestInterface $request, array $options) use (&$requests): PromiseInterface {
+ ++$requests;
+ $this->assertEquals(7, $options['timeout']);
+
+ return Create::promiseFor(new Response(200, [], json_encode([
+ 'AccessKeyId' => 'container-key',
+ 'SecretAccessKey' => 'container-secret',
+ 'Token' => 'container-token',
+ 'Expiration' => gmdate('c', time() + 3600),
+ ])));
+ },
+ ],
+ 'endpoint' => 'https://custom-s3.example.com',
+ 'pool' => ['fingerprint' => 'ecs-test-client'],
+ ];
+
+ if ($clientOverride) {
+ $config['client']['credentials'] = $config['credentials'];
+ $config['credentials'] = 'instance';
+ }
+
+ $disk = (new FilesystemManager($this->getContainer()))->build($config);
+
+ $disk->withClient(function (S3Client $client) use (&$requests): void {
+ $this->assertSame('container-key', $client->getCredentials()->wait()->getAccessKeyId());
+ $this->assertSame('container-token', $client->getCredentials()->wait()->getSecurityToken());
+ $this->assertSame(1, $requests);
+ $this->assertSame('https://custom-s3.example.com', (string) $client->getEndpoint());
+ });
+ } finally {
+ putenv($previous === false ? 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' : 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=' . $previous);
+ }
+ }
+
+ public function testS3InstanceCredentialProvider(): void
+ {
+ $previous = getenv('AWS_EC2_METADATA_DISABLED');
+ putenv('AWS_EC2_METADATA_DISABLED=true');
+
+ try {
+ $disk = (new FilesystemManager($this->getContainer()))->build([
+ ...$this->s3DiskConfig('documents'),
+ 'credentials' => 'instance',
+ ]);
+
+ $this->expectException(CredentialsException::class);
+ $this->expectExceptionMessage('EC2 metadata service access disabled');
+
+ $disk->withClient(static fn (S3Client $client): mixed => $client->getCredentials()->wait());
+ } finally {
+ putenv($previous === false ? 'AWS_EC2_METADATA_DISABLED' : 'AWS_EC2_METADATA_DISABLED=' . $previous);
+ }
+ }
+
+ public function testS3RejectsUnknownCredentialProviders(): void
+ {
+ $disk = (new FilesystemManager($this->getContainer()))->build([
+ ...$this->s3DiskConfig('documents'),
+ 'credentials' => 'unknown',
+ ]);
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid credential provider [unknown].');
+
+ $disk->withClient(static fn (S3Client $client): mixed => $client->getCredentials()->wait());
+ }
+
+ public function testS3PreservesExistingCredentialConfigurations(): void
+ {
+ foreach ([false, ['key' => 'key', 'secret' => 'secret'], fn (): null => null] as $credentials) {
+ $this->assertSame($credentials, $this->s3Config(['credentials' => $credentials])['credentials']);
+ }
+
+ $this->assertArrayNotHasKey('credentials', $this->s3Config([]));
+ $this->assertSame(['key' => 'key', 'secret' => 'secret', 'token' => 'token'], $this->s3Config([
+ 'key' => 'key', 'secret' => 'secret', 'token' => 'token',
+ ])['credentials']);
+ }
+
+ /**
+ * Format S3 configuration without constructing a client.
+ */
+ protected function s3Config(array $config): array
+ {
+ return (new InspectableFilesystemManager($this->getContainer()))->formatS3ConfigForTest($config);
+ }
+
public function testExceptionThrownOnUnsupportedDriver(): void
{
$this->expectExceptionObject(new InvalidArgumentException('Disk [local] does not have a configured driver.'));
@@ -399,7 +592,7 @@ public function testDisksDoNotExposePoolConstructionMetadata(): void
'pool' => ['max_objects' => 2],
]);
$clientPooled = $manager->build([
- ...$this->s3Config('pooled-cloud'),
+ ...$this->s3DiskConfig('pooled-cloud'),
'pool' => ['max_objects' => 2],
]);
@@ -1237,8 +1430,8 @@ public function testS3DisksWithTheSameClientConfigShareOneClientPoolAcrossBucket
{
$container = $this->getContainer([
'disks' => [
- 'documents' => $this->s3Config('documents'),
- 'archives' => $this->s3Config('archives'),
+ 'documents' => $this->s3DiskConfig('documents'),
+ 'archives' => $this->s3DiskConfig('archives'),
],
]);
Container::setInstance($container);
@@ -1257,12 +1450,68 @@ public function testS3DisksWithTheSameClientConfigShareOneClientPoolAcrossBucket
$this->assertCount(1, $container->make(PoolFactory::class)->getPools());
}
+ public function testNamedS3ProvidersKeepStableAndDistinctClientPools(): void
+ {
+ $container = $this->getContainer();
+ $manager = new FilesystemManager($container);
+ $poolNames = [];
+
+ foreach (['ecs', 'instance', ['provider' => 'ecs', 'timeout' => 2], ['provider' => 'ecs', 'timeout' => 3]] as $credentials) {
+ $config = [...$this->s3DiskConfig('documents'), 'credentials' => $credentials];
+ $first = $manager->build($config);
+ $second = $manager->build([...$config, 'bucket' => 'archives']);
+
+ $this->assertSame($first->getPoolName(), $second->getPoolName());
+ $this->assertSame(
+ $first->withClient(static fn (S3Client $client): int => spl_object_id($client)),
+ $second->withClient(static fn (S3Client $client): int => spl_object_id($client)),
+ );
+ $poolNames[] = $first->getPoolName();
+ }
+
+ $this->assertCount(4, array_unique($poolNames));
+ $this->assertCount(4, $container->make(PoolFactory::class)->getPools());
+ }
+
+ #[TestWith([false, false])]
+ #[TestWith([true, false])]
+ #[TestWith([true, true])]
+ public function testS3ArrayCallableCredentialsTakePrecedenceOverLegacyKeys(bool $nested, bool $clientOverride): void
+ {
+ $provider = new class {
+ /**
+ * Return credentials from the explicitly configured provider.
+ */
+ public function resolve(): PromiseInterface
+ {
+ return Create::promiseFor(new Credentials('provider-key', 'provider-secret'));
+ }
+ };
+ $credentials = [$provider, 'resolve'];
+ $config = [
+ ...$this->s3DiskConfig('documents', 'legacy-key'),
+ 'credentials' => $nested ? ['provider' => $credentials] : $credentials,
+ 'pool' => ['fingerprint' => 'array-provider'],
+ ];
+
+ if ($clientOverride) {
+ $config['client']['credentials'] = $config['credentials'];
+ unset($config['credentials']);
+ }
+
+ $disk = (new FilesystemManager($this->getContainer()))->build($config);
+
+ $this->assertSame('provider-key', $disk->withClient(
+ static fn (S3Client $client): string => $client->getCredentials()->wait()->getAccessKeyId(),
+ ));
+ }
+
public function testS3DisksWithDifferentCredentialsUseDifferentClientPools(): void
{
$container = $this->getContainer([
'disks' => [
- 'first' => $this->s3Config('documents', 'first-key'),
- 'second' => $this->s3Config('documents', 'second-key'),
+ 'first' => $this->s3DiskConfig('documents', 'first-key'),
+ 'second' => $this->s3DiskConfig('documents', 'second-key'),
],
]);
Container::setInstance($container);
@@ -1283,7 +1532,7 @@ public function testRepeatedOnDemandS3BuildsConvergeWithoutNameCollisions(): voi
$container = $this->getContainer();
Container::setInstance($container);
$manager = new FilesystemManager($container);
- $config = $this->s3Config('documents');
+ $config = $this->s3DiskConfig('documents');
$first = $manager->build($config);
$second = $manager->build($config);
@@ -1298,13 +1547,13 @@ public function testRepeatedOnDemandS3BuildsConvergeWithoutNameCollisions(): voi
public function testExplicitPoolNamesAndFingerprintsDeclareConstructionEquivalence(): void
{
- $namedFirst = $this->s3Config('first-bucket', 'first-key');
+ $namedFirst = $this->s3DiskConfig('first-bucket', 'first-key');
$namedFirst['pool'] = ['name' => 'shared-account', 'fingerprint' => 'account-v1'];
- $namedSecond = $this->s3Config('second-bucket', 'second-key');
+ $namedSecond = $this->s3DiskConfig('second-bucket', 'second-key');
$namedSecond['pool'] = ['name' => 'shared-account', 'fingerprint' => 'account-v1'];
- $fingerprintedFirst = $this->s3Config('third-bucket', 'third-key');
+ $fingerprintedFirst = $this->s3DiskConfig('third-bucket', 'third-key');
$fingerprintedFirst['pool'] = ['fingerprint' => 'declared-equivalent'];
- $fingerprintedSecond = $this->s3Config('fourth-bucket', 'fourth-key');
+ $fingerprintedSecond = $this->s3DiskConfig('fourth-bucket', 'fourth-key');
$fingerprintedSecond['pool'] = ['fingerprint' => 'declared-equivalent'];
$container = $this->getContainer([
'disks' => [
@@ -1368,9 +1617,9 @@ public function testObjectClientOptionsRequireAnExplicitFingerprint(): void
public function testConvergedDisksRejectConflictingPoolOptions(): void
{
- $firstConfig = $this->s3Config('documents');
+ $firstConfig = $this->s3DiskConfig('documents');
$firstConfig['pool'] = ['max_objects' => 1];
- $secondConfig = $this->s3Config('archives');
+ $secondConfig = $this->s3DiskConfig('archives');
$secondConfig['pool'] = ['max_objects' => 2];
$container = $this->getContainer([
'disks' => [
@@ -1395,7 +1644,7 @@ public function testConvergedDisksRejectConflictingPoolOptions(): void
public function testScopedClientPoolOptionsMustMatchTheBaseDisk(): void
{
- $baseConfig = $this->s3Config('documents');
+ $baseConfig = $this->s3DiskConfig('documents');
$baseConfig['pool'] = ['max_objects' => 1];
$container = $this->getContainer([
'disks' => [
@@ -1424,7 +1673,7 @@ public function testScopedClientPoolOptionsMustMatchTheBaseDisk(): void
public function testForgetDiskDropsOnlyTheWrapperAndPreservesTheSharedPool(): void
{
$container = $this->getContainer([
- 'disks' => ['documents' => $this->s3Config('documents')],
+ 'disks' => ['documents' => $this->s3DiskConfig('documents')],
]);
Container::setInstance($container);
$manager = new FilesystemManager($container);
@@ -1447,8 +1696,8 @@ public function testPurgeClosesCachedAndNeverCachedClientPools(): void
{
$container = $this->getContainer([
'disks' => [
- 'documents' => $this->s3Config('documents'),
- 'archives' => $this->s3Config('archives'),
+ 'documents' => $this->s3DiskConfig('documents'),
+ 'archives' => $this->s3DiskConfig('archives'),
],
]);
Container::setInstance($container);
@@ -1461,7 +1710,7 @@ public function testPurgeClosesCachedAndNeverCachedClientPools(): void
$this->assertFalse($container->make(PoolFactory::class)->has($identity));
- $manager->build($this->s3Config('archives'))
+ $manager->build($this->s3DiskConfig('archives'))
->withClient(static fn (object $client): object => $client);
$this->assertTrue($container->make(PoolFactory::class)->has($identity));
@@ -1489,7 +1738,7 @@ public function testNeverCachedScopedPurgeClosesAClientPoolCreatedThroughEveryEq
foreach (['parent disk', 'another scoped disk', 'on-demand build'] as $source) {
$container = $this->getContainer([
'disks' => [
- 'cloud' => $this->s3Config('documents'),
+ 'cloud' => $this->s3DiskConfig('documents'),
'target' => [
'driver' => 'scoped',
'disk' => 'cloud',
@@ -1507,7 +1756,7 @@ public function testNeverCachedScopedPurgeClosesAClientPoolCreatedThroughEveryEq
$disk = match ($source) {
'parent disk' => $manager->disk('cloud'),
'another scoped disk' => $manager->disk('other'),
- 'on-demand build' => $manager->build($this->s3Config('archives')),
+ 'on-demand build' => $manager->build($this->s3DiskConfig('archives')),
};
$disk->withClient(static fn (object $client): object => $client);
$identity = $disk->getPoolName();
@@ -1594,7 +1843,7 @@ public function testNestedScopedDisksComposePrefixesAndPurgeTheSameClientPool():
];
$container = $this->getContainer([
'disks' => [
- 'cloud' => $this->s3Config('documents'),
+ 'cloud' => $this->s3DiskConfig('documents'),
'inner' => [
'driver' => 'scoped',
'disk' => 'cloud',
@@ -1621,7 +1870,7 @@ public function testCachedScopedPurgeClosesItsClientPool(): void
{
$container = $this->getContainer([
'disks' => [
- 'cloud' => $this->s3Config('documents'),
+ 'cloud' => $this->s3DiskConfig('documents'),
'scoped' => [
'driver' => 'scoped',
'disk' => 'cloud',
@@ -2021,7 +2270,7 @@ public function testExplicitFingerprintOverridesWholeDriverConstructionIdentity(
public function testScopedS3BuildsShareTheParentClientPoolWithoutCollisions(): void
{
$container = $this->getContainer([
- 'disks' => ['cloud' => $this->s3Config('documents')],
+ 'disks' => ['cloud' => $this->s3DiskConfig('documents')],
]);
Container::setInstance($container);
$manager = new FilesystemManager($container);
@@ -2340,7 +2589,10 @@ public function testDriveAcceptsStringBackedEnum(): void
$this->assertInstanceOf(Filesystem::class, $disk);
}
- private function s3Config(string $bucket, string $key = 'test-key'): array
+ /**
+ * Create an S3 disk configuration with static credentials.
+ */
+ private function s3DiskConfig(string $bucket, string $key = 'test-key'): array
{
return [
'driver' => 's3',
@@ -2412,6 +2664,14 @@ public function __invoke(): Filesystem
class InspectableFilesystemManager extends FilesystemManager
{
+ /**
+ * Expose S3 configuration formatting for credential assertions.
+ */
+ public function formatS3ConfigForTest(array $config): array
+ {
+ return $this->formatS3Config($config);
+ }
+
public function callCustomCreatorForTest(array $config): Filesystem
{
return $this->callCustomCreator($config);
From 0516ed480110b8535dfd0fe571def8e907f1fe97 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 11:35:33 +0000
Subject: [PATCH 06/20] Add case-insensitive single-column schema inspection
Add Schema::getColumn() to return the metadata for one column, or an empty array when no column matches. Reuse the existing driver-neutral metadata lookup and normalize the requested name once before scanning columns.
Reconcile Laravel framework #61759 (9634291245fabb30822f5d82945628b3fd6834a9) and its complete case-insensitive follow-up #61967 (f33f3958266eec0e7717c09329e1272597a7adfc), using the final implementation at 5d7797528723107cd4f495326838a7d7d67fbb20. Regenerate the Schema facade and document the lookup beside the other inspection methods.
Port all upstream table, view, missing-column, case-insensitive and schema-qualified assertions. Preserve native typing and describe both the metadata and empty-array return shapes. Correct the upstream test spelling and expected/actual argument order.
Validation: formatting, full source/type/skeleton analysis, schema integration tests on isolated SQLite, and generated facade checks pass. Driver-specific cases remain limited to their supported database engines.
---
src/database/src/Schema/Builder.php | 18 +++++
src/docs/database.md | 6 ++
src/support/src/Facades/Schema.php | 1 +
.../Database/SchemaBuilderSchemaNameTest.php | 4 ++
.../Database/SchemaBuilderTest.php | 68 +++++++++++++++++++
5 files changed, 97 insertions(+)
diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php
index b343a520b..2d676abeb 100755
--- a/src/database/src/Schema/Builder.php
+++ b/src/database/src/Schema/Builder.php
@@ -352,6 +352,24 @@ public function getColumnListing(string $table): array
return array_column($this->getColumns($table), 'name');
}
+ /**
+ * Get the column for a given table.
+ *
+ * @return array{name: string, type: string, type_name: string, collation: null|string, nullable: bool, default: mixed, auto_increment: bool, comment: null|string, generation: null|array{type: null|string, expression: null|string}}|array{}
+ */
+ public function getColumn(string $table, string $column): array
+ {
+ $column = strtolower($column);
+
+ foreach ($this->getColumns($table) as $value) {
+ if (strtolower($value['name']) === $column) {
+ return $value;
+ }
+ }
+
+ return [];
+ }
+
/**
* Get the columns for a given table.
*
diff --git a/src/docs/database.md b/src/docs/database.md
index d32dd3c48..23065b8c4 100644
--- a/src/docs/database.md
+++ b/src/docs/database.md
@@ -1071,6 +1071,12 @@ $indexes = Schema::getIndexes('users');
$foreignKeys = Schema::getForeignKeys('users');
```
+To inspect a single column, use the `getColumn` method. Column names are matched without regard to case, and an empty array is returned if the column does not exist:
+
+```php
+$column = Schema::getColumn('users', 'email');
+```
+
If you would like to inspect a database connection that is not your application's default connection, you may use the `connection` method:
```php
diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php
index 48fa53d97..14a01ebca 100644
--- a/src/support/src/Facades/Schema.php
+++ b/src/support/src/Facades/Schema.php
@@ -29,6 +29,7 @@
* @method static void executeBlueprint(\Hypervel\Database\Schema\Blueprint $blueprint)
* @method static void flushMacros()
* @method static void flushState()
+ * @method static array getColumn(string $table, string $column)
* @method static array getColumnListing(string $table)
* @method static array getColumns(string $table)
* @method static string getColumnType(string $table, string $column, bool $fullDefinition = false)
diff --git a/tests/Integration/Database/SchemaBuilderSchemaNameTest.php b/tests/Integration/Database/SchemaBuilderSchemaNameTest.php
index a987d75d9..f7eff24ab 100644
--- a/tests/Integration/Database/SchemaBuilderSchemaNameTest.php
+++ b/tests/Integration/Database/SchemaBuilderSchemaNameTest.php
@@ -220,9 +220,13 @@ public function testAddColumns($connection): void
$this->assertEquals(['id', 'title', 'name', 'count'], $schema->getColumnListing('my_schema.table'));
$this->assertEquals(['id', 'name', 'count', 'title'], $schema->getColumnListing('my_table'));
$this->assertStringContainsString('default schema name', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'name')['default']);
+ $this->assertStringContainsString('default schema name', $schema->getColumn('my_schema.table', 'name')['default']);
$this->assertStringContainsString('default schema title', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'title')['default']);
+ $this->assertStringContainsString('default schema title', $schema->getColumn('my_schema.table', 'title')['default']);
$this->assertStringContainsString('default name', collect($schema->getColumns('my_table'))->firstWhere('name', 'name')['default']);
+ $this->assertStringContainsString('default name', $schema->getColumn('my_table', 'name')['default']);
$this->assertStringContainsString('default title', collect($schema->getColumns('my_table'))->firstWhere('name', 'title')['default']);
+ $this->assertStringContainsString('default title', $schema->getColumn('my_table', 'title')['default']);
}
#[DataProvider('connectionProvider')]
diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php
index b38b1ca34..b8fd9f956 100644
--- a/tests/Integration/Database/SchemaBuilderTest.php
+++ b/tests/Integration/Database/SchemaBuilderTest.php
@@ -423,6 +423,55 @@ public function testGetColumns(): void
));
}
+ public function testGetColumnsWithUnknownTable(): void
+ {
+ Schema::create('foo', function (Blueprint $table) {
+ $table->id();
+ $table->string('bar')->nullable();
+ });
+
+ $columns = Schema::getColumns('baz');
+
+ $this->assertIsArray($columns);
+ $this->assertEmpty($columns);
+ }
+
+ public function testGetColumn(): void
+ {
+ Schema::create('foo', function (Blueprint $table) {
+ $table->id();
+ $table->string('bar')->nullable();
+ $table->string('baz')->default('test');
+ });
+
+ $column = Schema::getColumn('foo', 'bar');
+
+ $this->assertSame('bar', $column['name']);
+ $this->assertTrue($column['nullable']);
+ }
+
+ public function testGetColumnIsCaseInsensitive(): void
+ {
+ Schema::create('foo', function (Blueprint $table) {
+ $table->string('bar');
+ });
+
+ $this->assertSame('bar', Schema::getColumn('foo', 'BAR')['name']);
+ }
+
+ public function testGetColumnWithUnknownColumn(): void
+ {
+ Schema::create('foo', function (Blueprint $table) {
+ $table->id();
+ $table->string('bar')->nullable();
+ });
+
+ $column = Schema::getColumn('foo', 'baz');
+
+ $this->assertIsArray($column);
+ $this->assertEmpty($column);
+ }
+
public function testGetColumnsOnView(): void
{
DB::statement('create view foo (bar) as select 1');
@@ -433,6 +482,25 @@ public function testGetColumnsOnView(): void
$this->assertSame('bar', $columns[0]['name']);
}
+ public function testGetColumnOnView(): void
+ {
+ DB::statement('create view foo (bar) as select 1');
+
+ $column = Schema::getColumn('foo', 'bar');
+
+ $this->assertSame('bar', $column['name']);
+ }
+
+ public function testGetColumnOnViewWithUnknownColumn(): void
+ {
+ DB::statement('create view foo (bar) as select 1');
+
+ $column = Schema::getColumn('foo', 'baz');
+
+ $this->assertIsArray($column);
+ $this->assertEmpty($column);
+ }
+
public function testGetIndexes(): void
{
Schema::create('foo', function (Blueprint $table) {
From 6708671aa01ea02693c668bd3a25c8d6c6730e3d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 11:35:49 +0000
Subject: [PATCH 07/20] Preserve polymorphic identity in through relationships
and aggregates
Recognize MorphMany on either leg when constructing fluent through relationships, matching Laravel framework #61760 (0414dd405b3ccd2bd12ce492adfba5e761729fe0) at target 5d7797528723107cd4f495326838a7d7d67fbb20. Carry the exact local relationship type through generic inference and declare the two possible native return types.
Correct related upstream defects: distant morph relations lost their type discriminator, and local discriminators never reached one-of-many aggregate subqueries. Both could select records belonging to another model with the same numeric key. Record the morph predicates on the through relation, preserve them during one() conversion, and apply them through the existing subquery hook at every aggregate level. Existing outer predicates are retained without duplication; arbitrary relationship constraints are not copied.
Port the upstream cardinality regression, Profile fixture and distant MorphMany type assertion. Add local MorphMany/MorphOne type coverage and real-query regressions for other-type records, including a tied multi-column aggregate after one(). These regressions fail with the upstream cardinality change alone and pass with the complete correction.
Validation: formatting, full source/type/skeleton analysis and the through-relationship unit and integration suites pass on SQLite.
---
.../Eloquent/Concerns/HasRelationships.php | 10 +-
.../PendingHasThroughRelationship.php | 18 ++-
.../src/Eloquent/Relations/HasManyThrough.php | 6 +-
.../Relations/HasOneOrManyThrough.php | 20 ++++
.../src/Eloquent/Relations/HasOneThrough.php | 4 +
.../Database/EloquentThroughTest.php | 113 ++++++++++++++++++
types/Database/Eloquent/Relations.php | 14 +++
7 files changed, 171 insertions(+), 14 deletions(-)
diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php
index 1d0d59b45..cb21554ca 100644
--- a/src/database/src/Eloquent/Concerns/HasRelationships.php
+++ b/src/database/src/Eloquent/Concerns/HasRelationships.php
@@ -476,18 +476,14 @@ protected function guessBelongsToRelation(): string
* Create a pending has-many-through or has-one-through relationship.
*
* @template TIntermediateModel of Model
+ * @template TLocalRelationship of HasOneOrMany
*
- * @param HasMany|HasOne|string $relationship
+ * @param string|TLocalRelationship $relationship
* @return (
* $relationship is string
* ? PendingHasThroughRelationship>
- * : (
- * $relationship is HasMany
- * ? PendingHasThroughRelationship>
- * : PendingHasThroughRelationship>
- * )
+ * : PendingHasThroughRelationship
* )
- * @phpstan-ignore conditionalType.alwaysFalse (template covariance limitation with conditional return types)
*/
public function through(string|HasOneOrMany $relationship): PendingHasThroughRelationship
{
diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php
index 3985ba4eb..177c9cca0 100644
--- a/src/database/src/Eloquent/PendingHasThroughRelationship.php
+++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php
@@ -10,6 +10,7 @@
use Hypervel\Database\Eloquent\Relations\HasOne;
use Hypervel\Database\Eloquent\Relations\HasOneOrMany;
use Hypervel\Database\Eloquent\Relations\HasOneThrough;
+use Hypervel\Database\Eloquent\Relations\MorphMany;
use Hypervel\Database\Eloquent\Relations\MorphOneOrMany;
use Hypervel\Support\Str;
use Hypervel\Support\Stringable;
@@ -52,22 +53,22 @@ public function __construct(Model $rootModel, HasOneOrMany $localRelationship)
*
* @template TRelatedModel of Model
*
- * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphOneOrMany))|string $callback
+ * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphMany|MorphOneOrMany))|string $callback
* @return (
* $callback is string
* ? HasManyThrough|HasOneThrough
* : (
- * TLocalRelationship is HasMany
+ * TLocalRelationship is HasMany|MorphMany
* ? HasManyThrough
* : (
- * $callback is callable(TIntermediateModel): HasMany
+ * $callback is callable(TIntermediateModel): (HasMany|MorphMany)
* ? HasManyThrough
* : HasOneThrough
* )
* )
* )
*/
- public function has(callable|string $callback): mixed
+ public function has(callable|string $callback): HasManyThrough|HasOneThrough
{
if (is_string($callback)) {
$callback = fn () => $this->localRelationship->getRelated()->{$callback}();
@@ -75,7 +76,8 @@ public function has(callable|string $callback): mixed
$distantRelation = $callback($this->localRelationship->getRelated());
- if ($distantRelation instanceof HasMany || $this->localRelationship instanceof HasMany) {
+ if ($distantRelation instanceof HasMany || $distantRelation instanceof MorphMany
+ || $this->localRelationship instanceof HasMany || $this->localRelationship instanceof MorphMany) {
$returnedRelation = $this->rootModel->hasManyThrough(
$distantRelation->getRelatedClass(),
$this->localRelationship->getRelatedClass(),
@@ -96,7 +98,11 @@ public function has(callable|string $callback): mixed
}
if ($this->localRelationship instanceof MorphOneOrMany) {
- $returnedRelation->where($this->localRelationship->getQualifiedMorphType(), $this->localRelationship->getMorphClass());
+ $returnedRelation->whereMorphClass($this->localRelationship->getQualifiedMorphType(), $this->localRelationship->getMorphClass());
+ }
+
+ if ($distantRelation instanceof MorphOneOrMany) {
+ $returnedRelation->whereMorphClass($distantRelation->getQualifiedMorphType(), $distantRelation->getMorphClass());
}
return $returnedRelation;
diff --git a/src/database/src/Eloquent/Relations/HasManyThrough.php b/src/database/src/Eloquent/Relations/HasManyThrough.php
index 43e52a503..2e481c18a 100644
--- a/src/database/src/Eloquent/Relations/HasManyThrough.php
+++ b/src/database/src/Eloquent/Relations/HasManyThrough.php
@@ -27,7 +27,7 @@ class HasManyThrough extends HasOneOrManyThrough
*/
public function one(): HasOneThrough
{
- return HasOneThrough::noConstraints(fn () => new HasOneThrough(
+ $relation = HasOneThrough::noConstraints(fn () => new HasOneThrough(
tap($this->getQuery(), fn (Builder $query) => $query->getQuery()->joins = []),
$this->farParent,
$this->throughParent,
@@ -36,6 +36,10 @@ public function one(): HasOneThrough
$this->getLocalKeyName(),
$this->getSecondLocalKeyName(),
));
+
+ $relation->morphConstraints = $this->morphConstraints;
+
+ return $relation;
}
public function initRelation(array $models, string $relation): array
diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
index de72b1012..223f9be7a 100644
--- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
+++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
@@ -70,6 +70,13 @@ abstract class HasOneOrManyThrough extends Relation
*/
protected string $secondLocalKey;
+ /**
+ * Morph identities that also constrain one-of-many subqueries.
+ *
+ * @var array
+ */
+ protected array $morphConstraints = [];
+
/**
* Create a new has many through relationship instance.
*
@@ -105,6 +112,19 @@ public function addConstraints(): void
}
}
+ /**
+ * Constrain the relationship to records of the given morph class.
+ *
+ * @return $this
+ */
+ public function whereMorphClass(string $column, string $morphClass): static
+ {
+ $this->morphConstraints[$column] = $morphClass;
+ $this->query->where($column, $morphClass);
+
+ return $this;
+ }
+
/**
* Set the join clause on the query.
*
diff --git a/src/database/src/Eloquent/Relations/HasOneThrough.php b/src/database/src/Eloquent/Relations/HasOneThrough.php
index d376fc086..df6a80918 100644
--- a/src/database/src/Eloquent/Relations/HasOneThrough.php
+++ b/src/database/src/Eloquent/Relations/HasOneThrough.php
@@ -82,6 +82,10 @@ public function addOneOfManySubQueryConstraints(Builder $query, ?string $column
{
$query->addSelect([$this->getQualifiedFirstKeyName()]);
+ foreach ($this->morphConstraints as $morphColumn => $morphClass) {
+ $query->where($morphColumn, $morphClass);
+ }
+
// We need to join subqueries that aren't the inner-most subquery which is joined in the CanBeOneOfMany::ofMany method...
if ($this->getOneOfManySubQuery() !== null) {
// @phpstan-ignore argument.type (Builder param typed without template in inherited interface)
diff --git a/tests/Integration/Database/EloquentThroughTest.php b/tests/Integration/Database/EloquentThroughTest.php
index 0b6cab4f4..4ab6a0481 100644
--- a/tests/Integration/Database/EloquentThroughTest.php
+++ b/tests/Integration/Database/EloquentThroughTest.php
@@ -8,7 +8,10 @@
use Hypervel\Database\Eloquent\Relations\BelongsTo;
use Hypervel\Database\Eloquent\Relations\HasMany;
use Hypervel\Database\Eloquent\Relations\HasManyThrough;
+use Hypervel\Database\Eloquent\Relations\HasOne;
+use Hypervel\Database\Eloquent\Relations\HasOneThrough;
use Hypervel\Database\Eloquent\Relations\MorphMany;
+use Hypervel\Database\Eloquent\Relations\MorphOne;
use Hypervel\Database\Eloquent\Relations\MorphTo;
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Support\Facades\Schema;
@@ -38,6 +41,22 @@ protected function afterRefreshingDatabase(): void
$table->unsignedInteger('comment_id');
});
+ Schema::create('profiles', function (Blueprint $table) {
+ $table->increments('id');
+ $table->unsignedInteger('comment_id');
+ $table->integer('rank')->default(0);
+ });
+
+ Schema::create('texts', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('post_id');
+ });
+
+ Schema::create('images', function (Blueprint $table) {
+ $table->id();
+ $table->morphs('imageable');
+ });
+
$post = tap(new Post(['public' => true]))->save();
$comment = tap((new Comment)->commentable()->associate($post))->save();
(new Like)->comment()->associate($comment)->save();
@@ -54,6 +73,54 @@ public function test(): void
$post = Post::first();
$this->assertEquals(2, $post->commentLikes()->count());
}
+
+ public function testMorphManyLocalRelationshipReturnsHasManyThrough(): void
+ {
+ /** @var Post $post */
+ $post = Post::first();
+ tap((new Comment)->commentable()->associate($post))->save();
+ $post->comments->each(fn ($comment) => $comment->profile()->create());
+
+ $this->assertInstanceOf(HasManyThrough::class, $post->commentProfiles());
+ $this->assertCount(2, $post->commentProfiles);
+ }
+
+ public function testDistantMorphManyExcludesOtherMorphTypes(): void
+ {
+ $post = Post::firstOrFail();
+ $text = $post->texts()->create();
+ $images = $text->images()->createMany([[], []]);
+ Image::create(['imageable_id' => $text->getKey(), 'imageable_type' => Post::class]);
+
+ $relation = $post->through($post->texts()->one())->has('images');
+
+ $this->assertInstanceOf(HasManyThrough::class, $relation);
+ $this->assertEqualsCanonicalizing($images->modelKeys(), $relation->get()->modelKeys());
+ }
+
+ public function testDistantMorphOneExcludesOtherMorphTypes(): void
+ {
+ $post = Post::firstOrFail();
+ $text = $post->texts()->create();
+ Image::create(['imageable_id' => $text->getKey(), 'imageable_type' => Post::class]);
+ $image = $text->image()->create();
+
+ $relation = $post->through($post->texts()->one())->has('image');
+
+ $this->assertInstanceOf(HasOneThrough::class, $relation);
+ $this->assertTrue($image->is($relation->getResults()));
+ }
+
+ public function testOneOfManyKeepsMorphConstraintsInEveryAggregate(): void
+ {
+ $post = Post::firstOrFail();
+ $profile = $post->comments()->firstOrFail()->profile()->create(['rank' => 10]);
+ OtherCommentable::firstOrFail()->comments()->firstOrFail()->profile()->create(['rank' => 10]);
+
+ $relation = $post->commentProfiles()->one()->ofMany(['rank' => 'MAX', 'id' => 'MAX']);
+
+ $this->assertTrue($profile->is($relation->getResults()));
+ }
}
class Comment extends Model
@@ -69,6 +136,14 @@ public function likes(): HasMany
{
return $this->hasMany(Like::class);
}
+
+ /**
+ * Get the comment's profile.
+ */
+ public function profile(): HasOne
+ {
+ return $this->hasOne(Profile::class);
+ }
}
class Post extends Model
@@ -89,6 +164,14 @@ public function commentLikes(): HasManyThrough
return $this->through($this->comments())->has('likes');
}
+ /**
+ * Get profiles through the post's comments.
+ */
+ public function commentProfiles(): HasManyThrough
+ {
+ return $this->through($this->comments())->has('profile');
+ }
+
public function texts(): HasMany
{
return $this->hasMany(Text::class);
@@ -115,6 +198,22 @@ public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
+
+ /**
+ * Get the text's images.
+ */
+ public function images(): MorphMany
+ {
+ return $this->morphMany(Image::class, 'imageable');
+ }
+
+ /**
+ * Get the text's image.
+ */
+ public function image(): MorphOne
+ {
+ return $this->images()->one();
+ }
}
class Like extends Model
@@ -126,3 +225,17 @@ public function comment(): BelongsTo
return $this->belongsTo(Comment::class);
}
}
+
+class Profile extends Model
+{
+ public bool $timestamps = false;
+
+ protected array $guarded = [];
+}
+
+class Image extends Model
+{
+ public bool $timestamps = false;
+
+ protected array $guarded = [];
+}
diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php
index e9015268f..0ce3e917e 100644
--- a/types/Database/Eloquent/Relations.php
+++ b/types/Database/Eloquent/Relations.php
@@ -300,6 +300,10 @@ public function parts(): HasManyThrough
'Hypervel\Database\Eloquent\Relations\HasManyThrough',
$this->through($this->mechanic())->has(fn ($mechanic) => $mechanic->parts()),
);
+ assertType(
+ 'Hypervel\Database\Eloquent\Relations\HasManyThrough',
+ $this->through($this->mechanic())->has(fn ($mechanic) => $mechanic->morphMany(Part::class, 'partable')),
+ );
return $hasManyThrough;
}
@@ -340,6 +344,11 @@ public function comments(): MorphMany
$morphMany = $this->morphMany(Comment::class, 'commentable');
assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $morphMany);
+ assertType(
+ 'Hypervel\Database\Eloquent\Relations\HasManyThrough',
+ $this->through($morphMany)->has(fn ($comment) => $comment->hasOne(Image::class)),
+ );
+
return $morphMany;
}
@@ -349,6 +358,11 @@ public function latestComment(): MorphOne
$comment = $this->comments()->one();
assertType('Hypervel\Database\Eloquent\Relations\MorphOne', $comment);
+ assertType(
+ 'Hypervel\Database\Eloquent\Relations\HasOneThrough',
+ $this->through($comment)->has(fn ($comment) => $comment->hasOne(Image::class)),
+ );
+
return $comment;
}
From 29bfa978751b89074e7004cf002e5f01206b8549 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 13:14:11 +0000
Subject: [PATCH 08/20] Exclude refreshed attributes when replicating models
Replicated models now leave database-generated attributes for the database to populate, preventing inserts that explicitly assign generated columns. Both replicate() and replicateQuietly() use the same default exclusions.
Port Laravel framework #61751 (d9319da66a67cd840415c4ba7e1b0beaf846b568), including the saved-replica ID and generated-value assertions. Preserve the existing configured Refreshes behavior and explicit exclusion semantics.
Validated the complete model integration test file on SQLite, affected tests, formatting and static analysis.
---
src/database/src/Eloquent/Model.php | 1 +
.../DatabaseEloquentIntegrationTest.php | 20 +++++++++++++++++++
2 files changed, 21 insertions(+)
diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php
index dbc4984ec..b5ae0d7c9 100644
--- a/src/database/src/Eloquent/Model.php
+++ b/src/database/src/Eloquent/Model.php
@@ -2338,6 +2338,7 @@ public function replicate(?array $except = null): static
$this->getCreatedAtColumn(),
$this->getUpdatedAtColumn(),
...$this->uniqueIds(),
+ ...$this->refreshes,
'hypervel_through_key',
]));
diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php
index c43989673..bbcafa774 100644
--- a/tests/Database/DatabaseEloquentIntegrationTest.php
+++ b/tests/Database/DatabaseEloquentIntegrationTest.php
@@ -368,6 +368,26 @@ public function testConfiguredAttributesAreRefreshedAfterIncrementAndDecrement()
$user->save();
}
+ public function testConfiguredAttributesAreExcludedWhenReplicating(): void
+ {
+ $user = GeneratedUser::create([
+ 'first_name' => 'Taylor',
+ 'last_name' => 'Otwell',
+ ]);
+
+ $replica = $user->replicate();
+ $replica->save();
+
+ $this->assertSame(2, $replica->id);
+ $this->assertSame('Taylor Otwell', $replica->name);
+
+ $quietReplica = $user->replicateQuietly();
+ $quietReplica->save();
+
+ $this->assertSame(3, $quietReplica->id);
+ $this->assertSame('Taylor Otwell', $quietReplica->name);
+ }
+
public function testRefreshedAttributesAreNotWrittenBackAfterIncrements(): void
{
$item = GeneratedTotal::create(['price' => 2, 'quantity' => 3, 'options' => ['color' => 'red']]);
From 718f8bedfdd2fc2e390fe62cdfee419ffdb6c5bf Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 13:14:11 +0000
Subject: [PATCH 09/20] Cover pivot range filters in relationship writes
Port the regression from Laravel framework #61747 (ac73dc63de3baff54444e7374a7b37e74a51042b) for sync, detach and updateExistingPivot with between and not-between filters.
Hypervel already applies these filters through its ordered pivotConstraints list. Retain that implementation and document the corresponding upstream pivotWhereBetweens property alongside the other replaced filter arrays.
Validated the complete belongs-to-many integration test file on SQLite and the affected checks.
---
src/database/README.md | 2 +-
.../Database/EloquentBelongsToManyTest.php | 33 +++++++++++++++++++
2 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/src/database/README.md b/src/database/README.md
index a88ad86af..f59b05e49 100644
--- a/src/database/README.md
+++ b/src/database/README.md
@@ -28,6 +28,6 @@ Documentation: https://hypervel.org/docs/database
- Eloquent expression plucks use `Query\Builder::pluckWithColumn()` to retain the returned field name for casts and accessors. Custom query builders overriding `pluck()` must also override `pluckWithColumn()` to customize this path. String-based Eloquent plucks still call `pluck()`.
- Eloquent's protected `refreshSavedAttributes()` method receives the raw attribute values each write stored, so configured attributes are refreshed from the row that write changed. Models that override this method must accept the `array $attributes` argument.
- Eloquent `updateFrom()` maintains the model's `updated_at` timestamp, like `update()`. Laravel forwards this method without adding a timestamp.
-- `BelongsToMany` records pivot filters in an ordered `$pivotConstraints` list instead of Laravel's `$pivotWheres`, `$pivotWhereIns`, and `$pivotWhereNulls` properties. Relationship extensions should use the `wherePivot*` methods instead of accessing those arrays.
+- `BelongsToMany` records pivot filters in an ordered `$pivotConstraints` list instead of Laravel's `$pivotWheres`, `$pivotWhereIns`, `$pivotWhereNulls`, and `$pivotWhereBetweens` properties. Relationship extensions should use the `wherePivot*` methods instead of accessing those arrays.
Ported from: https://github.com/laravel/framework
diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php
index 73e4d7136..5014ccd57 100644
--- a/tests/Integration/Database/EloquentBelongsToManyTest.php
+++ b/tests/Integration/Database/EloquentBelongsToManyTest.php
@@ -1222,6 +1222,39 @@ public function testWherePivotNotBetween(): void
$this->assertEquals($relationTag->getAttributes(), $tag->getAttributes());
}
+ public function testWherePivotBetweenIsAppliedToPivotTableOperations(): void
+ {
+ $post = Post::create(['title' => Str::random()]);
+ $tag1 = Tag::create(['name' => Str::random()]);
+ $tag2 = Tag::create(['name' => Str::random()]);
+ $tag3 = Tag::create(['name' => Str::random()]);
+ $tag4 = Tag::create(['name' => Str::random()]);
+
+ DB::table('posts_tags')->insert([
+ ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => '2024'],
+ ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => '2025'],
+ ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => '2026'],
+ ]);
+
+ $post->tagsWithExtraPivot()->wherePivotBetween('flag', ['2026', '2026'])->sync([$tag4->id => ['flag' => '2026']]);
+
+ $this->assertEquals(
+ [$tag1->id, $tag2->id, $tag4->id],
+ $post->tagsWithExtraPivot()->orderBy('tags.id')->pluck('tags.id')->all()
+ );
+
+ $post->tagsWithExtraPivot()->wherePivotNotBetween('flag', ['2024', '2025'])->detach();
+
+ $this->assertEquals(
+ [$tag1->id, $tag2->id],
+ $post->tagsWithExtraPivot()->orderBy('tags.id')->pluck('tags.id')->all()
+ );
+
+ $post->tagsWithExtraPivot()->wherePivotBetween('flag', ['2025', '2025'])->updateExistingPivot($tag1->id, ['flag' => 'updated']);
+
+ $this->assertSame('2024', $post->tagsWithExtraPivot()->find($tag1->id)->pivot->flag);
+ }
+
public function testWherePivotInMethod(): void
{
$tag = Tag::create(['name' => Str::random()])->fresh();
From ec671d1adf581b345a2954485e53a7960b233fe8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 13:14:11 +0000
Subject: [PATCH 10/20] Align compiled route scheme handling and coverage
Reconcile Laravel commits 57a5c1e42f2034b64a0c5f56000780d114896a06 and 060b2435a7357f165c876d7dcfad943d6c505e33. Use the upstream match expression when exporting route schemes while retaining scheme-aware compiled matching.
Consolidate the existing regression cases under the upstream test names and placement. Cover HTTP-only and HTTPS-only routes using the same compiled collection, preserving the additional dynamic HTTPS route case.
Validated the complete compiled route collection tests, affected parallel tests, formatting and static analysis.
---
src/routing/src/Route.php | 6 +-
.../Routing/CompiledRouteCollectionTest.php | 68 ++++++++-----------
2 files changed, 33 insertions(+), 41 deletions(-)
diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php
index c049d116b..57510f1ba 100755
--- a/src/routing/src/Route.php
+++ b/src/routing/src/Route.php
@@ -1546,7 +1546,11 @@ public function toSymfonyRoute(): SymfonyRoute
$this->wheres,
['utf8' => true],
$this->getDomain() ?: '',
- $this->httpOnly() ? ['http'] : ($this->httpsOnly() ? ['https'] : []),
+ match (true) {
+ $this->httpOnly() => ['http'],
+ $this->secure() => ['https'],
+ default => [],
+ },
$this->methods
);
}
diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php
index 874d79aee..da0e59e61 100644
--- a/tests/Integration/Routing/CompiledRouteCollectionTest.php
+++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php
@@ -308,6 +308,34 @@ public function testMatchingThrowsNotFoundExceptionWhenRouteIsNotFound(): void
$this->collection()->match(Request::create('/foo'));
}
+ #[TestWith(['account', 'account'])]
+ #[TestWith(['secure/{resource}', 'secure/report'])]
+ public function testMatchingRespectsHttpsOnlyRoutes(string $uri, string $path): void
+ {
+ $this->routeCollection->add($this->newRoute('GET', $uri, ['https', 'uses' => 'FooController@index', 'as' => 'account']));
+
+ $routes = $this->collection();
+
+ $this->assertSame('account', $routes->match(Request::create('https://localhost/' . $path))->getName());
+
+ $this->expectException(NotFoundHttpException::class);
+
+ $routes->match(Request::create('http://localhost/' . $path));
+ }
+
+ public function testMatchingRespectsHttpOnlyRoutes(): void
+ {
+ $this->routeCollection->add($this->newRoute('GET', 'account', ['http', 'uses' => 'FooController@index', 'as' => 'account']));
+
+ $routes = $this->collection();
+
+ $this->assertSame('account', $routes->match(Request::create('http://localhost/account'))->getName());
+
+ $this->expectException(NotFoundHttpException::class);
+
+ $routes->match(Request::create('https://localhost/account'));
+ }
+
public function testMatchingThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAllowed(): void
{
$this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index']));
@@ -540,46 +568,6 @@ public function testCompiledMatchBindsDomainAndPathParameters(): void
$this->assertSame(['tenant' => 'hypervel', 'user' => '42'], $route->originalParameters());
}
- public function testCompiledMatchHonorsHttpsOnlyRoute(): void
- {
- $this->routeCollection->add(
- $this->newRoute('GET', '/secure/{resource}', [
- 'uses' => 'FooController@index',
- 'as' => 'secure',
- 'https',
- ])
- );
-
- $this->assertSame(
- 'secure',
- $this->collection()->match(Request::create('https://example.com/secure/report'))->getName()
- );
-
- $this->expectException(NotFoundHttpException::class);
-
- $this->collection()->match(Request::create('http://example.com/secure/report'));
- }
-
- public function testCompiledMatchHonorsHttpOnlyRoute(): void
- {
- $this->routeCollection->add(
- $this->newRoute('GET', '/insecure', [
- 'uses' => 'FooController@index',
- 'as' => 'insecure',
- 'http',
- ])
- );
-
- $this->assertSame(
- 'insecure',
- $this->collection()->match(Request::create('http://example.com/insecure'))->getName()
- );
-
- $this->expectException(NotFoundHttpException::class);
-
- $this->collection()->match(Request::create('https://example.com/insecure'));
- }
-
public function testCompiledConditionReceivesCompleteRequestContext(): void
{
$this->routeCollection->add(
From c1a1c97ac9e57dd824512bc65d69ff35db68ae01 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 13:14:52 +0000
Subject: [PATCH 11/20] Accept property maps in fake assertions
Port Laravel framework #61770 (473570df55aaaffc9cc74c52a8a615e57abfe67c) and facade update 92dd8c36b3ae15fb25919edd0ec2971527b6feb2. Bus, event, notification and queue fakes now accept property maps through the shared MatchesProperties trait, with strict value comparison and model identity matching.
Preserve callable-array callbacks and widen the native callback types on forwarding paths, including synchronous dispatch, after-response dispatch, on-demand notifications and queue chains. Regenerate the affected facade annotations and port all upstream assertions and fixtures, with focused coverage of the additional typed forwarding paths.
Port the matching event and notification documentation from Laravel docs 226b0649c7. Queue documentation is included with the following queue options change because both edit the same file.
Validated every affected fake test file, facade annotations, affected parallel suites, formatting and full static analysis.
---
src/docs/events.md | 8 +++
src/docs/notifications.md | 8 +++
src/support/src/Facades/Bus.php | 20 ++++----
src/support/src/Facades/Event.php | 6 +--
src/support/src/Facades/Notification.php | 8 +--
src/support/src/Facades/Queue.php | 16 +++---
src/support/src/Testing/Fakes/BusFake.php | 51 ++++++++++++++-----
src/support/src/Testing/Fakes/EventFake.php | 15 ++++--
.../src/Testing/Fakes/MatchesProperties.php | 35 +++++++++++++
.../src/Testing/Fakes/NotificationFake.php | 19 +++++--
src/support/src/Testing/Fakes/QueueFake.php | 45 ++++++++++++----
tests/Support/SupportTestingBusFakeTest.php | 22 ++++++++
tests/Support/SupportTestingEventFakeTest.php | 18 +++++++
.../SupportTestingNotificationFakeTest.php | 25 +++++++++
tests/Support/SupportTestingQueueFakeTest.php | 45 ++++++++++++++++
15 files changed, 282 insertions(+), 59 deletions(-)
create mode 100644 src/support/src/Testing/Fakes/MatchesProperties.php
diff --git a/src/docs/events.md b/src/docs/events.md
index 583273303..0f91278a2 100644
--- a/src/docs/events.md
+++ b/src/docs/events.md
@@ -1302,6 +1302,14 @@ Event::assertDispatched(function (OrderShipped $event) use ($order) {
});
```
+Alternatively, you may pass an array of expected property values as the second argument to `assertDispatched` or `assertNotDispatched`:
+
+```php
+Event::assertDispatched(OrderShipped::class, ['order' => $order]);
+```
+
+All specified properties must match. Values are compared using strict equality, while Eloquent models are compared using their `is` method.
+
If you would simply like to assert that an event listener is listening to a given event, you may use the `assertListening` method:
```php
diff --git a/src/docs/notifications.md b/src/docs/notifications.md
index b4de840af..6f35fdd2b 100644
--- a/src/docs/notifications.md
+++ b/src/docs/notifications.md
@@ -1854,6 +1854,14 @@ Notification::assertSentTo(
);
```
+Alternatively, you may pass an array of expected property values as the third argument to `assertSentTo` or `assertNotSentTo`:
+
+```php
+Notification::assertSentTo($user, OrderShipped::class, ['order' => $order]);
+```
+
+All specified properties must match. Values are compared using strict equality, while Eloquent models are compared using their `is` method. You may also pass an array of expected property values as the second argument to `assertSentOnDemand`.
+
#### On-Demand Notifications
diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php
index 6ac399f51..a7a0fafed 100644
--- a/src/support/src/Facades/Bus.php
+++ b/src/support/src/Facades/Bus.php
@@ -30,27 +30,27 @@
* @method static void assertBatchCount(int $count)
* @method static void assertBatched(array|callable $callback)
* @method static void assertChained(array $expectedChain)
- * @method static void assertDispatched(\Closure|string $command, callable|int|null $callback = null)
- * @method static void assertDispatchedAfterResponse(\Closure|string $command, callable|int|null $callback = null)
+ * @method static void assertDispatched(\Closure|string $command, null|array|callable|int $callback = null)
+ * @method static void assertDispatchedAfterResponse(\Closure|string $command, null|array|callable|int $callback = null)
* @method static void assertDispatchedAfterResponseTimes(\Closure|string $command, int $times = 1)
* @method static void assertDispatchedOnce(\Closure|string $command)
- * @method static void assertDispatchedSync(\Closure|string $command, callable|int|null $callback = null)
+ * @method static void assertDispatchedSync(\Closure|string $command, null|array|callable|int $callback = null)
* @method static void assertDispatchedSyncTimes(\Closure|string $command, int $times = 1)
* @method static void assertDispatchedTimes(\Closure|string $command, int $times = 1)
- * @method static void assertDispatchedWithoutChain(\Closure|string $command, callable|null $callback = null)
- * @method static void assertNotDispatched(\Closure|string $command, callable|null $callback = null)
- * @method static void assertNotDispatchedAfterResponse(\Closure|string $command, callable|null $callback = null)
- * @method static void assertNotDispatchedSync(\Closure|string $command, callable|null $callback = null)
+ * @method static void assertDispatchedWithoutChain(\Closure|string $command, null|array|callable $callback = null)
+ * @method static void assertNotDispatched(\Closure|string $command, null|array|callable $callback = null)
+ * @method static void assertNotDispatchedAfterResponse(\Closure|string $command, null|array|callable $callback = null)
+ * @method static void assertNotDispatchedSync(\Closure|string $command, null|array|callable $callback = null)
* @method static void assertNothingBatched()
* @method static void assertNothingChained()
* @method static void assertNothingDispatched()
* @method static void assertNothingPlaced()
* @method static \Hypervel\Support\Collection batched(callable $callback)
* @method static \Hypervel\Support\Testing\Fakes\ChainedBatchTruthTest chainedBatch(\Closure $callback)
- * @method static \Hypervel\Support\Collection dispatched(string $command, callable|null $callback = null)
- * @method static \Hypervel\Support\Collection dispatchedAfterResponse(string $command, callable|null $callback = null)
+ * @method static \Hypervel\Support\Collection dispatched(string $command, null|array|callable $callback = null)
+ * @method static \Hypervel\Support\Collection dispatchedAfterResponse(string $command, null|array|callable $callback = null)
* @method static array dispatchedBatches()
- * @method static \Hypervel\Support\Collection dispatchedSync(string $command, callable|null $callback = null)
+ * @method static \Hypervel\Support\Collection dispatchedSync(string $command, null|array|callable $callback = null)
* @method static \Hypervel\Bus\Batch dispatchFakeBatch(string $name = '')
* @method static \Hypervel\Support\Testing\Fakes\BusFake except(array|string $jobsToDispatch)
* @method static bool hasDispatched(string $command)
diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php
index b5af9fe17..f0031f2e1 100644
--- a/src/support/src/Facades/Event.php
+++ b/src/support/src/Facades/Event.php
@@ -34,13 +34,13 @@
* @method static \Hypervel\Events\Dispatcher setTransactionManagerResolver(callable $resolver)
* @method static void subscribe(object|string $subscriber)
* @method static mixed until(object|string $event, mixed $payload = [])
- * @method static void assertDispatched(\Closure|string $event, callable|int|null $callback = null)
+ * @method static void assertDispatched(\Closure|string $event, null|array|callable|int $callback = null)
* @method static void assertDispatchedOnce(string $event)
* @method static void assertDispatchedTimes(string $event, int $times = 1)
* @method static void assertListening(string $expectedEvent, array|string $expectedListener)
- * @method static void assertNotDispatched(\Closure|string $event, callable|null $callback = null)
+ * @method static void assertNotDispatched(\Closure|string $event, null|array|callable $callback = null)
* @method static void assertNothingDispatched()
- * @method static \Hypervel\Support\Collection dispatched(string $event, callable|null $callback = null)
+ * @method static \Hypervel\Support\Collection dispatched(string $event, null|array|callable $callback = null)
* @method static array dispatchedEvents()
* @method static \Hypervel\Support\Testing\Fakes\EventFake except(array|string $eventsToDispatch)
* @method static bool hasDispatched(string $event)
diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php
index 284ae5b2e..6b328c765 100644
--- a/src/support/src/Facades/Notification.php
+++ b/src/support/src/Facades/Notification.php
@@ -33,16 +33,16 @@
* @method static void assertCount(int $expectedCount)
* @method static void assertNothingSent()
* @method static void assertNothingSentTo(mixed $notifiable)
- * @method static void assertNotSentTo(mixed $notifiable, \Closure|string $notification, callable|null $callback = null)
- * @method static void assertSentOnDemand(\Closure|string $notification, callable|null $callback = null)
+ * @method static void assertNotSentTo(mixed $notifiable, \Closure|string $notification, null|array|callable $callback = null)
+ * @method static void assertSentOnDemand(\Closure|string $notification, null|array|callable $callback = null)
* @method static void assertSentOnDemandOnce(string $notification)
* @method static void assertSentOnDemandTimes(string $notification, int $times = 1)
* @method static void assertSentTimes(string $notification, int $expectedCount)
- * @method static void assertSentTo(mixed $notifiable, \Closure|string $notification, callable|int|null $callback = null)
+ * @method static void assertSentTo(mixed $notifiable, \Closure|string $notification, null|array|callable|int $callback = null)
* @method static void assertSentToOnce(mixed $notifiable, string $notification)
* @method static void assertSentToTimes(mixed $notifiable, string $notification, int $times = 1)
* @method static bool hasSent(mixed $notifiable, string $notification)
- * @method static \Hypervel\Support\Collection sent(mixed $notifiable, string $notification, callable|null $callback = null)
+ * @method static \Hypervel\Support\Collection sent(mixed $notifiable, string $notification, null|array|callable $callback = null)
* @method static array sentNotifications()
* @method static \Hypervel\Support\Testing\Fakes\NotificationFake serializeAndRestore(bool $serializeAndRestore = true)
*
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index 039618212..42e1437bb 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -72,17 +72,17 @@
* @method static \Hypervel\Support\Collection allDelayedJobs()
* @method static \Hypervel\Support\Collection allPendingJobs()
* @method static \Hypervel\Support\Collection allReservedJobs()
- * @method static void assertClosureNotPushed(callable|null $callback = null)
- * @method static void assertClosurePushed(callable|int|null $callback = null)
+ * @method static void assertClosureNotPushed(null|array|callable $callback = null)
+ * @method static void assertClosurePushed(null|array|callable|int $callback = null)
* @method static void assertCount(int $expectedCount)
* @method static void assertNothingPushed()
- * @method static void assertNotPushed(\Closure|string $job, callable|null $callback = null)
- * @method static void assertPushed(\Closure|string $job, callable|int|null $callback = null)
- * @method static void assertPushedOn(\UnitEnum|string|null $queue, \Closure|string $job, callable|null $callback = null)
+ * @method static void assertNotPushed(\Closure|string $job, null|array|callable $callback = null)
+ * @method static void assertPushed(\Closure|string $job, null|array|callable|int $callback = null)
+ * @method static void assertPushedOn(\UnitEnum|string|null $queue, \Closure|string $job, null|array|callable $callback = null)
* @method static void assertPushedOnce(string $job)
* @method static void assertPushedTimes(string $job, int $times = 1)
- * @method static void assertPushedWithChain(string $job, array $expectedChain = [], callable|null $callback = null)
- * @method static void assertPushedWithoutChain(string $job, callable|null $callback = null)
+ * @method static void assertPushedWithChain(string $job, array $expectedChain = [], null|array|callable $callback = null)
+ * @method static void assertPushedWithoutChain(string $job, null|array|callable $callback = null)
* @method static \Hypervel\Support\Testing\Fakes\QueueFake beforePushing(callable $callback)
* @method static void clearReserved()
* @method static \Hypervel\Support\Collection delayedJobs(\UnitEnum|string|null $queue = null)
@@ -90,7 +90,7 @@
* @method static bool hasPushed(string $job)
* @method static \Hypervel\Support\Collection listenersPushed(string $listenerClass, callable|null $callback = null)
* @method static \Hypervel\Support\Collection pendingJobs(\UnitEnum|string|null $queue = null)
- * @method static \Hypervel\Support\Collection pushed(string $job, callable|null $callback = null)
+ * @method static \Hypervel\Support\Collection pushed(string $job, null|array|callable $callback = null)
* @method static array pushedJobs()
* @method static \Hypervel\Support\Collection pushedRaw(null|\Closure $callback = null)
* @method static array rawPushes()
diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php
index f49b776ad..a56cd635e 100644
--- a/src/support/src/Testing/Fakes/BusFake.php
+++ b/src/support/src/Testing/Fakes/BusFake.php
@@ -21,6 +21,7 @@
class BusFake implements Fake, QueueingDispatcher
{
+ use MatchesProperties;
use ReflectsClosures;
/**
@@ -89,8 +90,10 @@ public function except(array|string $jobsToDispatch): static
/**
* Assert if a job was dispatched based on a truth-test callback.
+ *
+ * @param null|array|callable|int $callback
*/
- public function assertDispatched(Closure|string $command, callable|int|null $callback = null): void
+ public function assertDispatched(Closure|string $command, callable|array|int|null $callback = null): void
{
if ($command instanceof Closure) {
[$command, $callback] = [$this->firstClosureParameterType($command), $command];
@@ -145,8 +148,10 @@ public function assertDispatchedTimes(Closure|string $command, int $times = 1):
/**
* Determine if a job was dispatched based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertNotDispatched(Closure|string $command, ?callable $callback = null): void
+ public function assertNotDispatched(Closure|string $command, callable|array|null $callback = null): void
{
if ($command instanceof Closure) {
[$command, $callback] = [$this->firstClosureParameterType($command), $command];
@@ -174,8 +179,10 @@ public function assertNothingDispatched(): void
/**
* Assert if a job was explicitly dispatched synchronously based on a truth-test callback.
+ *
+ * @param null|array|callable|int $callback
*/
- public function assertDispatchedSync(Closure|string $command, callable|int|null $callback = null): void
+ public function assertDispatchedSync(Closure|string $command, callable|array|int|null $callback = null): void
{
if ($command instanceof Closure) {
[$command, $callback] = [$this->firstClosureParameterType($command), $command];
@@ -218,8 +225,10 @@ public function assertDispatchedSyncTimes(Closure|string $command, int $times =
/**
* Determine if a job was dispatched based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertNotDispatchedSync(Closure|string $command, ?callable $callback = null): void
+ public function assertNotDispatchedSync(Closure|string $command, callable|array|null $callback = null): void
{
if ($command instanceof Closure) {
[$command, $callback] = [$this->firstClosureParameterType($command), $command];
@@ -234,8 +243,10 @@ public function assertNotDispatchedSync(Closure|string $command, ?callable $call
/**
* Assert if a job was dispatched after the response was sent based on a truth-test callback.
+ *
+ * @param null|array|callable|int $callback
*/
- public function assertDispatchedAfterResponse(Closure|string $command, callable|int|null $callback = null): void
+ public function assertDispatchedAfterResponse(Closure|string $command, callable|array|int|null $callback = null): void
{
if ($command instanceof Closure) {
[$command, $callback] = [$this->firstClosureParameterType($command), $command];
@@ -278,8 +289,10 @@ public function assertDispatchedAfterResponseTimes(Closure|string $command, int
/**
* Determine if a job was dispatched based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertNotDispatchedAfterResponse(Closure|string $command, ?callable $callback = null): void
+ public function assertNotDispatchedAfterResponse(Closure|string $command, callable|array|null $callback = null): void
{
if ($command instanceof Closure) {
[$command, $callback] = [$this->firstClosureParameterType($command), $command];
@@ -352,8 +365,10 @@ protected function resetChainPropertiesToDefaults(mixed $job): mixed
/**
* Assert if a job was dispatched with an empty chain based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertDispatchedWithoutChain(Closure|string $command, ?callable $callback = null): void
+ public function assertDispatchedWithoutChain(Closure|string $command, callable|array|null $callback = null): void
{
if ($command instanceof Closure) {
[$command, $callback] = [$this->firstClosureParameterType($command), $command];
@@ -370,9 +385,11 @@ public function assertDispatchedWithoutChain(Closure|string $command, ?callable
/**
* Assert if a job was dispatched with chained jobs based on a truth-test callback.
*
+ * @param null|array|callable $callback
+ *
* @throws RuntimeException
*/
- protected function assertDispatchedWithChainOfObjects(string $command, array $expectedChain, ?callable $callback): void
+ protected function assertDispatchedWithChainOfObjects(string $command, array $expectedChain, callable|array|null $callback): void
{
$chain = $expectedChain;
@@ -479,42 +496,48 @@ public function assertNothingPlaced(): void
/**
* Get all of the jobs matching a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function dispatched(string $command, ?callable $callback = null): Collection
+ public function dispatched(string $command, callable|array|null $callback = null): Collection
{
if (! $this->hasDispatched($command)) {
return new Collection;
}
- $callback = $callback ?: fn () => true;
+ $callback = $this->resolveTruthTest($callback) ?: fn () => true;
return (new Collection($this->commands[$command]))->filter(fn ($command) => $callback($command));
}
/**
* Get all of the jobs dispatched synchronously matching a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function dispatchedSync(string $command, ?callable $callback = null): Collection
+ public function dispatchedSync(string $command, callable|array|null $callback = null): Collection
{
if (! $this->hasDispatchedSync($command)) {
return new Collection;
}
- $callback = $callback ?: fn () => true;
+ $callback = $this->resolveTruthTest($callback) ?: fn () => true;
return (new Collection($this->commandsSync[$command]))->filter(fn ($command) => $callback($command));
}
/**
* Get all of the jobs dispatched after the response was sent matching a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function dispatchedAfterResponse(string $command, ?callable $callback = null): Collection
+ public function dispatchedAfterResponse(string $command, callable|array|null $callback = null): Collection
{
if (! $this->hasDispatchedAfterResponse($command)) {
return new Collection;
}
- $callback = $callback ?: fn () => true;
+ $callback = $this->resolveTruthTest($callback) ?: fn () => true;
return (new Collection($this->commandsAfterResponse[$command]))->filter(fn ($command) => $callback($command));
}
diff --git a/src/support/src/Testing/Fakes/EventFake.php b/src/support/src/Testing/Fakes/EventFake.php
index c4a9a09fd..6f71457a1 100644
--- a/src/support/src/Testing/Fakes/EventFake.php
+++ b/src/support/src/Testing/Fakes/EventFake.php
@@ -20,6 +20,7 @@
class EventFake implements Fake, DispatcherContract
{
use ForwardsCalls;
+ use MatchesProperties;
use ReflectsClosures;
/**
@@ -113,8 +114,10 @@ public function assertListening(string $expectedEvent, array|string $expectedLis
/**
* Assert if an event was dispatched based on a truth-test callback.
+ *
+ * @param null|array|callable|int $callback
*/
- public function assertDispatched(Closure|string $event, callable|int|null $callback = null): void
+ public function assertDispatched(Closure|string $event, callable|array|int|null $callback = null): void
{
if ($event instanceof Closure) {
[$event, $callback] = [$this->firstClosureParameterType($event), $event];
@@ -159,8 +162,10 @@ public function assertDispatchedTimes(string $event, int $times = 1): void
/**
* Determine if an event was dispatched based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertNotDispatched(Closure|string $event, ?callable $callback = null): void
+ public function assertNotDispatched(Closure|string $event, callable|array|null $callback = null): void
{
if ($event instanceof Closure) {
[$event, $callback] = [$this->firstClosureParameterType($event), $event];
@@ -198,14 +203,16 @@ public function assertNothingDispatched(): void
/**
* Get all of the events matching a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function dispatched(string $event, ?callable $callback = null): Collection
+ public function dispatched(string $event, callable|array|null $callback = null): Collection
{
if (! $this->hasDispatched($event)) {
return new Collection;
}
- $callback = $callback ?: function () {
+ $callback = $this->resolveTruthTest($callback) ?: function () {
return true;
};
diff --git a/src/support/src/Testing/Fakes/MatchesProperties.php b/src/support/src/Testing/Fakes/MatchesProperties.php
new file mode 100644
index 000000000..f1384bf14
--- /dev/null
+++ b/src/support/src/Testing/Fakes/MatchesProperties.php
@@ -0,0 +1,35 @@
+|callable $callback
+ */
+ protected function resolveTruthTest(callable|array|null $callback): ?callable
+ {
+ if (! is_array($callback) || is_callable($callback)) {
+ return $callback;
+ }
+
+ return fn (object|string $object): bool => (new Collection($callback))->every(function (mixed $expected, string $property) use ($object): bool {
+ if (! property_exists($object, $property) && ! isset($object->{$property})) {
+ return false;
+ }
+
+ $actual = $object->{$property} ?? null;
+
+ return $expected instanceof Model && $actual instanceof Model
+ ? $expected->is($actual)
+ : $actual === $expected;
+ });
+ }
+}
diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php
index 7ffd01cbf..3c1558500 100644
--- a/src/support/src/Testing/Fakes/NotificationFake.php
+++ b/src/support/src/Testing/Fakes/NotificationFake.php
@@ -21,6 +21,7 @@
class NotificationFake implements Fake, NotificationDispatcher, NotificationFactory
{
use Macroable;
+ use MatchesProperties;
use ReflectsClosures;
/**
@@ -41,9 +42,11 @@ class NotificationFake implements Fake, NotificationDispatcher, NotificationFact
/**
* Assert if a notification was sent on-demand based on a truth-test callback.
*
+ * @param null|array|callable $callback
+ *
* @throws Exception
*/
- public function assertSentOnDemand(Closure|string $notification, ?callable $callback = null): void
+ public function assertSentOnDemand(Closure|string $notification, callable|array|null $callback = null): void
{
$this->assertSentTo(new AnonymousNotifiable, $notification, $callback);
}
@@ -51,9 +54,11 @@ public function assertSentOnDemand(Closure|string $notification, ?callable $call
/**
* Assert if a notification was sent based on a truth-test callback.
*
+ * @param null|array|callable|int $callback
+ *
* @throws Exception
*/
- public function assertSentTo(mixed $notifiable, Closure|string $notification, callable|int|null $callback = null): void
+ public function assertSentTo(mixed $notifiable, Closure|string $notification, callable|array|int|null $callback = null): void
{
if (is_array($notifiable) || $notifiable instanceof Collection) {
if (count($notifiable) === 0) {
@@ -123,9 +128,11 @@ public function assertSentToOnce(mixed $notifiable, string $notification): void
/**
* Determine if a notification was sent based on a truth-test callback.
*
+ * @param null|array|callable $callback
+ *
* @throws Exception
*/
- public function assertNotSentTo(mixed $notifiable, Closure|string $notification, ?callable $callback = null): void
+ public function assertNotSentTo(mixed $notifiable, Closure|string $notification, callable|array|null $callback = null): void
{
if (is_array($notifiable) || $notifiable instanceof Collection) {
if (count($notifiable) === 0) {
@@ -226,14 +233,16 @@ public function assertCount(int $expectedCount): void
/**
* Get all of the notifications matching a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function sent(mixed $notifiable, string $notification, ?callable $callback = null): Collection
+ public function sent(mixed $notifiable, string $notification, callable|array|null $callback = null): Collection
{
if (! $this->hasSent($notifiable, $notification)) {
return new Collection;
}
- $callback = $callback ?: fn () => true;
+ $callback = $this->resolveTruthTest($callback) ?: fn () => true;
$notifications = new Collection($this->notificationsFor($notifiable, $notification));
diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php
index 4e20deedd..00b42b714 100644
--- a/src/support/src/Testing/Fakes/QueueFake.php
+++ b/src/support/src/Testing/Fakes/QueueFake.php
@@ -36,6 +36,7 @@
*/
class QueueFake extends QueueManager implements Fake, Queue
{
+ use MatchesProperties;
use ReadsQueueAttributes;
use ReflectsClosures;
@@ -119,8 +120,10 @@ public function except(array|string $jobsToBeQueued): static
/**
* Assert if a job was pushed based on a truth-test callback.
+ *
+ * @param null|array|callable|int $callback
*/
- public function assertPushed(Closure|string $job, callable|int|null $callback = null): void
+ public function assertPushed(Closure|string $job, callable|array|int|null $callback = null): void
{
if ($job instanceof Closure) {
[$job, $callback] = [$this->firstClosureParameterType($job), $job];
@@ -165,11 +168,13 @@ public function assertPushedOnce(string $job): void
/**
* Assert if a job was pushed based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
public function assertPushedOn(
UnitEnum|string|null $queue,
Closure|string $job,
- ?callable $callback = null,
+ callable|array|null $callback = null,
): void {
$queue = $this->normalizeQueue($queue);
@@ -177,6 +182,8 @@ public function assertPushedOn(
[$job, $callback] = [$this->firstClosureParameterType($job), $job];
}
+ $callback = $this->resolveTruthTest($callback);
+
$this->assertPushed($job, function ($job, $pushedQueue) use ($callback, $queue) {
if ($pushedQueue !== $queue) {
return false;
@@ -188,8 +195,10 @@ public function assertPushedOn(
/**
* Assert if a job was pushed with chained jobs based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertPushedWithChain(string $job, array $expectedChain = [], ?callable $callback = null): void
+ public function assertPushedWithChain(string $job, array $expectedChain = [], callable|array|null $callback = null): void
{
PHPUnit::assertTrue(
$this->pushed($job, $callback)->isNotEmpty(),
@@ -208,8 +217,10 @@ public function assertPushedWithChain(string $job, array $expectedChain = [], ?c
/**
* Assert if a job was pushed with an empty chain based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertPushedWithoutChain(string $job, ?callable $callback = null): void
+ public function assertPushedWithoutChain(string $job, callable|array|null $callback = null): void
{
PHPUnit::assertTrue(
$this->pushed($job, $callback)->isNotEmpty(),
@@ -221,8 +232,10 @@ public function assertPushedWithoutChain(string $job, ?callable $callback = null
/**
* Assert if a job was pushed with chained jobs based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- protected function assertPushedWithChainOfObjects(string $job, array $expectedChain, ?callable $callback): void
+ protected function assertPushedWithChainOfObjects(string $job, array $expectedChain, callable|array|null $callback): void
{
$chain = (new Collection($expectedChain))->map(fn ($job) => serialize($job))->all();
@@ -234,8 +247,10 @@ protected function assertPushedWithChainOfObjects(string $job, array $expectedCh
/**
* Assert if a job was pushed with chained jobs based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- protected function assertPushedWithChainOfClasses(string $job, array $expectedChain, ?callable $callback): void
+ protected function assertPushedWithChainOfClasses(string $job, array $expectedChain, callable|array|null $callback): void
{
$matching = $this->pushed($job, $callback)->contains(function (object $pushedJob) use ($expectedChain): bool {
return (new Collection($pushedJob->chained))->map(function (string $job): string {
@@ -251,16 +266,20 @@ protected function assertPushedWithChainOfClasses(string $job, array $expectedCh
/**
* Assert if a closure was pushed based on a truth-test callback.
+ *
+ * @param null|array|callable|int $callback
*/
- public function assertClosurePushed(callable|int|null $callback = null): void
+ public function assertClosurePushed(callable|array|int|null $callback = null): void
{
$this->assertPushed(CallQueuedClosure::class, $callback);
}
/**
* Assert that a closure was not pushed based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertClosureNotPushed(?callable $callback = null): void
+ public function assertClosureNotPushed(callable|array|null $callback = null): void
{
$this->assertNotPushed(CallQueuedClosure::class, $callback);
}
@@ -275,8 +294,10 @@ protected function isChainOfObjects(array $chain): bool
/**
* Determine if a job was pushed based on a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function assertNotPushed(Closure|string $job, ?callable $callback = null): void
+ public function assertNotPushed(Closure|string $job, callable|array|null $callback = null): void
{
if ($job instanceof Closure) {
[$job, $callback] = [$this->firstClosureParameterType($job), $job];
@@ -315,14 +336,16 @@ public function assertNothingPushed(): void
/**
* Get all of the jobs matching a truth-test callback.
+ *
+ * @param null|array|callable $callback
*/
- public function pushed(string $job, ?callable $callback = null): Collection
+ public function pushed(string $job, callable|array|null $callback = null): Collection
{
if (! $this->hasPushed($job)) {
return new Collection;
}
- $callback = $callback ?: fn () => true;
+ $callback = $this->resolveTruthTest($callback) ?: fn () => true;
return (new Collection($this->jobs[$job]))->filter(
fn ($data) => $callback($data['job'], $data['queue'], $data['data'])
diff --git a/tests/Support/SupportTestingBusFakeTest.php b/tests/Support/SupportTestingBusFakeTest.php
index 43d717607..1b0841516 100644
--- a/tests/Support/SupportTestingBusFakeTest.php
+++ b/tests/Support/SupportTestingBusFakeTest.php
@@ -19,6 +19,7 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\ExpectationFailedException;
use RuntimeException;
@@ -423,6 +424,17 @@ public function testAssertDispatchedSyncTimesWithCallbackFunction(): void
}, 2);
}
+ #[TestWith(['dispatch', 'assertDispatched', 'assertNotDispatched'])]
+ #[TestWith(['dispatchSync', 'assertDispatchedSync', 'assertNotDispatchedSync'])]
+ #[TestWith(['dispatchAfterResponse', 'assertDispatchedAfterResponse', 'assertNotDispatchedAfterResponse'])]
+ public function testAssertDispatchedWithArrayOfProperties(string $dispatchMethod, string $assertMethod, string $assertNotMethod): void
+ {
+ $this->fake->{$dispatchMethod}(new BusJobWithPropertiesStub('pending'));
+
+ $this->fake->{$assertMethod}(BusJobWithPropertiesStub::class, ['status' => 'pending']);
+ $this->fake->{$assertNotMethod}(BusJobWithPropertiesStub::class, ['status' => 'complete']);
+ }
+
#[DataProvider('countAssertionMethods')]
public function testCountAssertionsPluralizeFailureMessages(
string $dispatchMethod,
@@ -1160,6 +1172,16 @@ class BusJobStub
{
}
+class BusJobWithPropertiesStub
+{
+ /**
+ * Create a job with the given status.
+ */
+ public function __construct(public string $status)
+ {
+ }
+}
+
class ChainedJobStub
{
use Queueable;
diff --git a/tests/Support/SupportTestingEventFakeTest.php b/tests/Support/SupportTestingEventFakeTest.php
index 038b41170..ad42105c2 100644
--- a/tests/Support/SupportTestingEventFakeTest.php
+++ b/tests/Support/SupportTestingEventFakeTest.php
@@ -100,6 +100,14 @@ public function testAssertDispatchedTimes(): void
$this->fake->assertDispatchedTimes(EventStub::class, 2);
}
+ public function testAssertDispatchedWithArrayOfProperties(): void
+ {
+ $this->fake->dispatch(new EventWithPropertiesStub('pending'));
+
+ $this->fake->assertDispatched(EventWithPropertiesStub::class, ['status' => 'pending']);
+ $this->fake->assertNotDispatched(EventWithPropertiesStub::class, ['status' => 'complete']);
+ }
+
public function testAssertNotDispatched(): void
{
$this->fake->assertNotDispatched(EventStub::class);
@@ -176,3 +184,13 @@ class EventStub
class ListenerStub
{
}
+
+class EventWithPropertiesStub
+{
+ /**
+ * Create an event with the given status.
+ */
+ public function __construct(public string $status)
+ {
+ }
+}
diff --git a/tests/Support/SupportTestingNotificationFakeTest.php b/tests/Support/SupportTestingNotificationFakeTest.php
index c9d697099..d6b296d9d 100644
--- a/tests/Support/SupportTestingNotificationFakeTest.php
+++ b/tests/Support/SupportTestingNotificationFakeTest.php
@@ -76,6 +76,21 @@ public function testAssertSentOnDemandClosure(): void
});
}
+ public function testAssertSentToWithArrayOfProperties(): void
+ {
+ $this->fake->send($this->user, new NotificationWithPropertiesStub('pending'));
+
+ $this->fake->assertSentTo($this->user, NotificationWithPropertiesStub::class, ['status' => 'pending']);
+ $this->fake->assertNotSentTo($this->user, NotificationWithPropertiesStub::class, ['status' => 'complete']);
+ }
+
+ public function testAssertSentOnDemandWithArrayOfProperties(): void
+ {
+ $this->fake->send(new AnonymousNotifiable, new NotificationWithPropertiesStub('pending'));
+
+ $this->fake->assertSentOnDemand(NotificationWithPropertiesStub::class, ['status' => 'pending']);
+ }
+
public function testAssertNotSentTo(): void
{
$this->fake->assertNotSentTo($this->user, NotificationStub::class);
@@ -283,6 +298,16 @@ public function shouldSend(mixed $notifiable, string $channel): bool
}
}
+class NotificationWithPropertiesStub extends NotificationStub
+{
+ /**
+ * Create a notification with the given status.
+ */
+ public function __construct(public string $status)
+ {
+ }
+}
+
class UserStub extends User
{
}
diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php
index cfcb755ec..b3ac0ea5a 100644
--- a/tests/Support/SupportTestingQueueFakeTest.php
+++ b/tests/Support/SupportTestingQueueFakeTest.php
@@ -13,6 +13,7 @@
use Hypervel\Contracts\Cache\Repository as CacheContract;
use Hypervel\Contracts\Queue\Queue;
use Hypervel\Contracts\Queue\ShouldBeUnique;
+use Hypervel\Database\Eloquent\Model;
use Hypervel\Foundation\Application;
use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\CallQueuedClosure;
@@ -95,6 +96,35 @@ public function testAssertPushedWithClosure(): void
});
}
+ public function testAssertPushedWithArrayOfProperties(): void
+ {
+ $this->fake->pushOn('high', new JobWithPropertiesStub('pending', new ModelForQueueFakeStub(['id' => 1])));
+
+ $this->fake->assertPushed(JobWithPropertiesStub::class, ['status' => 'pending', 'model' => new ModelForQueueFakeStub(['id' => 1])]);
+ $this->fake->assertPushedOn('high', JobWithPropertiesStub::class, ['status' => 'pending']);
+ $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['status' => 'complete']);
+ $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['model' => new ModelForQueueFakeStub(['id' => 2])]);
+ }
+
+ public function testAssertPushedWithArrayOfPropertiesDoesNotMatchMissingOrMismatchedProperties(): void
+ {
+ $this->fake->push(new JobWithPropertiesStub('pending'));
+
+ $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['status' => new ModelForQueueFakeStub(['id' => 1])]);
+ $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['model' => new ModelForQueueFakeStub(['id' => 1])]);
+ $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['missing' => null]);
+ $this->fake->assertPushed(JobWithPropertiesStub::class, ['model' => null]);
+ }
+
+ public function testAssertPushedPreservesArrayCallbacks(): void
+ {
+ $predicate = fn (JobWithPropertiesStub $job): bool => $job->status === 'pending';
+ $this->fake->pushOn('high', new JobWithPropertiesStub('pending'));
+
+ $this->fake->assertPushed(JobWithPropertiesStub::class, [$predicate, '__invoke']);
+ $this->fake->assertPushedOn('high', JobWithPropertiesStub::class, [$predicate, '__invoke']);
+ }
+
public function testQueueSize(): void
{
$this->assertEquals(0, $this->fake->size());
@@ -1055,6 +1085,21 @@ public function handle(): void
}
}
+class JobWithPropertiesStub
+{
+ /**
+ * Create a job with the given properties.
+ */
+ public function __construct(public string $status, public ?ModelForQueueFakeStub $model = null)
+ {
+ }
+}
+
+class ModelForQueueFakeStub extends Model
+{
+ protected array $guarded = [];
+}
+
#[Delay(15)]
class JobWithDelayAttributeStub
{
From 63fc13de272e49013135be1ecf8550a608957cc4 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 13:14:52 +0000
Subject: [PATCH 12/20] Support percentage memory limits and persistent
scheduler interrupts
Port Laravel framework #61753 (926cd9ff53041de5f50d5a2448c8df7ba2b7cc84), #61764 (fc11f6fce255068fa3d24de81c49439f9500cf88) and facade update d6e9b9a7d8c22f017791ad019fa82389d647b51b. Keep these changes together because they share the complete command-signature fixture.
Queue workers accept percentages of the finite PHP memory limit while retaining fractional megabytes. Preserve those values through queue:listen into child workers and fix its parent limit comparison. Give the outbox relay the same option behavior, retaining positive-limit validation. One stateless conversion helper serves all three consumers.
Scheduler interrupts now retain a millisecond timestamp. Each invocation compares it with its own start time, so new runs neither clear interrupts needed by older commands nor stop for an earlier deployment. Expose hasBeenInterruptedSince for long-running callbacks, preserve throttled cache polling and task draining, and remove the obsolete --minutes option and expiry handling.
Port the applicable upstream tests and documentation, including the queue property-map examples from #61770. Add focused command-forwarding and memory-admission checks and prove that a later repeatable schedule ignores an old signal while leaving it available to earlier commands. Regenerate the Schedule facade and update command signatures.
Validated each affected test file, affected parallel queue/routing/scheduler/command suites, facade annotations, formatting and full source/type/skeleton analysis.
---
.../src/Commands/ScheduleInterruptCommand.php | 18 +----
.../src/Commands/ScheduleRunCommand.php | 45 ++++---------
src/console/src/Scheduling/Schedule.php | 14 ++++
src/docs/queue-outbox.md | 2 +-
src/docs/queues.md | 18 +++++
src/docs/scheduling.md | 20 ++++--
src/queue/src/Console/ListenCommand.php | 4 +-
src/queue/src/Console/RelayCommand.php | 4 +-
src/queue/src/Console/WorkCommand.php | 4 +-
src/queue/src/Listener.php | 6 +-
src/queue/src/ListenerOptions.php | 2 +-
src/queue/src/MemoryLimit.php | 18 +++++
src/queue/src/Outbox/OutboxWorker.php | 3 +-
src/queue/src/Outbox/OutboxWorkerOptions.php | 5 +-
src/queue/src/Worker.php | 4 +-
src/queue/src/WorkerOptions.php | 4 +-
src/support/src/Facades/Schedule.php | 1 +
tests/Console/Fixtures/command_signatures.php | 17 +----
.../ScheduleInterruptCommandTest.php | 63 -----------------
.../Scheduling/ScheduleRunCommandTest.php | 31 ++-------
.../ScheduleInterruptCommandTest.php | 67 +++++++++++++++++++
tests/Queue/ListenCommandTest.php | 13 ++++
tests/Queue/Outbox/OutboxWorkerTest.php | 16 +++++
tests/Queue/QueueListenerTest.php | 24 ++++++-
tests/Queue/QueueWorkerTest.php | 30 ++++++++-
tests/Queue/RelayCommandTest.php | 17 +++++
tests/Queue/WorkCommandTest.php | 13 ++++
27 files changed, 285 insertions(+), 178 deletions(-)
create mode 100644 src/queue/src/MemoryLimit.php
delete mode 100644 tests/Console/Scheduling/ScheduleInterruptCommandTest.php
create mode 100644 tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php
diff --git a/src/console/src/Commands/ScheduleInterruptCommand.php b/src/console/src/Commands/ScheduleInterruptCommand.php
index 1e9fbe815..bf43927c5 100644
--- a/src/console/src/Commands/ScheduleInterruptCommand.php
+++ b/src/console/src/Commands/ScheduleInterruptCommand.php
@@ -15,9 +15,7 @@ class ScheduleInterruptCommand extends Command
/**
* The console signature name.
*/
- protected ?string $signature = 'schedule:interrupt
- {--minutes=1 : TTL in minutes for the interrupt signal (minimum 1)}
- ';
+ protected ?string $signature = 'schedule:interrupt';
/**
* The console command description.
@@ -38,19 +36,7 @@ public function __construct(
*/
public function handle(): int
{
- $minutes = filter_var($this->option('minutes'), FILTER_VALIDATE_INT);
-
- if ($minutes === false || $minutes < 1) {
- $this->components->error('The --minutes option must be an integer of at least 1.');
-
- return self::FAILURE;
- }
-
- $this->cache->put(
- 'hypervel:schedule:interrupt',
- true,
- Date::now()->addMinutes($minutes)
- );
+ $this->cache->forever('hypervel:schedule:interrupt', Date::now()->getTimestampMs());
$this->components->info('Broadcasting schedule interrupt signal.');
diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php
index 2aef616a8..539f0beb5 100644
--- a/src/console/src/Commands/ScheduleRunCommand.php
+++ b/src/console/src/Commands/ScheduleRunCommand.php
@@ -90,9 +90,9 @@ class ScheduleRunCommand extends Command
protected bool $eventsRan = false;
/**
- * Check if scheduler should stop.
+ * Whether this schedule run has been interrupted.
*/
- protected bool $shouldStop = false;
+ protected bool $interrupted = false;
/**
* Whether a termination signal has requested shutdown.
@@ -100,7 +100,7 @@ class ScheduleRunCommand extends Command
protected bool $shouldQuit = false;
/**
- * Last time the stopped state was checked.
+ * Last time the interruption state was checked.
*/
protected ?CarbonInterface $lastChecked = null;
@@ -147,7 +147,7 @@ public function handle(
Cache $cache,
ExceptionHandler $handler,
): void {
- $this->shouldQuit = false;
+ $this->startedAt = Date::now();
$this->schedule = $schedule;
$this->dispatcher = $dispatcher;
@@ -168,15 +168,13 @@ public function handle(
$this->listenForSignals();
try {
- $this->clearShouldStop();
-
$noEventsAlerted = false;
- while (! $this->shouldQuit && ! $this->shouldStop()) {
- $startedAt = Date::now();
+ while (! $this->shouldQuit && ! $this->shouldInterrupt()) {
+ $now = Date::now();
$this->runEvents(
- $this->schedule->dueEventsAt($this->hypervel, $startedAt),
- $startedAt
+ $this->schedule->dueEventsAt($this->hypervel, $now),
+ $now
);
if (! $this->eventsRan && ! $noEventsAlerted && ! $this->option('whisper')) {
@@ -215,14 +213,8 @@ protected function stop(): void
*/
protected function runOnce(): void
{
- $this->startedAt = Date::now();
-
$events = $this->schedule->dueEventsAt($this->hypervel, $this->startedAt);
- if ($events->contains->isRepeatable()) {
- $this->clearShouldStop();
- }
-
// The finite --once path runs inside Waiter; signal-wait coroutines keep
// that scheduler coroutine alive, so mutex signal cleanup is long-run only.
(new Waiter(-1))->wait(function () use ($events) {
@@ -250,7 +242,7 @@ protected function repeatEvents(Collection $events): void
$paused = $this->isPaused();
foreach ($events as $event) {
- if ($this->shouldStop()) {
+ if ($this->shouldInterrupt()) {
return;
}
@@ -586,7 +578,7 @@ protected function isPaused(): bool
/**
* Determine if the schedule run should be interrupted.
*/
- protected function shouldStop(): bool
+ protected function shouldInterrupt(): bool
{
if (! Schedule::$interruptible) {
return false;
@@ -596,23 +588,14 @@ protected function shouldStop(): bool
$this->lastChecked = Date::now();
}
- if ($this->shouldStop || abs($this->lastChecked->diffInSeconds()) < 1) {
- return $this->shouldStop;
+ // The loop and repeating events check more often; avoid a cache round trip on every check.
+ if ($this->interrupted || abs($this->lastChecked->diffInSeconds()) < 1) {
+ return $this->interrupted;
}
$this->lastChecked = Date::now();
- return $this->shouldStop = (bool) $this->cache->get('hypervel:schedule:interrupt', false);
- }
-
- /**
- * Clear the interrupt cache.
- */
- protected function clearShouldStop(): void
- {
- $this->cache->forget('hypervel:schedule:interrupt');
-
- $this->shouldStop = false;
+ return $this->interrupted = $this->schedule->hasBeenInterruptedSince($this->startedAt);
}
/**
diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php
index 3469fb5b4..36b3ce15b 100644
--- a/src/console/src/Scheduling/Schedule.php
+++ b/src/console/src/Scheduling/Schedule.php
@@ -483,6 +483,20 @@ public static function withoutInterruptionPolling(): void
static::$interruptible = false;
}
+ /**
+ * Determine if the schedule has been interrupted since the given time.
+ */
+ public function hasBeenInterruptedSince(DateTimeInterface $time): bool
+ {
+ if (! static::$interruptible) {
+ return false;
+ }
+
+ $interruptedAt = Container::getInstance()->make(Cache::class)->get('hypervel:schedule:interrupt');
+
+ return is_numeric($interruptedAt) && (int) $interruptedAt >= Date::instance($time)->getTimestampMs();
+ }
+
/**
* Flush all static state.
*/
diff --git a/src/docs/queue-outbox.md b/src/docs/queue-outbox.md
index 0ed15f4f1..0d5ae7421 100644
--- a/src/docs/queue-outbox.md
+++ b/src/docs/queue-outbox.md
@@ -253,7 +253,7 @@ The relay supports the following options:
| `--sleep` | `1` | Seconds to wait after finding no work or after a delivery failure. Must be positive; fractions are supported. |
| `--max-jobs` | `0` | Stop after taking this many messages from the outbox; zero means unlimited. |
| `--max-time` | `0` | Stop taking new work after this many seconds; zero means unlimited. |
-| `--memory` | `128` | Memory limit in megabytes. |
+| `--memory` | `128` | Memory limit in megabytes, or a percentage of PHP's finite `memory_limit` (for example, `60%`). |
| `--stop-when-empty` | Off | Stop when no selected database has messages ready for delivery, after finishing work already started. |
| `--once` | Off | Process at most one batch from each selected database, without sleeping. |
diff --git a/src/docs/queues.md b/src/docs/queues.md
index 94b47fe73..30844080a 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -2942,6 +2942,14 @@ php artisan queue:work --force
#### Resource Considerations
+The `--memory` option specifies the memory limit in megabytes at which the worker should exit. By default, this limit is 128 megabytes. You may also specify a percentage of PHP's configured `memory_limit`:
+
+```shell
+php artisan queue:work --memory=60%
+```
+
+The `queue:listen` command also accepts memory limits in megabytes or as a percentage. Percentage-based limits require a finite PHP `memory_limit`. If PHP's memory limit is unlimited (`-1`), use a limit in megabytes instead. A process monitor such as [Supervisor](#supervisor-configuration) can automatically restart the worker after it exits.
+
Daemon queue workers do not "reboot" the framework before processing each job. Therefore, you should release any heavy resources after each job completes. For example, after [image manipulation](/docs/{{version}}/images), release references to image objects when you no longer need them. Do not retain them in static properties or singleton services between jobs.
@@ -3804,6 +3812,16 @@ Queue::assertClosurePushed(function (CallQueuedClosure $job) {
});
```
+Alternatively, you may pass an array of expected property values as the second argument to `assertPushed` or `assertNotPushed`, or as the third argument to `assertPushedOn`:
+
+```php
+Queue::assertPushed(ShipOrder::class, ['order' => $order]);
+
+Queue::assertPushedOn('shipping', ShipOrder::class, ['order' => $order]);
+```
+
+All specified properties must match. Values are compared using strict equality, while Eloquent models are compared using their `is` method. The `Bus` facade's `assertDispatched`, `assertNotDispatched`, `assertDispatchedSync`, and `assertDispatchedAfterResponse` methods also accept an array of expected property values as their second argument.
+
### Testing Bus Dispatches
diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md
index 6195364d6..e3b22f8d0 100644
--- a/src/docs/scheduling.md
+++ b/src/docs/scheduling.md
@@ -576,7 +576,7 @@ Schedule::command('users:delete')->everyTenSeconds()->runInBackground();
When the long-running `schedule:run` process receives `SIGTERM`, `SIGINT`, or `SIGQUIT`, it stops starting new tasks and waits for running tasks to finish. A second termination signal stops the process immediately and releases its owned overlap locks, unless the task disables `releaseOnTerminationSignals`. Configure your process manager's shutdown timeout to allow your longest task to finish. A forced `SIGKILL` cannot release overlap locks; they remain until they expire or you run `schedule:clear-cache`.
-When deploying your application, you may also use `schedule:interrupt` to stop the scheduler so that the next invocation picks up your newly deployed code. This command writes a flag to the cache that `schedule:run` polls. After detecting the flag, the scheduler finishes the current set of due events and waits for running background tasks before exiting.
+When deploying your application, you may also use `schedule:interrupt` to stop the scheduler so that the next invocation picks up your newly deployed code. This command records an interruption timestamp in the cache. Schedulers started before the interruption finish the current set of due events and wait for running background tasks before exiting. Schedulers started afterward are unaffected.
This command should be invoked after your application is finished deploying:
@@ -584,13 +584,23 @@ This command should be invoked after your application is finished deploying:
php artisan schedule:interrupt
```
-By default, the interrupt flag expires after one minute. You may use the `--minutes` option to extend this window if your deployment process takes longer:
+Long-running scheduled commands may check for an interruption between units of work using `Schedule::hasBeenInterruptedSince`. Record the command's start time and pass it to this method:
-```shell
-php artisan schedule:interrupt --minutes=5
+```php
+use Hypervel\Support\Facades\Schedule;
+
+$startedAt = now();
+
+foreach ($items as $item) {
+ if (Schedule::hasBeenInterruptedSince($startedAt)) {
+ break;
+ }
+
+ // Process the item...
+}
```
-If you do not want the scheduler to poll for pause or interrupt flags, call `Schedule::withoutInterruptionPolling()` during boot. The `schedule:pause` command will then fail instead of pausing the scheduler. Operating-system termination signals still stop the scheduler.
+If you do not want the scheduler to poll for pause or interrupt signals, call `Schedule::withoutInterruptionPolling()` during boot. The `schedule:pause` command will then fail instead of pausing the scheduler, and `Schedule::hasBeenInterruptedSince` will return `false`. Operating-system termination signals still stop the scheduler.
### Running the Scheduler Locally
diff --git a/src/queue/src/Console/ListenCommand.php b/src/queue/src/Console/ListenCommand.php
index 2a0ec74b9..ca295cb2d 100644
--- a/src/queue/src/Console/ListenCommand.php
+++ b/src/queue/src/Console/ListenCommand.php
@@ -23,7 +23,7 @@ class ListenCommand extends Command
{--delay=0 : The number of seconds to delay failed jobs (Deprecated)}
{--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception}
{--force : Force the worker to run even in maintenance mode}
- {--memory=128 : The memory limit in megabytes}
+ {--memory=128 : The memory limit in megabytes, or as a percentage of the PHP memory limit}
{--queue= : The queue to listen on}
{--sleep=3 : The number of seconds to sleep when no job is available}
{--rest=0 : The number of seconds to rest between jobs}
@@ -97,7 +97,7 @@ protected function gatherOptions(): ListenerOptions
name: $this->option('name'),
environment: $this->option('env'),
backoff: (int) $backoff,
- memory: (int) $this->option('memory'),
+ memory: (string) $this->option('memory'),
timeout: (int) $this->option('timeout'),
sleep: (float) $this->option('sleep'),
maxTries: (int) $this->option('tries'),
diff --git a/src/queue/src/Console/RelayCommand.php b/src/queue/src/Console/RelayCommand.php
index 65ee006ed..79f40bc43 100644
--- a/src/queue/src/Console/RelayCommand.php
+++ b/src/queue/src/Console/RelayCommand.php
@@ -21,7 +21,7 @@ class RelayCommand extends Command
{--concurrency=1 : The maximum number of batches to relay at once}
{--max-jobs=0 : The number of messages to claim before stopping}
{--max-time=0 : The maximum number of seconds the relay should run}
- {--memory=128 : The memory limit in megabytes}';
+ {--memory=128 : The memory limit in megabytes, or as a percentage of the PHP memory limit}';
protected string $description = 'Relay committed outbox messages to their queues';
@@ -53,7 +53,7 @@ public function handle(): int
concurrency: (int) $this->option('concurrency'),
maxJobs: (int) $this->option('max-jobs'),
maxTime: (int) $this->option('max-time'),
- memory: (float) $this->option('memory'),
+ memory: (string) $this->option('memory'),
stopWhenEmpty: (bool) $this->option('stop-when-empty'),
once: (bool) $this->option('once'),
));
diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php
index 9b2edd6ac..a11b98ef7 100644
--- a/src/queue/src/Console/WorkCommand.php
+++ b/src/queue/src/Console/WorkCommand.php
@@ -56,7 +56,7 @@ class WorkCommand extends Command
{--max-jobs=0 : The number of jobs to process before stopping}
{--max-time=0 : The maximum number of seconds the worker should run}
{--force : Force the worker to run even in maintenance mode}
- {--memory=128 : The memory limit in megabytes}
+ {--memory=128 : The memory limit in megabytes, or as a percentage of the PHP memory limit}
{--sleep=3 : The number of seconds to sleep when no job is available}
{--rest=0 : The number of seconds to rest between jobs}
{--timeout=60 : The number of seconds a child process can run}
@@ -155,7 +155,7 @@ protected function gatherWorkerOptions(): WorkerOptions
return new WorkerOptions(
name: (string) $this->option('name'),
backoff: (int) max($this->option('backoff'), $this->option('delay')),
- memory: (float) $this->option('memory'),
+ memory: (string) $this->option('memory'),
timeout: (int) $this->option('timeout'),
sleep: (float) $this->option('sleep'),
maxTries: (int) $this->option('tries'),
diff --git a/src/queue/src/Listener.php b/src/queue/src/Listener.php
index 08c8a91ea..5483ad5bf 100644
--- a/src/queue/src/Listener.php
+++ b/src/queue/src/Listener.php
@@ -134,7 +134,7 @@ protected function createCommand(?string $connection, string $queue, ListenerOpt
/**
* Run the given process.
*/
- public function runProcess(Process $process, float $memory): void
+ public function runProcess(Process $process, float|string $memory): void
{
$process->run(function ($type, $line) {
$this->handleWorkerOutput($type, $line);
@@ -161,9 +161,9 @@ protected function handleWorkerOutput(string $type, string $line): void
/**
* Determine if the memory limit has been exceeded.
*/
- public function memoryExceeded(float $memoryLimit): bool
+ public function memoryExceeded(float|string $memoryLimit): bool
{
- return (memory_get_usage(true) / 1024 / 1024) >= $memoryLimit;
+ return (memory_get_usage(true) / 1024 / 1024) >= MemoryLimit::toMegabytes($memoryLimit);
}
/**
diff --git a/src/queue/src/ListenerOptions.php b/src/queue/src/ListenerOptions.php
index 99527539d..ee0da3dca 100644
--- a/src/queue/src/ListenerOptions.php
+++ b/src/queue/src/ListenerOptions.php
@@ -20,7 +20,7 @@ public function __construct(
string $name = 'default',
?string $environment = null,
array|int $backoff = 0,
- int $memory = 128,
+ float|string $memory = 128,
int $timeout = 60,
float $sleep = 3,
int $maxTries = 1,
diff --git a/src/queue/src/MemoryLimit.php b/src/queue/src/MemoryLimit.php
new file mode 100644
index 000000000..5e8609fe3
--- /dev/null
+++ b/src/queue/src/MemoryLimit.php
@@ -0,0 +1,18 @@
+= $options->memory) {
+ if (memory_get_usage(true) / 1024 / 1024 >= MemoryLimit::toMegabytes($options->memory)) {
$status = Worker::EXIT_MEMORY_LIMIT;
break;
}
diff --git a/src/queue/src/Outbox/OutboxWorkerOptions.php b/src/queue/src/Outbox/OutboxWorkerOptions.php
index 45692bbac..c0d966f67 100644
--- a/src/queue/src/Outbox/OutboxWorkerOptions.php
+++ b/src/queue/src/Outbox/OutboxWorkerOptions.php
@@ -4,6 +4,7 @@
namespace Hypervel\Queue\Outbox;
+use Hypervel\Queue\MemoryLimit;
use InvalidArgumentException;
readonly class OutboxWorkerOptions
@@ -17,7 +18,7 @@ public function __construct(
public int $concurrency = 1,
public int $maxJobs = 0,
public int $maxTime = 0,
- public float $memory = 128,
+ public float|string $memory = 128,
public bool $stopWhenEmpty = false,
public bool $once = false,
) {
@@ -29,7 +30,7 @@ public function __construct(
throw new InvalidArgumentException('Outbox sleep and concurrency must be greater than zero.');
}
- if ($maxJobs < 0 || $maxTime < 0 || $memory <= 0) {
+ if ($maxJobs < 0 || $maxTime < 0 || MemoryLimit::toMegabytes($memory) <= 0) {
throw new InvalidArgumentException('Outbox job and time limits must be non-negative, and memory must be greater than zero.');
}
}
diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php
index 4acbad0fd..456cae996 100644
--- a/src/queue/src/Worker.php
+++ b/src/queue/src/Worker.php
@@ -1852,8 +1852,10 @@ protected function supportsAsyncSignals(): bool
/**
* Determine if the memory limit has been exceeded.
*/
- public function memoryExceeded(float $memoryLimit): bool
+ public function memoryExceeded(float|string $memoryLimit): bool
{
+ $memoryLimit = MemoryLimit::toMegabytes($memoryLimit);
+
return $memoryLimit > 0 && $this->currentMemoryUsage() >= $memoryLimit;
}
diff --git a/src/queue/src/WorkerOptions.php b/src/queue/src/WorkerOptions.php
index bbb0fb720..3a9d437ac 100644
--- a/src/queue/src/WorkerOptions.php
+++ b/src/queue/src/WorkerOptions.php
@@ -11,7 +11,7 @@ class WorkerOptions
*
* @param string $name the name of the worker
* @param int|int[] $backoff
- * @param float $memory the maximum amount of RAM in megabytes the worker may consume
+ * @param float|string $memory the maximum RAM in megabytes or a percentage of PHP's memory limit
* @param int $timeout the maximum number of seconds a child worker may run
* @param float $sleep the number of seconds to wait in between polling the queue
* @param int $maxTries the maximum number of times a job may be attempted
@@ -28,7 +28,7 @@ class WorkerOptions
public function __construct(
public string $name = 'default',
public array|int $backoff = 0,
- public float $memory = 128,
+ public float|string $memory = 128,
public int $timeout = 60,
public float $sleep = 3,
public int $maxTries = 1,
diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php
index c89850b2e..3b9e420c5 100644
--- a/src/support/src/Facades/Schedule.php
+++ b/src/support/src/Facades/Schedule.php
@@ -18,6 +18,7 @@
* @method static void flushMacros()
* @method static void flushState()
* @method static void group(\Closure $events)
+ * @method static bool hasBeenInterruptedSince(\DateTimeInterface $time)
* @method static bool hasMacro(string $name)
* @method static \Hypervel\Console\Scheduling\CallbackEvent job(object|string $job, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null)
* @method static void macro(string $name, callable|object $macro)
diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php
index f0ab29261..6eff29c6a 100644
--- a/tests/Console/Fixtures/command_signatures.php
+++ b/tests/Console/Fixtures/command_signatures.php
@@ -119,19 +119,6 @@
// REMOVED: schedule:finish; coroutine tasks finish directly through Event::finish().
\Hypervel\Console\Commands\ScheduleInterruptCommand::class => [
'name' => 'schedule:interrupt',
- 'options' => [
- [
- 'name' => 'minutes',
- 'shortcut' => null,
- 'negatable' => false,
- 'valueRequired' => false,
- 'valueOptional' => true,
- 'isArray' => false,
- 'acceptValue' => true,
- 'default' => '1',
- 'description' => 'TTL in minutes for the interrupt signal (minimum 1)',
- ],
- ],
],
\Hypervel\Console\Commands\ScheduleListCommand::class => [
'name' => 'schedule:list',
@@ -3605,7 +3592,7 @@
'isArray' => false,
'acceptValue' => true,
'default' => '128',
- 'description' => 'The memory limit in megabytes',
+ 'description' => 'The memory limit in megabytes, or as a percentage of the PHP memory limit',
],
[
'name' => 'queue',
@@ -4031,7 +4018,7 @@
'isArray' => false,
'acceptValue' => true,
'default' => '128',
- 'description' => 'The memory limit in megabytes',
+ 'description' => 'The memory limit in megabytes, or as a percentage of the PHP memory limit',
],
[
'name' => 'sleep',
diff --git a/tests/Console/Scheduling/ScheduleInterruptCommandTest.php b/tests/Console/Scheduling/ScheduleInterruptCommandTest.php
deleted file mode 100644
index c1ed06a52..000000000
--- a/tests/Console/Scheduling/ScheduleInterruptCommandTest.php
+++ /dev/null
@@ -1,63 +0,0 @@
-shouldReceive('put')
- ->once()
- ->with('hypervel:schedule:interrupt', true, m::type(DateTimeInterface::class));
-
- $this->app->instance(Cache::class, $cache);
-
- $this->artisan('schedule:interrupt')
- ->assertSuccessful();
- }
-
- public function testInterruptCommandRejectsZeroMinutes(): void
- {
- $this->artisan('schedule:interrupt', ['--minutes' => '0'])
- ->assertFailed();
- }
-
- public function testInterruptCommandRejectsNegativeMinutes(): void
- {
- $this->artisan('schedule:interrupt', ['--minutes' => '-1'])
- ->assertFailed();
- }
-
- public function testInterruptCommandRejectsNonNumericMinutes(): void
- {
- $this->artisan('schedule:interrupt', ['--minutes' => 'abc'])
- ->assertFailed();
- }
-
- public function testInterruptCommandRejectsDecimalMinutes(): void
- {
- $this->artisan('schedule:interrupt', ['--minutes' => '1.5'])
- ->assertFailed();
- }
-
- public function testInterruptCommandAcceptsCustomMinutes(): void
- {
- $cache = m::mock(Cache::class);
- $cache->shouldReceive('put')
- ->once()
- ->with('hypervel:schedule:interrupt', true, m::type(DateTimeInterface::class));
-
- $this->app->instance(Cache::class, $cache);
-
- $this->artisan('schedule:interrupt', ['--minutes' => '5'])
- ->assertSuccessful();
- }
-}
diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php
index 3f168ea8a..7c83b95ef 100644
--- a/tests/Console/Scheduling/ScheduleRunCommandTest.php
+++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php
@@ -497,6 +497,7 @@ public function testRunOncePreservesLogContextAcrossItsOuterCoroutine(): void
$command = $this->makeCommand();
(new ReflectionProperty($command, 'schedule'))->setValue($command, $schedule);
$command->setInput(new ArrayInput(['--whisper' => true], $command->getDefinition()));
+ (new ReflectionProperty($command, 'startedAt'))->setValue($command, Date::now());
$this->captureOutput($command);
(new ReflectionMethod($command, 'runOnce'))->invoke($command);
@@ -984,11 +985,11 @@ public function testPauseAndInterruptChecksDoNotReadTheCacheWhenPollingIsDisable
$command = $this->makeCommand($cache);
$this->invokeRunEvents($command, [$callbackEvent]);
- // A stale last check makes shouldStop() read the interrupt signal unless polling is disabled.
+ // A stale last check makes shouldInterrupt() read the interrupt signal unless polling is disabled.
(new ReflectionProperty($command, 'lastChecked'))->setValue($command, CarbonImmutable::now()->subSeconds(5));
$this->assertSame(1, $runCount);
- $this->assertFalse((new ReflectionMethod($command, 'shouldStop'))->invoke($command));
+ $this->assertFalse((new ReflectionMethod($command, 'shouldInterrupt'))->invoke($command));
}
public function testNonRepeatableEventOnlyRunsOncePerMinute(): void
@@ -1063,6 +1064,7 @@ public function testSingleServerEventClaimedElsewhereDoesNotReportThatNoCommands
$command = $this->makeCommand();
(new ReflectionProperty($command, 'schedule'))->setValue($command, $schedule);
$command->setInput(new ArrayInput([], $command->getDefinition()));
+ (new ReflectionProperty($command, 'startedAt'))->setValue($command, Date::now());
$output = $this->captureOutput($command);
(new ReflectionMethod($command, 'runOnce'))->invoke($command);
@@ -1237,7 +1239,6 @@ public function testInFlightExecutionsFinishBeforeTheWorkerQuits(): void
$schedule = m::mock(Schedule::class);
$schedule->expects('dueEventsAt')->andReturn(new Collection([$event]));
$cache = m::mock(Cache::class);
- $cache->expects('forget')->with('hypervel:schedule:interrupt');
$cache->shouldNotReceive('get');
$command = $this->makeCommand($cache);
$command->setInput(new ArrayInput([], $command->getDefinition()));
@@ -1372,30 +1373,6 @@ function () use ($waiting, $release, $signal): void {
$this->assertSame($first, $this->dispatched[0]->task);
}
- public function testRunOnceResetsThePreviousShutdownRequest(): void
- {
- $signal = null;
- $this->captureTerminationSignal($signal);
- $command = $this->makeCommand();
- (new ReflectionMethod($command, 'listenForSignals'))->invoke($command);
- $signal(SIGTERM);
- $ran = false;
- $event = new CallbackEvent(m::mock(EventMutex::class), function () use (&$ran): void {
- $ran = true;
- });
- $schedule = m::mock(Schedule::class);
- $schedule->expects('dueEventsAt')->andReturn(new Collection([$event]));
- $cache = m::mock(Cache::class);
- $cache->shouldReceive('get')->with('hypervel:schedule:paused', false)->andReturnFalse();
- $cache->shouldNotReceive('get')->with('hypervel:schedule:interrupt', false);
- $command->setInput(new ArrayInput(['--once' => true], $command->getDefinition()));
- $this->captureOutput($command);
-
- $command->handle($schedule, $this->dispatcher, $cache, $this->handler);
-
- $this->assertTrue($ran);
- }
-
public function testSecondTerminationSignalReleasesMutexesBeforeTerminating(): void
{
$process = new Process([PHP_BINARY, '-r', <<<'PHP'
diff --git a/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php
new file mode 100644
index 000000000..cac00c30d
--- /dev/null
+++ b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php
@@ -0,0 +1,67 @@
+app->make(Schedule::class);
+ $startedAt = CarbonImmutable::now();
+
+ CarbonImmutable::setTestNow($startedAt->addSecond());
+
+ $this->artisan('schedule:interrupt')->assertSuccessful();
+
+ $this->assertTrue($schedule->hasBeenInterruptedSince($startedAt));
+ $this->assertFalse($schedule->hasBeenInterruptedSince(CarbonImmutable::now()->addSecond()));
+ }
+
+ public function testReadsTheTimestampAsStoresSuchAsRedisReturnIt(): void
+ {
+ Cache::forever('hypervel:schedule:interrupt', (string) CarbonImmutable::now()->getTimestampMs());
+
+ $this->assertTrue($this->app->make(Schedule::class)->hasBeenInterruptedSince(CarbonImmutable::now()->subSecond()));
+ }
+
+ public function testDoesNotInterruptWhenInterruptingIsDisabled(): void
+ {
+ $this->artisan('schedule:interrupt');
+
+ Schedule::$interruptible = false;
+
+ $this->assertFalse($this->app->make(Schedule::class)->hasBeenInterruptedSince(CarbonImmutable::now()->subMinute()));
+ }
+
+ public function testLaterScheduleRunsPreserveEarlierInterruptionsAfterMoreThanAMinute(): void
+ {
+ $schedule = $this->app->make(Schedule::class);
+ $earlierStart = CarbonImmutable::now()->startOfMinute();
+ CarbonImmutable::setTestNow($earlierStart->addSecond());
+
+ $this->artisan('schedule:interrupt')->assertSuccessful();
+
+ $laterStart = $earlierStart->addMinutes(2)->addSeconds(57);
+ CarbonImmutable::setTestNow($laterStart);
+ $runs = 0;
+ $schedule->call(function () use (&$runs): void {
+ ++$runs;
+ })->everySecond();
+ Sleep::fake(syncWithCarbon: true);
+
+ $this->artisan('schedule:run', ['--once' => true])->assertSuccessful();
+
+ $this->assertSame(3, $runs);
+ $this->assertTrue($schedule->hasBeenInterruptedSince($earlierStart));
+ }
+}
diff --git a/tests/Queue/ListenCommandTest.php b/tests/Queue/ListenCommandTest.php
index add6333a5..c23127274 100644
--- a/tests/Queue/ListenCommandTest.php
+++ b/tests/Queue/ListenCommandTest.php
@@ -8,9 +8,22 @@
use Hypervel\Queue\ListenerOptions;
use Hypervel\Testbench\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
class ListenCommandTest extends TestCase
{
+ #[TestWith(['60%'])]
+ #[TestWith(['128.5'])]
+ public function testMemoryLimitIsPassedToTheListenerUnchanged(string $memory): void
+ {
+ $listener = m::mock(Listener::class);
+ $listener->allows('setOutputHandler');
+ $listener->expects('listen')->withArgs(static fn (?string $connection, string $queue, ListenerOptions $options): bool => $options->memory === $memory);
+ $this->app->instance(Listener::class, $listener);
+
+ $this->artisan('queue:listen', ['--memory' => $memory])->assertSuccessful();
+ }
+
public function testFractionalSleepAndRestSecondsAreKept(): void
{
$listener = m::mock(Listener::class);
diff --git a/tests/Queue/Outbox/OutboxWorkerTest.php b/tests/Queue/Outbox/OutboxWorkerTest.php
index 5e25ca9c6..1f968f790 100644
--- a/tests/Queue/Outbox/OutboxWorkerTest.php
+++ b/tests/Queue/Outbox/OutboxWorkerTest.php
@@ -255,6 +255,22 @@ public function testMemoryLimitStopsBeforeClaiming(): void
$this->assertSame(Worker::EXIT_MEMORY_LIMIT, $this->worker()->run(null, new OutboxWorkerOptions(memory: 1)));
}
+ public function testPercentageMemoryLimitControlsAdmission(): void
+ {
+ $originalLimit = ini_get('memory_limit');
+ ini_set('memory_limit', (string) (memory_get_usage(true) * 4));
+
+ try {
+ $worker = $this->worker();
+ $this->relay->expects('claim')->with('primary', 100)->andReturn([]);
+
+ $this->assertSame(Worker::EXIT_SUCCESS, $worker->run(null, new OutboxWorkerOptions(memory: '50%', once: true)));
+ $this->assertSame(Worker::EXIT_MEMORY_LIMIT, $this->worker()->run(null, new OutboxWorkerOptions(memory: '1%')));
+ } finally {
+ ini_set('memory_limit', $originalLimit);
+ }
+ }
+
/**
* Construct an isolated worker with observable dependencies and signal ownership.
*/
diff --git a/tests/Queue/QueueListenerTest.php b/tests/Queue/QueueListenerTest.php
index f87f95562..ef5374f48 100644
--- a/tests/Queue/QueueListenerTest.php
+++ b/tests/Queue/QueueListenerTest.php
@@ -9,6 +9,7 @@
use Hypervel\Support\Sleep;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Symfony\Component\Process\Process;
@@ -61,12 +62,29 @@ public function testListenRestsForFractionalSecondsBetweenWorkers(): void
Sleep::assertSequence([Sleep::usleep(250_000)]);
}
- public function testMakeProcessCorrectlyFormatsCommandLine(): void
+ public function testListenerChecksPercentageMemoryLimits(): void
+ {
+ $listener = new Listener(__DIR__);
+ $originalLimit = ini_get('memory_limit');
+ ini_set('memory_limit', (string) (memory_get_usage(true) * 4));
+
+ try {
+ $this->assertFalse($listener->memoryExceeded('50%'));
+ $this->assertTrue($listener->memoryExceeded('1%'));
+ } finally {
+ ini_set('memory_limit', $originalLimit);
+ }
+ }
+
+ #[TestWith([2])]
+ #[TestWith(['60%'])]
+ #[TestWith([128.5])]
+ public function testMakeProcessCorrectlyFormatsCommandLine(float|string $memory): void
{
$listener = new Listener(__DIR__);
$options = new ListenerOptions;
$options->backoff = 1;
- $options->memory = 2;
+ $options->memory = $memory;
$options->timeout = 3;
$process = $listener->makeProcess('connection', 'queue', $options);
$escape = '\\' === DIRECTORY_SEPARATOR ? '' : '\'';
@@ -75,7 +93,7 @@ public function testMakeProcessCorrectlyFormatsCommandLine(): void
$this->assertInstanceOf(Process::class, $process);
$this->assertSame(__DIR__, $process->getWorkingDirectory());
$this->assertSame(3.0, $process->getTimeout());
- $this->assertSame($escape . php_binary() . $escape . " {$escape}{$artisanBinary}{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--name=default{$escape} {$escape}--queue=queue{$escape} {$escape}--backoff=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape}", $process->getCommandLine());
+ $this->assertSame($escape . php_binary() . $escape . " {$escape}{$artisanBinary}{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--name=default{$escape} {$escape}--queue=queue{$escape} {$escape}--backoff=1{$escape} {$escape}--memory={$memory}{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape}", $process->getCommandLine());
}
public function testMakeProcessCorrectlyFormatsCommandLineWithAnEnvironmentSpecified(): void
diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php
index 042f6d5ad..81a6984d4 100644
--- a/tests/Queue/QueueWorkerTest.php
+++ b/tests/Queue/QueueWorkerTest.php
@@ -1585,6 +1585,34 @@ public function testWorkerMemoryExceededWhenMemoryIsNegative(): void
$this->assertFalse($worker->memoryExceeded(-1));
}
+ public function testWorkerMemoryExceededWhenMemoryIsAPercentage(): void
+ {
+ $worker = new class(...$this->workerDependencies()) extends Worker {
+ /**
+ * Get the current memory usage in megabytes.
+ */
+ protected function currentMemoryUsage(): float
+ {
+ return 512;
+ }
+ };
+
+ $original = ini_set('memory_limit', '1G');
+
+ try {
+ $this->assertTrue($worker->memoryExceeded('50%'));
+ $this->assertFalse($worker->memoryExceeded('51%'));
+ $this->assertFalse($worker->memoryExceeded('512.5'));
+
+ ini_set('memory_limit', '-1');
+
+ $this->assertFalse($worker->memoryExceeded('50%'));
+ $this->assertTrue($worker->memoryExceeded(512));
+ } finally {
+ ini_set('memory_limit', $original);
+ }
+ }
+
public function testDaemonShouldRunSkipsLoopingEventWhenNoListenersAreRegistered(): void
{
$events = m::mock(EventDispatcher::class);
@@ -3501,7 +3529,7 @@ public function daemonShouldRun(WorkerOptions $options, string $connectionName,
return ! ($this->isDownForMaintenance)();
}
- public function memoryExceeded(float $memoryLimit): bool
+ public function memoryExceeded(float|string $memoryLimit): bool
{
return $this->stopOnMemoryExceeded;
}
diff --git a/tests/Queue/RelayCommandTest.php b/tests/Queue/RelayCommandTest.php
index bebad1f01..3664663f5 100644
--- a/tests/Queue/RelayCommandTest.php
+++ b/tests/Queue/RelayCommandTest.php
@@ -55,6 +55,21 @@ public function testCommandDefaultsToAllConfiguredSources(): void
$this->assertSame(0, Artisan::call('queue:relay'));
}
+ public function testPercentageMemoryLimitIsPassedToTheWorkerUnchanged(): void
+ {
+ $originalLimit = ini_get('memory_limit');
+ ini_set('memory_limit', (string) (memory_get_usage(true) * 4));
+ $worker = m::mock(OutboxWorker::class);
+ $worker->expects('run')->with(null, m::on(fn (OutboxWorkerOptions $options): bool => $options->memory === '60%'))->andReturn(0);
+ $this->app->instance(OutboxWorker::class, $worker);
+
+ try {
+ $this->assertSame(0, Artisan::call('queue:relay', ['--memory' => '60%']));
+ } finally {
+ ini_set('memory_limit', $originalLimit);
+ }
+ }
+
#[DataProvider('invalidOptions')]
public function testInvalidOptionsFailBeforeTheWorkerStarts(array $options): void
{
@@ -75,6 +90,8 @@ public static function invalidOptions(): array
'hot-loop sleep' => [['--sleep' => 0]],
'no capacity' => [['--concurrency' => 0]],
'negative maximum' => [['--max-jobs' => -1]],
+ 'zero memory' => [['--memory' => 0]],
+ 'zero percentage' => [['--memory' => '0%']],
];
}
}
diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php
index cab06ea27..6db08cce9 100644
--- a/tests/Queue/WorkCommandTest.php
+++ b/tests/Queue/WorkCommandTest.php
@@ -101,6 +101,19 @@ public function testFractionalSleepAndRestSecondsAreKept(): void
$this->assertSame(0.25, $options->rest);
}
+ public function testPercentageMemoryLimitIsKept(): void
+ {
+ $command = new WorkCommandOutputStub(
+ $this->app,
+ $this->app->make('config'),
+ m::mock(Worker::class),
+ $this->app->make('cache'),
+ );
+ $command->setInput(new ArrayInput(['--memory' => '50%'], $command->getDefinition()));
+
+ $this->assertSame('50%', $command->workerOptions()->memory);
+ }
+
#[DataProvider('queueStatusOutputProvider')]
public function testQueueStatusOutputUsesTheCurrentCommand(bool $json, bool $decorated, string $queue): void
{
From 91b3d8a05fe4561d77009c8bf6ebaecd610066ca Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 13:35:58 +0000
Subject: [PATCH 13/20] Handle directories removed during parallel test cleanup
A second process can remove a compiled-view directory after the initial directory check but before FilesystemIterator opens it. Handle this race in Filesystem::deleteDirectory so all cleanup callers benefit, rather than suppressing every iterator failure in the testing callback.
Clear cached file metadata after iterator construction fails, return false when the directory disappeared, and rethrow when it still exists. Preserve recursive failure reporting and symlink ownership without adding work to normal deletion. The existing ensureDirectoryExists already tolerates concurrent creation while reporting genuine failures.
Reconcile Laravel framework #61786 (6070a6d7e9804a490b12cb889173fbb926b8e3c8), including both callback regression tests. Adapt their stale metadata to a single check and retain isolated temporary-directory cleanup. Add an external-process deletion regression that fails both without the fix and without its stat-cache clear under Swoole hooks.
Validated the complete changed test files, Filesystem and Testing parallel suites, formatting and full source/type/skeleton analysis.
---
src/filesystem/src/Filesystem.php | 15 +++-
tests/Filesystem/FilesystemTest.php | 32 +++++++
tests/Testing/Concerns/TestViewsTest.php | 102 +++++++++++++++++++++++
3 files changed, 148 insertions(+), 1 deletion(-)
diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php
index 426b09868..80460a422 100644
--- a/src/filesystem/src/Filesystem.php
+++ b/src/filesystem/src/Filesystem.php
@@ -19,6 +19,7 @@
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
+use UnexpectedValueException;
class Filesystem
{
@@ -672,7 +673,19 @@ public function deleteDirectory(string $directory, bool $preserve = false): bool
return false;
}
- $items = new FilesystemIterator($directory);
+ try {
+ $items = new FilesystemIterator($directory);
+ } catch (UnexpectedValueException $exception) {
+ // Another process may remove the directory after the check above.
+ $this->clearStatCache($directory);
+
+ if (! $this->isDirectory($directory)) {
+ return false;
+ }
+
+ throw $exception;
+ }
+
$success = true;
foreach ($items as $item) {
diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php
index c2b532557..9a20bc502 100755
--- a/tests/Filesystem/FilesystemTest.php
+++ b/tests/Filesystem/FilesystemTest.php
@@ -366,6 +366,38 @@ public function testDeleteDirectoryReturnFalseWhenNotADirectory(): void
$this->assertFalse($files->deleteDirectory($this->tempDir . '/bar/file.txt'));
}
+ public function testDeleteDirectoryReturnsFalseWhenAnotherProcessRemovesItAfterTheCheck(): void
+ {
+ $directory = $this->tempDir . '/removed-by-another-process';
+ mkdir($directory);
+ $files = new class extends Filesystem {
+ private bool $checked = false;
+
+ /**
+ * Remove the directory after reading its initial metadata.
+ */
+ public function isDirectory(string $directory): bool
+ {
+ $exists = parent::isDirectory($directory);
+
+ if (! $this->checked) {
+ $this->checked = true;
+ // In-process removal clears PHP's stat cache and would hide the race.
+ exec('rmdir ' . escapeshellarg($directory), $output, $status);
+
+ if ($status !== 0) {
+ throw new RuntimeException('The external directory removal failed.');
+ }
+ }
+
+ return $exists;
+ }
+ };
+
+ $this->assertFalse($files->deleteDirectory($directory));
+ $this->assertDirectoryDoesNotExist($directory);
+ }
+
public function testDeleteDirectoryReportsFailureAfterAttemptingEveryEntry(): void
{
$directory = $this->tempDir . '/exhaustive-delete';
diff --git a/tests/Testing/Concerns/TestViewsTest.php b/tests/Testing/Concerns/TestViewsTest.php
index 7ee71316d..24d13c41c 100644
--- a/tests/Testing/Concerns/TestViewsTest.php
+++ b/tests/Testing/Concerns/TestViewsTest.php
@@ -4,10 +4,12 @@
namespace Hypervel\Tests\Testing\Concerns;
+use ErrorException;
use Hypervel\Config\Repository as Config;
use Hypervel\Container\Container;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Support\Facades\Facade;
+use Hypervel\Support\Facades\File;
use Hypervel\Testing\Concerns\TestViews;
use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
@@ -176,6 +178,106 @@ public function testTearDownProcessDeletesCompiledViewDirectory(): void
$this->assertFileExists($this->tempDir . '/shared.php');
}
+ public function testSetUpProcessToleratesDirectoryCreatedByConcurrentRun(): void
+ {
+ $path = $this->tempDir . '/test_9';
+
+ // Another parallel run created the directory after the first metadata check.
+ mkdir($path, 0755, true);
+
+ $this->bootTestViewsUsingFilesystem($this->tempDir, '9', new class extends Filesystem {
+ private bool $checked = false;
+
+ /**
+ * Return the stale metadata once, then observe the concurrent creation.
+ */
+ public function isDirectory(string $directory): bool
+ {
+ if (! $this->checked) {
+ $this->checked = true;
+
+ return false;
+ }
+
+ return parent::isDirectory($directory);
+ }
+ });
+
+ $this->withWarningsAsExceptions(
+ fn () => Container::getInstance()->make(ParallelTesting::class)->callSetUpProcessCallbacks()
+ );
+
+ $this->assertDirectoryExists($path);
+ }
+
+ public function testTearDownProcessToleratesDirectoryRemovedByConcurrentRun(): void
+ {
+ $path = $this->tempDir . '/test_9';
+
+ // Another parallel run removed the directory after the first metadata check.
+ $this->bootTestViewsUsingFilesystem($this->tempDir, '9', new class extends Filesystem {
+ private bool $checked = false;
+
+ /**
+ * Return the stale metadata once, then observe the concurrent removal.
+ */
+ public function isDirectory(string $directory): bool
+ {
+ if (! $this->checked) {
+ $this->checked = true;
+
+ return true;
+ }
+
+ return parent::isDirectory($directory);
+ }
+ });
+
+ $this->withWarningsAsExceptions(
+ fn () => Container::getInstance()->make(ParallelTesting::class)->callTearDownProcessCallbacks()
+ );
+
+ $this->assertDirectoryDoesNotExist($path);
+ }
+
+ /**
+ * Boot the view callbacks with the given filesystem and worker token.
+ */
+ protected function bootTestViewsUsingFilesystem(string $compiled, string $token, Filesystem $files): void
+ {
+ $container = Container::getInstance();
+ $container->make('config')->set('view.compiled', $compiled);
+ $container->make(ParallelTesting::class)->resolveTokenUsing(fn () => $token);
+ $container->instance('files', $files);
+ File::clearResolvedInstance();
+ $instance = $this->makeTestViewsInstance();
+
+ (new ReflectionMethod($instance, 'bootTestViews'))->invoke($instance);
+ }
+
+ /**
+ * Run the callback with unsuppressed warnings converted to exceptions.
+ */
+ protected function withWarningsAsExceptions(callable $callback): void
+ {
+ $reporting = error_reporting();
+
+ set_error_handler(function (int $severity, string $message, string $file, int $line) use ($reporting): bool {
+ // A changed level means the warning was silenced with the @ operator...
+ if (error_reporting() !== $reporting) {
+ return false;
+ }
+
+ throw new ErrorException($message, 0, $severity, $file, $line);
+ });
+
+ try {
+ $callback();
+ } finally {
+ restore_error_handler();
+ }
+ }
+
/**
* Get the compiled view path for the current process.
*/
From 68d7387f62a9f144187970f97f059af95bcc0d6d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:26:30 +0000
Subject: [PATCH 14/20] Preserve exact remaining counts in Redis throttles
Large integer limits lose precision when Lua calculates the remaining allowance. PHP_INT_MAX can become a negative Redis integer reply, causing a fresh limiter to report no remaining requests.
Return the stored window count as an exact string and calculate the allowance in PHP. Keep atomic admission, expiry, and Redis command counts unchanged. Cover fresh and occupied windows with PHP_INT_MAX, and remove the one-second timing dependency from the occupied-state regression while retaining real-expiry coverage elsewhere.
Validated with the Redis integration tests, formatting, full static analysis, and the affected parallel suites.
---
src/redis/src/Limiters/DurationLimiter.php | 17 ++++++------
.../Redis/DurationLimiterIntegrationTest.php | 26 +++++++++++++++----
2 files changed, 30 insertions(+), 13 deletions(-)
diff --git a/src/redis/src/Limiters/DurationLimiter.php b/src/redis/src/Limiters/DurationLimiter.php
index f19f5592e..e1c6f71a3 100644
--- a/src/redis/src/Limiters/DurationLimiter.php
+++ b/src/redis/src/Limiters/DurationLimiter.php
@@ -84,7 +84,8 @@ public function acquire(): bool
$this->decaysAt = (int) $results[1];
- $this->remaining = max(0, (int) $results[2]);
+ // Lua numbers lose precision above 2^53, so remaining is calculated here.
+ $this->remaining = max(0, $this->maxLocks - (int) $results[2]);
return (bool) $results[0];
}
@@ -103,7 +104,7 @@ public function tooManyAttempts(): bool
);
$this->decaysAt = (int) $results[0];
- $this->remaining = max(0, (int) $results[1]);
+ $this->remaining = max(0, $this->maxLocks - (int) $results[1]);
return $this->remaining <= 0;
}
@@ -134,18 +135,18 @@ protected function luaScript(): string
end
if redis.call('EXISTS', KEYS[1]) == 0 then
- return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1}
+ return {reset(), ARGV[2] + ARGV[3], 1}
end
if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then
return {
tonumber(redis.call('HINCRBY', KEYS[1], 'count', 1)) <= tonumber(ARGV[4]),
redis.call('HGET', KEYS[1], 'end'),
- ARGV[4] - redis.call('HGET', KEYS[1], 'count')
+ redis.call('HGET', KEYS[1], 'count')
}
end
-return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1}
+return {reset(), ARGV[2] + ARGV[3], 1}
LUA;
}
@@ -163,17 +164,17 @@ protected function tooManyAttemptsLuaScript(): string
return <<<'LUA'
if redis.call('EXISTS', KEYS[1]) == 0 then
- return {ARGV[2] + ARGV[3], tonumber(ARGV[4])}
+ return {ARGV[2] + ARGV[3], 0}
end
if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then
return {
redis.call('HGET', KEYS[1], 'end'),
- ARGV[4] - redis.call('HGET', KEYS[1], 'count')
+ redis.call('HGET', KEYS[1], 'count')
}
end
-return {ARGV[2] + ARGV[3], tonumber(ARGV[4])}
+return {ARGV[2] + ARGV[3], 0}
LUA;
}
}
diff --git a/tests/Integration/Redis/DurationLimiterIntegrationTest.php b/tests/Integration/Redis/DurationLimiterIntegrationTest.php
index f77ffdc51..edcfa63e7 100644
--- a/tests/Integration/Redis/DurationLimiterIntegrationTest.php
+++ b/tests/Integration/Redis/DurationLimiterIntegrationTest.php
@@ -111,18 +111,17 @@ public function testAcquireSetsDecaysAtAndRemaining(): void
public function testTooManyAttemptsReportsCorrectly(): void
{
$redis = $this->redis();
- $limiter = new DurationLimiter($redis, 'too-many-key', 2, 1);
+ $limiter = new DurationLimiter($redis, 'too-many-key', 2, 60);
$before = time();
// Initially, should not have too many attempts
$this->assertFalse($limiter->tooManyAttempts());
- $this->assertGreaterThanOrEqual($before + 1, $limiter->decaysAt);
- $this->assertLessThanOrEqual(time() + 1, $limiter->decaysAt);
+ $this->assertGreaterThanOrEqual($before + 60, $limiter->decaysAt);
+ $this->assertLessThanOrEqual(time() + 60, $limiter->decaysAt);
$this->assertSame(2, $limiter->remaining);
$this->assertSame(0, $redis->exists('too-many-key'));
// Use up the available slots
- $this->waitForNextSecond();
$this->assertTrue($limiter->acquire());
$this->assertTrue($limiter->acquire());
@@ -133,10 +132,27 @@ public function testTooManyAttemptsReportsCorrectly(): void
$this->assertSame('2', $redis->hget('too-many-key', 'count'));
// After decay window, attempts should be allowed again
- sleep(1);
+ $redis->hset('too-many-key', 'end', time() - 1);
$this->assertFalse($limiter->tooManyAttempts());
}
+ public function testRemainingAttemptsStayExactWithLargeLimits(): void
+ {
+ $limiter = new DurationLimiter($this->redis(), 'large-limit-key', PHP_INT_MAX, 60);
+
+ $this->assertFalse($limiter->tooManyAttempts());
+ $this->assertSame(PHP_INT_MAX, $limiter->remaining);
+
+ $this->assertTrue($limiter->acquire());
+ $this->assertSame(PHP_INT_MAX - 1, $limiter->remaining);
+
+ $this->assertFalse($limiter->tooManyAttempts());
+ $this->assertSame(PHP_INT_MAX - 1, $limiter->remaining);
+
+ $this->assertTrue($limiter->acquire());
+ $this->assertSame(PHP_INT_MAX - 2, $limiter->remaining);
+ }
+
public function testTooManyAttemptsClampsOverLimitRemainingCount(): void
{
$limiter = new DurationLimiter($this->redis(), 'over-limit-key', 2, 60);
From a12868f461a5c60979adbf7f5736efede5ad8593 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:26:54 +0000
Subject: [PATCH 15/20] Match only readable instance properties in fake
assertions
Property-map assertions treated inaccessible or uninitialized properties as null, allowing assertions to pass for values that were never exposed. Class-name events could also match properties despite carrying no object.
Reject string events and verify public, initialized instance properties on the null path. Preserve dynamic null properties, property hooks, magic access, callable-array callbacks, and model identity matching. Read a verified property directly instead of invoking magic existence checks again.
Added focused queue and event regressions. All four fake suites pass, together with formatting and static analysis.
---
.../src/Testing/Fakes/MatchesProperties.php | 17 ++++++++--
tests/Support/SupportTestingEventFakeTest.php | 7 ++++
tests/Support/SupportTestingQueueFakeTest.php | 34 +++++++++++++++++++
3 files changed, 56 insertions(+), 2 deletions(-)
diff --git a/src/support/src/Testing/Fakes/MatchesProperties.php b/src/support/src/Testing/Fakes/MatchesProperties.php
index f1384bf14..c97fcddb8 100644
--- a/src/support/src/Testing/Fakes/MatchesProperties.php
+++ b/src/support/src/Testing/Fakes/MatchesProperties.php
@@ -6,6 +6,7 @@
use Hypervel\Database\Eloquent\Model;
use Hypervel\Support\Collection;
+use ReflectionProperty;
trait MatchesProperties
{
@@ -21,11 +22,23 @@ protected function resolveTruthTest(callable|array|null $callback): ?callable
}
return fn (object|string $object): bool => (new Collection($callback))->every(function (mixed $expected, string $property) use ($object): bool {
- if (! property_exists($object, $property) && ! isset($object->{$property})) {
+ if (! is_object($object)) {
return false;
}
- $actual = $object->{$property} ?? null;
+ if (! isset($object->{$property})) {
+ if (! property_exists($object, $property)) {
+ return false;
+ }
+
+ $reflection = new ReflectionProperty($object, $property);
+
+ if (! $reflection->isPublic() || $reflection->isStatic() || ! $reflection->isInitialized($object)) {
+ return false;
+ }
+ }
+
+ $actual = $object->{$property};
return $expected instanceof Model && $actual instanceof Model
? $expected->is($actual)
diff --git a/tests/Support/SupportTestingEventFakeTest.php b/tests/Support/SupportTestingEventFakeTest.php
index ad42105c2..b599610cc 100644
--- a/tests/Support/SupportTestingEventFakeTest.php
+++ b/tests/Support/SupportTestingEventFakeTest.php
@@ -108,6 +108,13 @@ public function testAssertDispatchedWithArrayOfProperties(): void
$this->fake->assertNotDispatched(EventWithPropertiesStub::class, ['status' => 'complete']);
}
+ public function testPropertyMapsDoNotMatchStringEvents(): void
+ {
+ $this->fake->dispatch(EventWithPropertiesStub::class);
+
+ $this->fake->assertNotDispatched(EventWithPropertiesStub::class, ['status' => null]);
+ }
+
public function testAssertNotDispatched(): void
{
$this->fake->assertNotDispatched(EventStub::class);
diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php
index b3ac0ea5a..3710da006 100644
--- a/tests/Support/SupportTestingQueueFakeTest.php
+++ b/tests/Support/SupportTestingQueueFakeTest.php
@@ -25,6 +25,7 @@
use Mockery as m;
use PHPUnit\Framework\ExpectationFailedException;
use RuntimeException;
+use stdClass;
class SupportTestingQueueFakeTest extends TestCase
{
@@ -116,6 +117,39 @@ public function testAssertPushedWithArrayOfPropertiesDoesNotMatchMissingOrMismat
$this->fake->assertPushed(JobWithPropertiesStub::class, ['model' => null]);
}
+ public function testPropertyMapsDoNotMatchUnreadablePropertiesAsNull(): void
+ {
+ $job = new class {
+ public static ?string $shared = null;
+
+ public string $uninitialized;
+
+ protected string $hidden = 'pending';
+
+ private string $secret = 'pending';
+ };
+
+ $this->fake->push($job);
+
+ foreach (['shared', 'uninitialized', 'hidden', 'secret'] as $property) {
+ $this->fake->assertNotPushed($job::class, [$property => null]);
+ }
+ }
+
+ public function testPropertyMapsMatchNullDynamicPropertiesAndPropertyHooks(): void
+ {
+ $job = new class extends stdClass {
+ public ?string $status {
+ get => null;
+ }
+ };
+ $job->dynamic = null;
+
+ $this->fake->push($job);
+
+ $this->fake->assertPushed($job::class, ['dynamic' => null, 'status' => null]);
+ }
+
public function testAssertPushedPreservesArrayCallbacks(): void
{
$predicate = fn (JobWithPropertiesStub $job): bool => $job->status === 'pending';
From 659b780108d50ba65b382b9b32ef2bd52aa52a90 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:26:54 +0000
Subject: [PATCH 16/20] Resolve schema columns by exact name before case
folding
Case-insensitive column inspection could select the wrong PostgreSQL column when quoted names differed only by case, and ASCII-only folding missed accented names. Column existence checks also considered distinct numeric names equal through loose comparison.
Prefer exact metadata names and retain the first Unicode-insensitive fallback in one pass. Share that lookup with getColumnType, and use strict Unicode-aware existence checks while preserving the getColumnListing extension path. No additional metadata query is needed.
Cover exact and fallback selection, type definitions, accented names, and distinct numeric names. Extend the existing database integration regression and clarify the lookup documentation. Schema tests, affected parallel suites, formatting, and full static analysis pass.
---
src/database/src/Schema/Builder.php | 30 ++++++----
src/docs/database.md | 2 +-
tests/Database/DatabaseSchemaBuilderTest.php | 55 +++++++++++++++++++
.../Database/SchemaBuilderTest.php | 2 +
4 files changed, 76 insertions(+), 13 deletions(-)
diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php
index 2d676abeb..61a8b3275 100755
--- a/src/database/src/Schema/Builder.php
+++ b/src/database/src/Schema/Builder.php
@@ -11,6 +11,7 @@
use Hypervel\Database\MultipleColumnsSelectedException;
use Hypervel\Database\PostgresConnection;
use Hypervel\Database\Schema\Grammars\Grammar;
+use Hypervel\Support\Str;
use Hypervel\Support\Traits\Macroable;
use InvalidArgumentException;
use LogicException;
@@ -260,8 +261,9 @@ public function getTypes(array|string|null $schema = null): array
public function hasColumn(string $table, string $column): bool
{
return in_array(
- strtolower($column),
- array_map(strtolower(...), $this->getColumnListing($table))
+ Str::lower($column),
+ array_map(Str::lower(...), $this->getColumnListing($table)),
+ true
);
}
@@ -272,11 +274,11 @@ public function hasColumn(string $table, string $column): bool
*/
public function hasColumns(string $table, array $columns): bool
{
- $tableColumns = array_map(strtolower(...), $this->getColumnListing($table));
+ $tableColumns = array_map(Str::lower(...), $this->getColumnListing($table));
// Keep this loop to avoid an extra callback per item.
foreach ($columns as $column) {
- if (! in_array(strtolower($column), $tableColumns)) {
+ if (! in_array(Str::lower($column), $tableColumns, true)) {
return false;
}
}
@@ -331,12 +333,10 @@ public function whenTableDoesntHaveIndex(string $table, array|string $index, Clo
*/
public function getColumnType(string $table, string $column, bool $fullDefinition = false): string
{
- $columns = $this->getColumns($table);
+ $value = $this->getColumn($table, $column);
- foreach ($columns as $value) {
- if (strtolower($value['name']) === strtolower($column)) {
- return $fullDefinition ? $value['type'] : $value['type_name'];
- }
+ if ($value !== []) {
+ return $fullDefinition ? $value['type'] : $value['type_name'];
}
throw new InvalidArgumentException("There is no column with name '{$column}' on table '{$table}'.");
@@ -359,15 +359,21 @@ public function getColumnListing(string $table): array
*/
public function getColumn(string $table, string $column): array
{
- $column = strtolower($column);
+ $lowercaseColumn = Str::lower($column);
+ $match = [];
foreach ($this->getColumns($table) as $value) {
- if (strtolower($value['name']) === $column) {
+ if ($value['name'] === $column) {
return $value;
}
+
+ // Keep the first case-insensitive match unless an exact name appears later.
+ if ($match === [] && Str::lower($value['name']) === $lowercaseColumn) {
+ $match = $value;
+ }
}
- return [];
+ return $match;
}
/**
diff --git a/src/docs/database.md b/src/docs/database.md
index 23065b8c4..cda6734a4 100644
--- a/src/docs/database.md
+++ b/src/docs/database.md
@@ -1071,7 +1071,7 @@ $indexes = Schema::getIndexes('users');
$foreignKeys = Schema::getForeignKeys('users');
```
-To inspect a single column, use the `getColumn` method. Column names are matched without regard to case, and an empty array is returned if the column does not exist:
+To inspect a single column, use the `getColumn` method. An exact column name takes precedence over a case-insensitive match, and an empty array is returned if the column does not exist:
```php
$column = Schema::getColumn('users', 'email');
diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php
index 83c0e5f8f..688864102 100644
--- a/tests/Database/DatabaseSchemaBuilderTest.php
+++ b/tests/Database/DatabaseSchemaBuilderTest.php
@@ -348,6 +348,61 @@ public function testTableHasColumns(): void
$this->assertFalse($builder->hasColumns('users', ['id', 'address']));
}
+ #[DataProvider('columnNames')]
+ public function testColumnExistenceMatchesUnicodeWithoutCoercingNumericNames(string $column, bool $exists): void
+ {
+ $connection = m::mock(Connection::class);
+ $connection->expects('getSchemaGrammar')->andReturn(m::mock(Grammar::class));
+ $builder = m::mock(Builder::class . '[getColumnListing]', [$connection]);
+ $builder->expects('getColumnListing')->with('users')->twice()->andReturn(['Émail', '01']);
+
+ $this->assertSame($exists, $builder->hasColumn('users', $column));
+ $this->assertSame($exists, $builder->hasColumns('users', [$column]));
+ }
+
+ /**
+ * Provide column names and their expected existence.
+ */
+ public static function columnNames(): array
+ {
+ return [
+ ['émail', true],
+ ['01', true],
+ ['1', false],
+ ];
+ }
+
+ #[DataProvider('columnLookups')]
+ public function testColumnLookupPrefersExactNamesBeforeCaseInsensitiveMatches(string $column, int $index): void
+ {
+ $columns = [
+ ['name' => 'foo', 'type_name' => 'integer', 'type' => 'integer'],
+ ['name' => 'FOO', 'type_name' => 'varchar', 'type' => 'varchar(255)'],
+ ['name' => 'Émail', 'type_name' => 'text', 'type' => 'text'],
+ ];
+ $connection = m::mock(Connection::class);
+ $connection->expects('getSchemaGrammar')->andReturn(m::mock(Grammar::class));
+ $builder = m::mock(Builder::class . '[getColumns]', [$connection]);
+ $builder->expects('getColumns')->with('users')->times(3)->andReturn($columns);
+
+ $this->assertSame($columns[$index], $builder->getColumn('users', $column));
+ $this->assertSame($columns[$index]['type_name'], $builder->getColumnType('users', $column));
+ $this->assertSame($columns[$index]['type'], $builder->getColumnType('users', $column, true));
+ }
+
+ /**
+ * Provide exact and case-insensitive column lookups.
+ */
+ public static function columnLookups(): array
+ {
+ return [
+ ['foo', 0],
+ ['FOO', 1],
+ ['fOo', 0],
+ ['émail', 2],
+ ];
+ }
+
public function testGetColumnTypeAddsPrefix(): void
{
$connection = m::mock(Connection::class);
diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php
index b8fd9f956..13052d6bd 100644
--- a/tests/Integration/Database/SchemaBuilderTest.php
+++ b/tests/Integration/Database/SchemaBuilderTest.php
@@ -454,9 +454,11 @@ public function testGetColumnIsCaseInsensitive(): void
{
Schema::create('foo', function (Blueprint $table) {
$table->string('bar');
+ $table->string('Émail');
});
$this->assertSame('bar', Schema::getColumn('foo', 'BAR')['name']);
+ $this->assertSame('Émail', Schema::getColumn('foo', 'émail')['name']);
}
public function testGetColumnWithUnknownColumn(): void
From c92768ced02767e72ecb8e2cf14ddd1ee191958d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:27:06 +0000
Subject: [PATCH 17/20] Link S3 provider customization to the porting guide
Keep the filesystem README focused on the pool-identity difference and the method used to customize provider resolution. Link the canonical porting guidance instead of repeating per-client implementation details.
Verified the destination section and retained the explanation needed when adapting a filesystem manager.
---
src/filesystem/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/filesystem/README.md b/src/filesystem/README.md
index 02e6833dc..a4c868e15 100644
--- a/src/filesystem/README.md
+++ b/src/filesystem/README.md
@@ -21,7 +21,7 @@ On read-through disks, raw `listContents()` results from pooled, S3 and Google C
Filesystem construction differs from Laravel in how it carries logical disk identity. `callCustomCreator()` accepts the logical disk name as an optional second parameter, so existing one-argument calls remain valid while overrides must adopt the parameter. The public `build()` method uses a logical-name-aware construction path rather than the protected `resolve()` method because anonymous builds must pass a null name to creators; `resolve()` remains the configured-disk seam. `createScopedDriver()` resolves its prepared descriptor directly rather than through `build()`, so override `createScopedDriver()` for scoped construction customization. Customize on-demand construction through `Storage::extend()` or the public driver creator methods. Creator callbacks may accept the nullable name as a third argument after the application and configuration, while existing two-argument callbacks remain valid. Hypervel carries the name through scoped reconstruction and whole-driver pool fingerprints. A matching explicit fingerprint declares that every construction detail is equivalent, including serving-route ownership.
-`formatS3Config()` leaves named credential providers as configuration data so they can define pool identity. Override `createS3Client()` to customize provider resolution; each client resolves and memoizes its own named provider when it is created.
+`formatS3Config()` leaves named credential providers as configuration data so they can define pool identity. Override `createS3Client()` to customize provider resolution; see the [porting guide](https://hypervel.org/docs/porting-from-laravel#filesystem).
Hypervel registers signed file-serving routes for any configured disk whose `serve` option is exactly `true`, while Laravel limits these routes to local disks and accepts truthy values. Every served disk must use a unique URL or application boot will fail. Custom drivers that opt in must provide the filesystem response methods used by these routes.
From 5b5db16d9d249b326cbbcd51d3b0b8972d89a35b Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:27:06 +0000
Subject: [PATCH 18/20] Leave view-test facade cleanup to the shared subscriber
The framework cleanup subscriber already clears facade state after each test. Remove the duplicate reset from TestViewsTest while retaining its temporary-directory and environment cleanup.
The complete test file and affected parallel suites pass.
---
tests/Testing/Concerns/TestViewsTest.php | 2 --
1 file changed, 2 deletions(-)
diff --git a/tests/Testing/Concerns/TestViewsTest.php b/tests/Testing/Concerns/TestViewsTest.php
index 24d13c41c..fcf79b4e0 100644
--- a/tests/Testing/Concerns/TestViewsTest.php
+++ b/tests/Testing/Concerns/TestViewsTest.php
@@ -63,8 +63,6 @@ protected function tearDown(): void
{
$this->filesystem->deleteDirectory($this->tempDir);
- Facade::setFacadeApplication(null);
-
if ($this->originalParallelTesting === null) {
unset($_SERVER['HYPERVEL_PARALLEL_TESTING']);
} else {
From b4af723caf2a885750ea76285fa493b173b61326 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:55:54 +0000
Subject: [PATCH 19/20] Align Redis limiter test doubles with count-based
replies
The limiter now derives its remaining allowance from the count returned by Lua, but its unit-test doubles still supplied remaining values. Update acquisition, inspection, builder and connection-release fixtures to return reset integers or stored-count strings as the scripts do.
Preserve the admission, allowance, callback and connection-lifetime assertions. Remove the weaker duplicate clamp test because a rejected acquisition now already exercises the same over-limit subtraction and checks both its result and zero allowance.
All changed test files and the Redis unit suite pass. Formatting and diff checks pass; no production code changes are needed.
---
tests/Redis/DurationLimiterBuilderTest.php | 6 ++--
tests/Redis/DurationLimiterTest.php | 33 ++++++--------------
tests/Redis/LimiterConnectionReleaseTest.php | 2 +-
3 files changed, 14 insertions(+), 27 deletions(-)
diff --git a/tests/Redis/DurationLimiterBuilderTest.php b/tests/Redis/DurationLimiterBuilderTest.php
index 98ce49a05..7687ffb10 100644
--- a/tests/Redis/DurationLimiterBuilderTest.php
+++ b/tests/Redis/DurationLimiterBuilderTest.php
@@ -77,7 +77,7 @@ public function testThenExecutesCallbackWhenLockAcquired(): void
{
$redis = $this->mockRedis();
// DurationLimiter::acquire() Lua script returns success
- $this->expectEvaluation($redis, [1, time() + 60, 4]);
+ $this->expectEvaluation($redis, [1, time() + 60, 1]);
$builder = new DurationLimiterBuilder($redis, 'test-key');
$builder->allow(5)->every(60)->block(0);
@@ -93,7 +93,7 @@ public function testThenCallsFailureCallbackOnTimeout(): void
{
$redis = $this->mockRedis();
// DurationLimiter::acquire() always fails
- $this->expectEvaluation($redis, [0, time() + 60, 0]);
+ $this->expectEvaluation($redis, [0, time() + 60, '6']);
$builder = new DurationLimiterBuilder($redis, 'test-key');
$builder->allow(5)->every(60)->block(0)->sleep(1);
@@ -117,7 +117,7 @@ public function testThenThrowsExceptionWithoutFailureCallback(): void
{
$redis = $this->mockRedis();
// DurationLimiter::acquire() always fails
- $this->expectEvaluation($redis, [0, time() + 60, 0]);
+ $this->expectEvaluation($redis, [0, time() + 60, '6']);
$builder = new DurationLimiterBuilder($redis, 'test-key');
$builder->allow(5)->every(60)->block(0)->sleep(1);
diff --git a/tests/Redis/DurationLimiterTest.php b/tests/Redis/DurationLimiterTest.php
index c23b7c075..377ed79ff 100644
--- a/tests/Redis/DurationLimiterTest.php
+++ b/tests/Redis/DurationLimiterTest.php
@@ -21,8 +21,8 @@ class DurationLimiterTest extends TestCase
public function testAcquireSucceedsWhenBelowLimit(): void
{
$redis = $this->mockRedis();
- // Lua script returns: [acquired (1=success), decaysAt, remaining]
- $this->expectEvaluation($redis, [1, time() + 60, 4]);
+ // Lua script returns: [acquired (1=success), decaysAt, count]
+ $this->expectEvaluation($redis, [1, time() + 60, 1]);
$limiter = new DurationLimiter($redis, 'test-key', 5, 60);
@@ -37,7 +37,7 @@ public function testAcquireUsesShaCachedEvalSignature(): void
$redis = $this->mockRedis();
$this->expectEvaluation(
$redis,
- [1, time() + 60, 4],
+ [1, time() + 60, 1],
function (string $script, array $keys, array $arguments): bool {
$this->assertNotSame('', $script);
$this->assertSame(['test-key'], $keys);
@@ -58,8 +58,8 @@ function (string $script, array $keys, array $arguments): bool {
public function testAcquireFailsWhenAtLimit(): void
{
$redis = $this->mockRedis();
- // Lua script returns: [acquired (0=failed), decaysAt, remaining]
- $this->expectEvaluation($redis, [0, time() + 30, 0]);
+ // Lua script returns: [acquired (0=failed), decaysAt, count]
+ $this->expectEvaluation($redis, [0, time() + 30, '6']);
$limiter = new DurationLimiter($redis, 'test-key', 5, 60);
@@ -69,23 +69,10 @@ public function testAcquireFailsWhenAtLimit(): void
$this->assertSame(0, $limiter->remaining);
}
- public function testRemainingIsNeverNegative(): void
- {
- $redis = $this->mockRedis();
- // Even if script returns negative, remaining should be 0
- $this->expectEvaluation($redis, [0, time() + 60, -2]);
-
- $limiter = new DurationLimiter($redis, 'test-key', 5, 60);
-
- $limiter->acquire();
-
- $this->assertSame(0, $limiter->remaining);
- }
-
public function testTooManyAttemptsReturnsTrueWhenNoRemaining(): void
{
$redis = $this->mockRedis();
- $this->expectEvaluation($redis, [time() + 60, 0]);
+ $this->expectEvaluation($redis, [time() + 60, '5']);
$limiter = new DurationLimiter($redis, 'test-key', 5, 60);
@@ -98,7 +85,7 @@ public function testTooManyAttemptsReturnsTrueWhenNoRemaining(): void
public function testTooManyAttemptsReturnsFalseWhenHasRemaining(): void
{
$redis = $this->mockRedis();
- $this->expectEvaluation($redis, [time() + 60, 3]);
+ $this->expectEvaluation($redis, [time() + 60, '2']);
$limiter = new DurationLimiter($redis, 'test-key', 5, 60);
@@ -113,7 +100,7 @@ public function testTooManyAttemptsUsesShaCachedEvalSignature(): void
$redis = $this->mockRedis();
$this->expectEvaluation(
$redis,
- [time() + 60, 2],
+ [time() + 60, '3'],
function (string $script, array $keys, array $arguments): bool {
$this->assertNotSame('', $script);
$this->assertSame(['test-key'], $keys);
@@ -150,7 +137,7 @@ public function testClearDeletesKey(): void
public function testBlockExecutesCallbackOnSuccess(): void
{
$redis = $this->mockRedis();
- $this->expectEvaluation($redis, [1, time() + 60, 4]);
+ $this->expectEvaluation($redis, [1, time() + 60, 1]);
$limiter = new DurationLimiter($redis, 'test-key', 5, 60);
@@ -174,7 +161,7 @@ public function testBlockThrowsExceptionAfterTimeout(): void
// Always fail to acquire
$connection->shouldReceive('evalWithShaCache')
- ->andReturn([0, time() + 60, 0]);
+ ->andReturn([0, time() + 60, '6']);
$limiter = new DurationLimiter($redis, 'test-key', 5, 60);
diff --git a/tests/Redis/LimiterConnectionReleaseTest.php b/tests/Redis/LimiterConnectionReleaseTest.php
index cc7d8a57e..1c614be2c 100644
--- a/tests/Redis/LimiterConnectionReleaseTest.php
+++ b/tests/Redis/LimiterConnectionReleaseTest.php
@@ -41,7 +41,7 @@ public function testWaitingReturnsIdleSessionsButPreservesTransactionsAndPins(bo
$redis->shouldReceive('withConnection')->twice()->andReturn(false, 'waiting0');
$limiter = new ConcurrencyLimiter($redis, 'waiting', 1, 10);
} else {
- $redis->shouldReceive('withConnection')->twice()->andReturn([0, time() + 10, 0], [1, time() + 10, 0]);
+ $redis->shouldReceive('withConnection')->twice()->andReturn([0, time() + 10, '2'], [1, time() + 10, 1]);
$limiter = new DurationLimiter($redis, 'waiting', 1, 10);
}
From 81de24b12edd0d24528b499aedb7425353d11f4f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 16:36:32 +0000
Subject: [PATCH 20/20] Match readable null property hooks in fake assertions
A backed property getter can return null before its backing value is initialized. Fake property assertions rejected that readable value because they required ReflectionProperty::isInitialized even when a getter was present.
Allow public instance properties with a get hook through the readability check. Ordinary uninitialized properties, static properties and inaccessible properties remain excluded. Extend the existing null-property regression with an uninitialized backed getter.
Validated with formatting, full static analysis and the Bus, Event, Notification and Queue fake test suites.
---
src/support/src/Testing/Fakes/MatchesProperties.php | 4 +++-
tests/Support/SupportTestingQueueFakeTest.php | 6 +++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/support/src/Testing/Fakes/MatchesProperties.php b/src/support/src/Testing/Fakes/MatchesProperties.php
index c97fcddb8..ba3d1053d 100644
--- a/src/support/src/Testing/Fakes/MatchesProperties.php
+++ b/src/support/src/Testing/Fakes/MatchesProperties.php
@@ -6,6 +6,7 @@
use Hypervel\Database\Eloquent\Model;
use Hypervel\Support\Collection;
+use PropertyHookType;
use ReflectionProperty;
trait MatchesProperties
@@ -33,7 +34,8 @@ protected function resolveTruthTest(callable|array|null $callback): ?callable
$reflection = new ReflectionProperty($object, $property);
- if (! $reflection->isPublic() || $reflection->isStatic() || ! $reflection->isInitialized($object)) {
+ if (! $reflection->isPublic() || $reflection->isStatic()
+ || (! $reflection->hasHook(PropertyHookType::Get) && ! $reflection->isInitialized($object))) {
return false;
}
}
diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php
index 3710da006..7cd3ea2f1 100644
--- a/tests/Support/SupportTestingQueueFakeTest.php
+++ b/tests/Support/SupportTestingQueueFakeTest.php
@@ -142,12 +142,16 @@ public function testPropertyMapsMatchNullDynamicPropertiesAndPropertyHooks(): vo
public ?string $status {
get => null;
}
+
+ public ?string $backedStatus {
+ get => $this->backedStatus ?? null;
+ }
};
$job->dynamic = null;
$this->fake->push($job);
- $this->fake->assertPushed($job::class, ['dynamic' => null, 'status' => null]);
+ $this->fake->assertPushed($job::class, ['dynamic' => null, 'status' => null, 'backedStatus' => null]);
}
public function testAssertPushedPreservesArrayCallbacks(): void