From 3fd51e4351fae3b508d8b11e3f1b3a8ae17f2975 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:53:45 +0000 Subject: [PATCH 1/6] Complete Laravel's exception and Support array annotations Port the remaining `@throws` annotations from laravel/framework#59016 (https://github.com/laravel/framework/pull/59016) and the Support array value types from laravel/framework#59383 (https://github.com/laravel/framework/pull/59383), from the pinned 13.x source (01d008c9b5). laravel/framework#59008 (https://github.com/laravel/framework/pull/59008) was closed unmerged and superseded by #59016; its only other hunk never landed upstream. Where Hypervel's structure differs, annotations sit where Hypervel actually throws: HealthCheckController rather than the routing callback, RedisProxy::command(), and the model boot lock's RuntimeException. Hunks for classes Hypervel doesn't ship, native-typed guards and Postgres generated-column changes (supported here) don't apply. Some upstream types are corrected rather than copied: - Manager driver keys are array-key, since numeric driver names become integer keys. - Magic __call arguments keep native array, since positional arguments have integer keys (also removed from ExceptionHandlerFake). - Composer::getProcess() env matches Symfony's accepted values. - Fluent::all() keeps precise generics only when no keys are selected. Existing more precise BinaryCodec, MessageBag and Env types are kept. Also: - Prunable::pruneAll() now passes coroutine cancellation through instead of reporting it and continuing to prune, and drops a handler null check that could never fail. - Add titles to the anonymous cast methods in AsCollection and AsEncryptedCollection, and replace isMultipleOf's stale comment. - Regenerate the seven Manager facades for the getDrivers() type. Validation: lint, full PHPStan and the types analysis pass, along with FacadeDocblocksTest and EloquentPrunableTest (SQLite and MySQL). The new cancellation test fails without the fix. Claude-Session: https://claude.ai/code/session_01WveaB7yyFo2T6yo7cnEpW9 --- src/broadcasting/src/BroadcastManager.php | 3 +- .../src/Concerns/ConfiguresPrompts.php | 2 + .../src/Scheduling/ManagesFrequencies.php | 2 + src/console/src/Scheduling/Schedule.php | 2 + src/console/src/View/Components/Task.php | 2 + src/database/src/Concerns/BuildsQueries.php | 3 ++ .../src/Eloquent/Casts/AsCollection.php | 11 ++++ .../Eloquent/Casts/AsEncryptedCollection.php | 11 ++++ .../src/Eloquent/Concerns/HasAttributes.php | 12 +++++ .../Concerns/QueriesRelationships.php | 4 +- .../Concerns/TransformsToResource.php | 2 + src/database/src/Eloquent/MassPrunable.php | 2 + src/database/src/Eloquent/Model.php | 3 ++ .../PendingHasThroughRelationship.php | 2 + src/database/src/Eloquent/Prunable.php | 15 +++--- src/database/src/Query/Builder.php | 6 +++ src/database/src/Query/Grammars/Grammar.php | 4 ++ src/database/src/Schema/Builder.php | 4 ++ src/database/src/Schema/Grammars/Grammar.php | 4 ++ .../src/Schema/Grammars/SQLiteGrammar.php | 2 + src/database/src/Schema/MySqlSchemaState.php | 3 ++ .../src/Http/HealthCheckController.php | 2 + .../src/Middleware/ValidatePathEncoding.php | 2 + src/process/src/PendingProcess.php | 1 + src/queue/src/InteractsWithQueue.php | 2 + .../src/Middleware/ThrottlesExceptions.php | 2 + src/queue/src/Queue.php | 2 + src/redis/src/RedisProxy.php | 2 + .../src/Middleware/SubstituteBindings.php | 2 + src/routing/src/RoutingServiceProvider.php | 4 ++ src/socialite/src/Socialite.php | 2 +- src/support/src/Benchmark.php | 5 ++ src/support/src/Composer.php | 8 +++ src/support/src/ConfigurationUrlParser.php | 19 +++++++ src/support/src/Facades/Hash.php | 2 +- src/support/src/Facades/Image.php | 2 +- src/support/src/Facades/Jwt.php | 2 +- src/support/src/Facades/MaintenanceMode.php | 2 +- src/support/src/Facades/Notification.php | 2 +- src/support/src/Facades/Session.php | 2 +- src/support/src/Fluent.php | 2 + src/support/src/Manager.php | 6 +++ src/support/src/MessageBag.php | 6 +++ src/support/src/Testing/Fakes/BusFake.php | 2 + .../Testing/Fakes/ExceptionHandlerFake.php | 6 ++- .../src/Concerns/ValidatesAttributes.php | 8 ++- .../Database/EloquentPrunableTest.php | 53 +++++++++++++++++-- types/Support/Fluent.php | 14 +++++ 48 files changed, 233 insertions(+), 28 deletions(-) create mode 100644 types/Support/Fluent.php diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 7f51ca4b1b..805c72e25a 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -300,9 +300,10 @@ protected function get(string $name): Broadcaster } /** - * Resolve the given broadcaster with Pool Proxy if need. + * Resolve the given broadcaster with a pool proxy if needed. * * @throws InvalidArgumentException + * @throws RuntimeException */ protected function resolve(string $name): Broadcaster { diff --git a/src/console/src/Concerns/ConfiguresPrompts.php b/src/console/src/Concerns/ConfiguresPrompts.php index d17bb80875..6790f1d544 100644 --- a/src/console/src/Concerns/ConfiguresPrompts.php +++ b/src/console/src/Concerns/ConfiguresPrompts.php @@ -179,6 +179,8 @@ function () use ($prompt): mixed { * @param bool|string $required * @param null|(Closure(PResult): mixed) $validate * @return PResult + * + * @throws PromptValidationException */ protected function promptUntilValid($prompt, $required, $validate) { diff --git a/src/console/src/Scheduling/ManagesFrequencies.php b/src/console/src/Scheduling/ManagesFrequencies.php index ac1df43e14..db2e9535a7 100644 --- a/src/console/src/Scheduling/ManagesFrequencies.php +++ b/src/console/src/Scheduling/ManagesFrequencies.php @@ -124,6 +124,8 @@ public function everyThirtySeconds(): static * Schedule the event to run multiple times per minute. * * @param int<1, 59> $seconds + * + * @throws InvalidArgumentException */ protected function repeatEvery(int $seconds): static { diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index 6259ab79ee..358dce0821 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -493,6 +493,8 @@ public static function flushState(): void /** * Dynamically handle calls into the schedule instance. + * + * @throws BadMethodCallException */ public function __call(string $method, array $parameters): mixed { diff --git a/src/console/src/View/Components/Task.php b/src/console/src/View/Components/Task.php index 76cdcb76d6..2561e73a8c 100644 --- a/src/console/src/View/Components/Task.php +++ b/src/console/src/View/Components/Task.php @@ -19,6 +19,8 @@ class Task extends Component * Render the component using the given arguments. * * @param null|(callable(): mixed) $task + * + * @throws Throwable */ public function render(string $description, ?callable $task = null, int $verbosity = OutputInterface::VERBOSITY_NORMAL): void { diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 82440fa43b..75e0f673f8 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -308,6 +308,9 @@ public function lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?str * Query lazily, by chunking the results of a query by comparing IDs in a given order. * * @return LazyCollection + * + * @throws InvalidArgumentException + * @throws RuntimeException */ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): LazyCollection { diff --git a/src/database/src/Eloquent/Casts/AsCollection.php b/src/database/src/Eloquent/Casts/AsCollection.php index b6c33f9b47..78c9a2cc65 100644 --- a/src/database/src/Eloquent/Casts/AsCollection.php +++ b/src/database/src/Eloquent/Casts/AsCollection.php @@ -18,15 +18,23 @@ class AsCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @return CastsAttributes, iterable> + * + * @throws InvalidArgumentException */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { + /** + * Create a new collection cast instance. + */ public function __construct(protected array $arguments) { $this->arguments = array_pad(array_values($this->arguments), 2, ''); } + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { if (! isset($attributes[$key])) { @@ -60,6 +68,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): : $instance->mapInto($this->arguments[1][0]); } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): array { $encoded = Json::encode($value); diff --git a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php index ed0150518d..bb323581db 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php @@ -19,15 +19,23 @@ class AsEncryptedCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @return CastsAttributes, iterable> + * + * @throws InvalidArgumentException */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { + /** + * Create a new encrypted collection cast instance. + */ public function __construct(protected array $arguments) { $this->arguments = array_pad(array_values($this->arguments), 2, ''); } + /** + * Transform the attribute from the underlying model values. + */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; @@ -61,6 +69,9 @@ public function get(Model $model, string $key, mixed $value, array $attributes): : $instance->mapInto($this->arguments[1][0]); } + /** + * Transform the attribute to its underlying model values. + */ public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! is_null($value)) { diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 7e4a746ad5..aa849bf819 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -595,6 +595,8 @@ public function isRelation(string $key): bool /** * Handle a lazy loading violation. + * + * @throws LazyLoadingViolationException */ protected function handleLazyLoadingViolation(string $key): mixed { @@ -773,6 +775,8 @@ protected function flushCastCaches(): void /** * Ensure that the given casts are strings. + * + * @throws InvalidArgumentException */ protected function ensureCastsAreStringValues(array $casts): array { @@ -1265,6 +1269,8 @@ protected function getEnumCaseFromValue(string $enumClass, string|int $value): m * Get the storable value from the given enum. * * @param UnitEnum $value + * + * @throws ValueError */ protected function getStorableEnumValue(string $expectedEnum, mixed $value): string|int { @@ -1303,6 +1309,8 @@ protected function getArrayAttributeByKey(string $key): array /** * Cast the given attribute to JSON. + * + * @throws JsonEncodingException */ protected function castAttributeAsJson(string $key, mixed $value): string { @@ -1390,6 +1398,8 @@ public static function currentEncrypter(): EncrypterContract /** * Cast the given attribute to a hashed string. + * + * @throws RuntimeException */ protected function castAttributeAsHashedString(string $key, #[SensitiveParameter] mixed $value): ?string { @@ -1429,6 +1439,8 @@ public function fromFloat(mixed $value): float /** * Return a decimal as string. + * + * @throws MathException */ protected function asDecimal(float|string $value, int $decimals): string { diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 6a4efb08a1..8987056050 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -692,6 +692,7 @@ protected function getMorphTypeForQuery(string $model): string * * @param EloquentCollection|Model $related * + * @throws InvalidArgumentException * @throws RelationNotFoundException */ public function whereBelongsTo(mixed $related, ?string $relationshipName = null, string $boolean = 'and'): static @@ -733,8 +734,6 @@ public function whereBelongsTo(mixed $related, ?string $relationshipName = null, /** * Add a "BelongsTo" relationship with an "or where" clause to the query. - * - * @throws RuntimeException */ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = null): static { @@ -746,6 +745,7 @@ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = nul * * @param EloquentCollection|Model $related * + * @throws InvalidArgumentException * @throws RelationNotFoundException */ public function whereAttachedTo(mixed $related, ?string $relationshipName = null, string $boolean = 'and'): static diff --git a/src/database/src/Eloquent/Concerns/TransformsToResource.php b/src/database/src/Eloquent/Concerns/TransformsToResource.php index cdb885285a..b05037f1b6 100644 --- a/src/database/src/Eloquent/Concerns/TransformsToResource.php +++ b/src/database/src/Eloquent/Concerns/TransformsToResource.php @@ -31,6 +31,8 @@ public function toResource(?string $resourceClass = null): JsonResource /** * Guess the resource class for the model. + * + * @throws LogicException */ protected function guessResource(): JsonResource { diff --git a/src/database/src/Eloquent/MassPrunable.php b/src/database/src/Eloquent/MassPrunable.php index 06abe1ddb4..54de357531 100644 --- a/src/database/src/Eloquent/MassPrunable.php +++ b/src/database/src/Eloquent/MassPrunable.php @@ -47,6 +47,8 @@ public function pruneAll(int $chunkSize = 1000): int * Get the prunable model query. * * @return Builder + * + * @throws LogicException */ public function prunable(): Builder { diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index d566333c26..a5b7283918 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -367,6 +367,9 @@ public function __construct(array $attributes = []) /** * Check if the model needs to be booted and if so, do it. + * + * @throws LogicException + * @throws RuntimeException */ protected function bootIfNotBooted(): void { diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php index 3068fe09c2..4bc946fb18 100644 --- a/src/database/src/Eloquent/PendingHasThroughRelationship.php +++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php @@ -104,6 +104,8 @@ public function has(callable|string $callback): mixed /** * Handle dynamic method calls into the model. + * + * @throws BadMethodCallException */ public function __call(string $method, array $parameters): mixed { diff --git a/src/database/src/Eloquent/Prunable.php b/src/database/src/Eloquent/Prunable.php index 0069897899..b9d9faefa3 100644 --- a/src/database/src/Eloquent/Prunable.php +++ b/src/database/src/Eloquent/Prunable.php @@ -8,12 +8,15 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Events\ModelsPruned; use LogicException; +use Swoole\Coroutine\CanceledException; use Throwable; trait Prunable { /** * Prune all prunable models in the database. + * + * @throws Throwable */ public function pruneAll(int $chunkSize = 1000): int { @@ -29,14 +32,10 @@ public function pruneAll(int $chunkSize = 1000): int $model->prune(); ++$total; + } catch (CanceledException $exception) { + throw $exception; } catch (Throwable $e) { - $handler = app(ExceptionHandler::class); - - if ($handler) { - $handler->report($e); - } else { - throw $e; - } + app(ExceptionHandler::class)->report($e); } }); @@ -54,6 +53,8 @@ public function pruneAll(int $chunkSize = 1000): int * Get the prunable model query. * * @return Builder + * + * @throws LogicException */ public function prunable(): Builder { diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index f1828352a4..8d683ca5e8 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -1262,6 +1262,8 @@ public function orWhereNullSafeEquals(ExpressionContract|string $column, mixed $ /** * Add a "where in" clause to the query. + * + * @throws InvalidArgumentException */ public function whereIn(ExpressionContract|string $column, mixed $values, string $boolean = 'and', bool $not = false): static { @@ -3877,6 +3879,8 @@ public function update(array $values): int /** * Update records in a PostgreSQL database using the update from syntax. + * + * @throws LogicException */ public function updateFrom(array $values): int { @@ -4292,6 +4296,8 @@ public function getConnection(): ConnectionInterface /** * Ensure the database connection supports vector queries. + * + * @throws RuntimeException */ protected function ensureConnectionSupportsVectors(): void { diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 074fa9652f..2e7f0002d5 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -314,6 +314,8 @@ protected function whereBitwise(Builder $query, array $where): string /** * Compile a "where like" clause. + * + * @throws RuntimeException */ protected function whereLike(Builder $query, array $where): string { @@ -695,6 +697,8 @@ public function compileJsonValueCast(string $value): string /** * Compile a "where fulltext" clause. + * + * @throws RuntimeException */ public function whereFullText(Builder $query, array $where): string { diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 52286ce4f6..66a102f12f 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -325,6 +325,8 @@ public function whenTableDoesntHaveIndex(string $table, array|string $index, Clo /** * Get the data type for the given column name. + * + * @throws InvalidArgumentException */ public function getColumnType(string $table, string $column, bool $fullDefinition = false): string { @@ -691,6 +693,8 @@ public function ensureVectorExtensionExists(?string $schema = null): void /** * Create a new extension on the schema if it does not exist. + * + * @throws RuntimeException */ public function ensureExtensionExists(string $name, ?string $schema = null): void { diff --git a/src/database/src/Schema/Grammars/Grammar.php b/src/database/src/Schema/Grammars/Grammar.php index 4c866692e2..a65f4caf9d 100755 --- a/src/database/src/Schema/Grammars/Grammar.php +++ b/src/database/src/Schema/Grammars/Grammar.php @@ -62,6 +62,8 @@ public function compileDropDatabaseIfExists(string $name): string /** * Compile the query to determine the schemas. + * + * @throws RuntimeException */ public function compileSchemas(): string { @@ -245,6 +247,8 @@ public function compileForeign(Blueprint $blueprint, Fluent $command): ?string /** * Compile a drop foreign key command. + * + * @throws RuntimeException */ public function compileDropForeign(Blueprint $blueprint, Fluent $command): array|string|null { diff --git a/src/database/src/Schema/Grammars/SQLiteGrammar.php b/src/database/src/Schema/Grammars/SQLiteGrammar.php index 4e1ee9cd1b..8117d14921 100644 --- a/src/database/src/Schema/Grammars/SQLiteGrammar.php +++ b/src/database/src/Schema/Grammars/SQLiteGrammar.php @@ -713,6 +713,8 @@ public function compileDropSpatialIndex(Blueprint $blueprint, Fluent $command): /** * Compile a drop foreign key command. + * + * @throws RuntimeException */ public function compileDropForeign(Blueprint $blueprint, Fluent $command): ?array { diff --git a/src/database/src/Schema/MySqlSchemaState.php b/src/database/src/Schema/MySqlSchemaState.php index c00a02fba6..79a8e9b97c 100644 --- a/src/database/src/Schema/MySqlSchemaState.php +++ b/src/database/src/Schema/MySqlSchemaState.php @@ -12,6 +12,7 @@ use Pdo\Mysql; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; +use Throwable; /** * @property MySqlConnection $connection @@ -157,6 +158,8 @@ protected function baseVariables(array $config): array * Execute the given dump process. * * @param array $variables + * + * @throws Throwable */ protected function executeDumpProcess(Process $process, ?callable $output, array $variables, int $depth = 0): Process { diff --git a/src/foundation/src/Http/HealthCheckController.php b/src/foundation/src/Http/HealthCheckController.php index 8f35a4820a..c1d1e4f966 100644 --- a/src/foundation/src/Http/HealthCheckController.php +++ b/src/foundation/src/Http/HealthCheckController.php @@ -34,6 +34,8 @@ public function __construct( /** * Run the application health check. + * + * @throws Throwable */ public function __invoke(Request $request): Response { diff --git a/src/http/src/Middleware/ValidatePathEncoding.php b/src/http/src/Middleware/ValidatePathEncoding.php index 367644291c..6fb2690ef9 100644 --- a/src/http/src/Middleware/ValidatePathEncoding.php +++ b/src/http/src/Middleware/ValidatePathEncoding.php @@ -13,6 +13,8 @@ class ValidatePathEncoding { /** * Validate that the incoming request has a valid UTF-8 encoded path. + * + * @throws MalformedUrlException */ public function handle(Request $request, Closure $next): Response { diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index ee07447868..57bafb65bf 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -326,6 +326,7 @@ protected function fakeFor(string $command): ?callable * Resolve the given fake handler for a synchronous process. * * @throws LogicException + * @throws Throwable */ protected function resolveSynchronousFake(string $command, Closure $fake): ProcessResultContract { diff --git a/src/queue/src/InteractsWithQueue.php b/src/queue/src/InteractsWithQueue.php index f6f6063451..61ad79334a 100644 --- a/src/queue/src/InteractsWithQueue.php +++ b/src/queue/src/InteractsWithQueue.php @@ -222,6 +222,8 @@ public function assertNotReleased(): static /** * Ensure that queue interactions have been faked. + * + * @throws RuntimeException */ private function ensureQueueInteractionsHaveBeenFaked(): void { diff --git a/src/queue/src/Middleware/ThrottlesExceptions.php b/src/queue/src/Middleware/ThrottlesExceptions.php index 580a4ece82..20ec40086b 100644 --- a/src/queue/src/Middleware/ThrottlesExceptions.php +++ b/src/queue/src/Middleware/ThrottlesExceptions.php @@ -83,6 +83,8 @@ public function __construct( /** * Process the job. + * + * @throws Throwable */ public function handle(mixed $job, callable $next): mixed { diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 87fd51e6de..8455291ca8 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -178,6 +178,8 @@ protected function createPayloadArray(array|object|string $job, ?string $queue, /** * Create a payload for an object-based queue handler. + * + * @throws RuntimeException */ protected function createObjectPayload(object $job, ?string $queue): array { diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index ea14eb0cfa..555c53c751 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -802,6 +802,8 @@ public function psubscribe(array|string $channels, Closure $callback): void /** * Run a command against the Redis database. + * + * @throws Throwable */ public function command(string $method, array $parameters = []): mixed { diff --git a/src/routing/src/Middleware/SubstituteBindings.php b/src/routing/src/Middleware/SubstituteBindings.php index 7c813d3f62..cee08ee1ad 100644 --- a/src/routing/src/Middleware/SubstituteBindings.php +++ b/src/routing/src/Middleware/SubstituteBindings.php @@ -27,6 +27,8 @@ public function __construct(Registrar $router) /** * Handle an incoming request. + * + * @throws ModelNotFoundException */ public function handle(Request $request, Closure $next): Response { diff --git a/src/routing/src/RoutingServiceProvider.php b/src/routing/src/RoutingServiceProvider.php index a3a0a074b3..5b98b8562d 100644 --- a/src/routing/src/RoutingServiceProvider.php +++ b/src/routing/src/RoutingServiceProvider.php @@ -122,6 +122,8 @@ protected function registerRedirector(): void /** * Register a binding for the PSR-7 request implementation. + * + * @throws BindingResolutionException */ protected function registerPsrRequest(): void { @@ -145,6 +147,8 @@ protected function registerPsrRequest(): void /** * Register a binding for the PSR-7 response implementation. + * + * @throws BindingResolutionException */ protected function registerPsrResponse(): void { diff --git a/src/socialite/src/Socialite.php b/src/socialite/src/Socialite.php index 03763c9fd4..98e52c0fac 100644 --- a/src/socialite/src/Socialite.php +++ b/src/socialite/src/Socialite.php @@ -17,7 +17,7 @@ * @method static \Hypervel\Socialite\SocialiteManager forgetDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static string getDefaultDriver() - * @method static array getDrivers() + * @method static array getDrivers() * @method static \Hypervel\Socialite\SocialiteManager setContainer(\Hypervel\Contracts\Container\Container $container) * @method static \Hypervel\Socialite\Contracts\Provider with(string $driver) * diff --git a/src/support/src/Benchmark.php b/src/support/src/Benchmark.php index 05a10a16fa..46a29c1abd 100644 --- a/src/support/src/Benchmark.php +++ b/src/support/src/Benchmark.php @@ -13,6 +13,9 @@ class Benchmark /** * Measure a callable or array of callables over the given number of iterations. + * + * @param array|Closure $benchmarkables + * @return array|float */ public static function measure(Closure|array $benchmarkables, int $iterations = 1): array|float { @@ -54,6 +57,8 @@ public static function value(callable $callback): array /** * Measure a callable or array of callables over the given number of iterations, then dump and die. + * + * @param array|Closure $benchmarkables */ public static function dd(Closure|array $benchmarkables, int $iterations = 1): never { diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index 152d3d1a48..a4c3bf16ef 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -9,6 +9,7 @@ use Hypervel\Filesystem\Filesystem; use JsonException; use RuntimeException; +use Stringable; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; @@ -135,6 +136,8 @@ public function modify(callable $callback): void /** * Regenerate the Composer autoloader files. + * + * @param array|string $extra */ public function dumpAutoloads(string|array $extra = '', ?string $composerBinary = null): int { @@ -155,6 +158,8 @@ public function dumpOptimized(?string $composerBinary = null): int /** * Get the Composer binary / command for the environment. + * + * @return array */ public function findComposer(?string $composerBinary = null): array { @@ -210,6 +215,9 @@ protected function phpBinary(): string /** * Get a new Symfony process instance. + * + * @param array $command + * @param array $env */ protected function getProcess(array $command, array $env = []): Process { diff --git a/src/support/src/ConfigurationUrlParser.php b/src/support/src/ConfigurationUrlParser.php index b02cee62ac..5ec5cd35a7 100644 --- a/src/support/src/ConfigurationUrlParser.php +++ b/src/support/src/ConfigurationUrlParser.php @@ -23,11 +23,16 @@ class ConfigurationUrlParser /** * The drivers aliases map. + * + * @var array */ protected static array $driverAliases = self::DEFAULT_DRIVER_ALIASES; /** * Parse the database configuration, hydrating options using a database configuration URL if possible. + * + * @param array|string $config + * @return array */ public function parseConfiguration(array|string $config): array { @@ -62,6 +67,9 @@ public function parseConfiguration(array|string $config): array /** * Get the primary database connection options. + * + * @param array $url + * @return array */ protected function getPrimaryOptions(array $url): array { @@ -77,6 +85,8 @@ protected function getPrimaryOptions(array $url): array /** * Get the database driver from the URL. + * + * @param array $url */ protected function getDriver(array $url): ?string { @@ -91,6 +101,8 @@ protected function getDriver(array $url): ?string /** * Get the database name from the URL. + * + * @param array $url */ protected function getDatabase(array $url): ?string { @@ -101,6 +113,9 @@ protected function getDatabase(array $url): ?string /** * Get all of the additional database options from the query string. + * + * @param array $url + * @return array */ protected function getQueryOptions(array $url): array { @@ -120,6 +135,8 @@ protected function getQueryOptions(array $url): array /** * Parse the string URL to an array of components. * + * @return array + * * @throws InvalidArgumentException */ protected function parseUrl(string $url): array @@ -159,6 +176,8 @@ protected function parseStringsToNativeTypes(mixed $value): mixed /** * Get all of the current drivers' aliases. + * + * @return array */ public static function getDriverAliases(): array { diff --git a/src/support/src/Facades/Hash.php b/src/support/src/Facades/Hash.php index dc30ce1132..4cdde9a51e 100644 --- a/src/support/src/Facades/Hash.php +++ b/src/support/src/Facades/Hash.php @@ -17,7 +17,7 @@ * @method static \Hypervel\Hashing\HashManager forgetDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static string getDefaultDriver() - * @method static array getDrivers() + * @method static array getDrivers() * @method static array info(string $hashedValue) * @method static bool isHashed(string $value) * @method static string make(string $value, array $options = []) diff --git a/src/support/src/Facades/Image.php b/src/support/src/Facades/Image.php index 48a7053f0f..506851600e 100644 --- a/src/support/src/Facades/Image.php +++ b/src/support/src/Facades/Image.php @@ -17,7 +17,7 @@ * @method static \Hypervel\Image\Image fromUrl(string $url) * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static string getDefaultDriver() - * @method static array getDrivers() + * @method static array getDrivers() * @method static \Hypervel\Image\ImageManager setContainer(\Hypervel\Contracts\Container\Container $container) * @method static \Hypervel\Image\ImageManager transformUsing(string $driver, string $transformation, callable $callback) * diff --git a/src/support/src/Facades/Jwt.php b/src/support/src/Facades/Jwt.php index 21bc6d82a7..32c662990f 100644 --- a/src/support/src/Facades/Jwt.php +++ b/src/support/src/Facades/Jwt.php @@ -13,7 +13,7 @@ * @method static \Hypervel\Jwt\JwtManager forgetDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static string getDefaultDriver() - * @method static array getDrivers() + * @method static array getDrivers() * @method static bool hasBlacklistEnabled() * @method static bool invalidate(string $token, bool $forceForever = false) * @method static string refresh(string $token, bool $forceForever = false, bool $resetClaims = false, array $customClaims = [], int|false|null $ttl = false) diff --git a/src/support/src/Facades/MaintenanceMode.php b/src/support/src/Facades/MaintenanceMode.php index 0aaea879d6..b488c18ec8 100644 --- a/src/support/src/Facades/MaintenanceMode.php +++ b/src/support/src/Facades/MaintenanceMode.php @@ -12,7 +12,7 @@ * @method static \Hypervel\Foundation\MaintenanceModeManager forgetDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static string getDefaultDriver() - * @method static array getDrivers() + * @method static array getDrivers() * @method static \Hypervel\Foundation\MaintenanceModeManager setContainer(\Hypervel\Contracts\Container\Container $container) * * @see \Hypervel\Foundation\MaintenanceModeManager diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index d1b071a330..284ae5b2e4 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -19,7 +19,7 @@ * @method static \Hypervel\Notifications\ChannelManager forgetDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static string getDefaultDriver() - * @method static array getDrivers() + * @method static array getDrivers() * @method static string|null getLocale() * @method static bool hasMacro(string $name) * @method static \Hypervel\Notifications\ChannelManager locale(string $locale) diff --git a/src/support/src/Facades/Session.php b/src/support/src/Facades/Session.php index 3362d3d345..70f5ba80d5 100644 --- a/src/support/src/Facades/Session.php +++ b/src/support/src/Facades/Session.php @@ -14,7 +14,7 @@ * @method static \Hypervel\Session\UserSessions forUser(\Hypervel\Contracts\Auth\Authenticatable|string|int $user, \UnitEnum|string|null $guard = null) * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static string getDefaultDriver() - * @method static array getDrivers() + * @method static array getDrivers() * @method static array getSessionConfig() * @method static \Hypervel\Session\SessionManager setContainer(\Hypervel\Contracts\Container\Container $container) * @method static void setDefaultDriver(\UnitEnum|string $name) diff --git a/src/support/src/Fluent.php b/src/support/src/Fluent.php index 16e1c94678..f0dc2e33d3 100644 --- a/src/support/src/Fluent.php +++ b/src/support/src/Fluent.php @@ -119,6 +119,8 @@ public function scope(string $key, mixed $default = null): static /** * Get all of the attributes from the fluent instance. + * + * @return ($keys is null ? array : array) */ public function all(mixed $keys = null): array { diff --git a/src/support/src/Manager.php b/src/support/src/Manager.php index c373a006b5..db3a5cc777 100644 --- a/src/support/src/Manager.php +++ b/src/support/src/Manager.php @@ -23,11 +23,15 @@ abstract class Manager /** * The registered custom driver creators. + * + * @var array */ protected array $customCreators = []; /** * The array of created "drivers". + * + * @var array */ protected array $drivers = []; @@ -135,6 +139,8 @@ public function extend(string $driver, Closure $callback): static /** * Get all of the created "drivers". + * + * @return array */ public function getDrivers(): array { diff --git a/src/support/src/MessageBag.php b/src/support/src/MessageBag.php index dffbbfab27..6949083dac 100755 --- a/src/support/src/MessageBag.php +++ b/src/support/src/MessageBag.php @@ -103,6 +103,8 @@ public function merge(MessageProvider|array $messages): static /** * Determine if messages exist for all of the given keys. + * + * @param null|array|string $key */ public function has(array|string|null $key = null): bool { @@ -128,6 +130,8 @@ public function has(array|string|null $key = null): bool /** * Determine if messages exist for any of the given keys. + * + * @param null|array|string $keys */ public function hasAny(array|string|null $keys = []): bool { @@ -149,6 +153,8 @@ public function hasAny(array|string|null $keys = []): bool /** * Determine if messages don't exist for all of the given keys. + * + * @param null|array|string $key */ public function missing(array|string|null $key = null): bool { diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php index 4092ddc8a8..cadcd75bcb 100644 --- a/src/support/src/Testing/Fakes/BusFake.php +++ b/src/support/src/Testing/Fakes/BusFake.php @@ -369,6 +369,8 @@ public function assertDispatchedWithoutChain(Closure|string $command, ?callable /** * Assert if a job was dispatched with chained jobs based on a truth-test callback. + * + * @throws RuntimeException */ protected function assertDispatchedWithChainOfObjects(string $command, array $expectedChain, ?callable $callback): void { diff --git a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php index 54e8065690..c5598f0dc7 100644 --- a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php +++ b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php @@ -105,6 +105,8 @@ public function assertReportedCount(int $count): void * Assert if an exception of the given type has not been reported. * * @param class-string|(Closure(Throwable): bool) $exception + * + * @throws ExpectationFailedException */ public function assertNotReported(Closure|string $exception): void { @@ -136,6 +138,8 @@ public function assertNothingReported(): void /** * Report or log an exception. + * + * @throws Throwable */ public function report(Throwable $e): void { @@ -250,8 +254,6 @@ public function setHandler(ExceptionHandler $handler): static /** * Handle dynamic method calls to the handler. - * - * @param array $parameters */ public function __call(string $method, array $parameters): mixed { diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index c30d6e8fa4..38ce9ce5e4 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -1857,6 +1857,8 @@ public function validateMissingWithAll(string $attribute, mixed $value, mixed $p * Validate the value of an attribute is a multiple of a given value. * * @param array $parameters + * + * @throws MathException */ public function validateMultipleOf(string $attribute, mixed $value, mixed $parameters): bool { @@ -1868,11 +1870,7 @@ public function validateMultipleOf(string $attribute, mixed $value, mixed $param /** * Determine if a value is an exact multiple of a divisor. * - * Uses BigDecimal remainder for arbitrary-precision comparison. Both - * value and divisor must be numeric; the exception translation wrapping - * stays in the caller (validateMultipleOf) for non-numeric cases, but - * the BigDecimal exception is thrown from here since that's where the - * math operation lives. + * @throws MathException */ protected function isMultipleOf(mixed $value, mixed $divisor): bool { diff --git a/tests/Integration/Database/EloquentPrunableTest.php b/tests/Integration/Database/EloquentPrunableTest.php index 5b02a604f5..1b5d4045f1 100644 --- a/tests/Integration/Database/EloquentPrunableTest.php +++ b/tests/Integration/Database/EloquentPrunableTest.php @@ -6,6 +6,7 @@ use Exception; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Prunable; use Hypervel\Database\Eloquent\SoftDeletes; @@ -15,6 +16,7 @@ use Hypervel\Support\Facades\Exceptions; use Hypervel\Support\Facades\Schema; use LogicException; +use Swoole\Coroutine\CanceledException; /** * The fixtures are intentionally smaller than Laravel's because this suite @@ -31,6 +33,7 @@ protected function afterRefreshingDatabase(): void 'prunable_test_model_missing_prunable_methods', 'prunable_with_custom_prune_method_test_models', 'prunable_with_exceptions', + 'prunable_with_cancellations', ])->each(function ($table) { Schema::create($table, function (Blueprint $table) { $table->increments('id'); @@ -49,7 +52,7 @@ public function testPrunableMethodMustBeImplemented(): void PrunableTestModelMissingPrunableMethod::create()->pruneAll(); } - public function testPrunesRecords() + public function testPrunesRecords(): void { Event::fake(); @@ -84,7 +87,7 @@ static function (ModelsPruned $event) use (&$observedEvents): void { $this->assertSame([], $observedEvents); } - public function testPrunesSoftDeletedRecords() + public function testPrunesSoftDeletedRecords(): void { Event::fake(); @@ -103,7 +106,7 @@ public function testPrunesSoftDeletedRecords() Event::assertDispatched(ModelsPruned::class, 3); } - public function testPruneWithCustomPruneMethod() + public function testPruneWithCustomPruneMethod(): void { Event::fake(); @@ -124,7 +127,7 @@ public function testPruneWithCustomPruneMethod() Event::assertDispatched(ModelsPruned::class, 1); } - public function testPruneWithExceptionAtOneOfModels() + public function testPruneWithExceptionAtOneOfModels(): void { Event::fake(); Exceptions::fake(); @@ -144,6 +147,23 @@ public function testPruneWithExceptionAtOneOfModels() Exceptions::assertReportedCount(1); Exceptions::assertReported(fn (Exception $exception) => $exception->getMessage() === 'foo bar'); } + + public function testPruneRethrowsCancellationWithoutReportingIt(): void + { + Exceptions::fake(); + + PrunableWithCancellation::insert(array_fill(0, 10, ['name' => 'foo'])); + + try { + (new PrunableWithCancellation)->pruneAll(); + + $this->fail('The cancellation was not rethrown.'); + } catch (CanceledException $exception) { + $this->assertSame('canceled', $exception->getMessage()); + } + + Exceptions::assertNothingReported(); + } } class PrunableTestModel extends Model @@ -201,6 +221,31 @@ public function prune() } } +class PrunableWithCancellation extends Model +{ + use Prunable; + + /** + * Get the prunable model query. + */ + public function prunable(): Builder + { + return $this->where('id', '<=', 10); + } + + /** + * Prune the model in the database. + */ + public function prune(): int|bool|null + { + if ($this->id === 5) { + throw new CanceledException('canceled'); + } + + return true; + } +} + class PrunableTestModelMissingPrunableMethod extends Model { use Prunable; diff --git a/types/Support/Fluent.php b/types/Support/Fluent.php new file mode 100644 index 0000000000..e7ecfb3be6 --- /dev/null +++ b/types/Support/Fluent.php @@ -0,0 +1,14 @@ + $fluent */ +$fluent = new Fluent(['count' => 1]); + +assertType('array', $fluent->all()); +assertType('array', $fluent->all('count')); +assertType('array', $fluent->all(['count', 'total'])); From f99c107f913056bebc966f39cee49a86ac487532 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:45:21 +0000 Subject: [PATCH 2/6] Add QUERY routing and adopt Stringable::explode() off hot paths Port laravel/framework#60655 (https://github.com/laravel/framework/pull/60655), which adds first-class routing for the HTTP QUERY method. It is only on Laravel master, which is now the porting target (framework master cd6e81dff3): - Router::query() and QUERY in Router::$verbs, so Route::any() matches it. - query() on the Registrar contract, RouteRegistrar and the Route facade. - route:list gives QUERY a color and derives "ANY" from Router::$verbs. - PreventRequestForgery treats QUERY as a reading request, since the method is safe (RFC 10008). - The upstream router, registrar, CSRF and cached-route tests and fixture. - Routing docs for Route::query(), with a warning that Swoole 6.2.2 rejects QUERY over HTTP/1.1, so the connection to Hypervel must use HTTP/2. The HTTP testing docs note that simulated requests aren't affected. FileResponseBuilder now uses Request::isMethodSafe() for Accept-Ranges, matching Symfony's BinaryFileResponse, so QUERY responses advertise byte ranges. Port laravel/framework#60979 (https://github.com/laravel/framework/pull/60979) in the console commands, the monitor commands and the schema grammars' escapeNames(). JSON path wrapping, table wrapping, signed-URL checks and SetCacheHeaders keep the direct explode(): Stringable adds ~100ns (8-12%) per call on these request and query paths, and a comment keeps future ports from reintroducing it. laravel/framework#61005 (https://github.com/laravel/framework/pull/61005) and laravel/framework#60662 (https://github.com/laravel/framework/pull/60662) were already covered; no changes. Validation: lint, full PHPStan and FacadeDocblocksTest pass. The routing, registrar, CSRF, route cache, route list and file response tests pass, and the related command, grammar and schema tests pass on SQLite, MySQL, PostgreSQL and MariaDB. Claude-Session: https://claude.ai/code/session_01WveaB7yyFo2T6yo7cnEpW9 --- src/contracts/src/Routing/Registrar.php | 5 ++++ .../src/Concerns/CompilesJsonPaths.php | 1 + src/database/src/Console/MonitorCommand.php | 3 ++- src/database/src/Grammar.php | 1 + .../src/Schema/Grammars/MySqlGrammar.php | 3 ++- .../src/Schema/Grammars/PostgresGrammar.php | 4 ++-- src/docs/http-tests.md | 2 ++ src/docs/routing.md | 8 ++++++- src/filesystem/src/FileResponseBuilder.php | 4 +--- src/foundation/src/Console/AboutCommand.php | 2 +- .../src/Console/MailMakeCommand.php | 4 ++-- .../src/Console/NotificationMakeCommand.php | 4 ++-- .../src/Console/OptimizeClearCommand.php | 3 ++- .../src/Console/OptimizeCommand.php | 3 ++- src/foundation/src/Console/ReloadCommand.php | 3 ++- .../src/Console/RouteListCommand.php | 5 ++-- .../Http/Middleware/PreventRequestForgery.php | 2 +- src/http/src/Middleware/SetCacheHeaders.php | 1 + src/queue/src/Console/MonitorCommand.php | 3 ++- src/routing/src/RouteRegistrar.php | 3 ++- src/routing/src/Router.php | 10 +++++++- src/routing/src/UrlGenerator.php | 1 + src/support/src/Facades/Route.php | 1 + tests/Filesystem/FileResponseBuilderTest.php | 23 +++++++++++++++++++ .../Middleware/PreventRequestForgeryTest.php | 14 +++++++++-- .../Routing/Fixtures/query_routes.php | 14 +++++++++++ .../Integration/Routing/RouteCachingTest.php | 11 +++++++++ tests/Routing/RouteRegistrarTest.php | 11 +++++++++ tests/Routing/RoutingRouteTest.php | 5 ++++ 29 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 tests/Integration/Routing/Fixtures/query_routes.php diff --git a/src/contracts/src/Routing/Registrar.php b/src/contracts/src/Routing/Registrar.php index c6b4a41933..5fd9250043 100644 --- a/src/contracts/src/Routing/Registrar.php +++ b/src/contracts/src/Routing/Registrar.php @@ -40,6 +40,11 @@ public function patch(string $uri, array|string|callable $action): Route; */ public function options(string $uri, array|string|callable $action): Route; + /** + * Register a new QUERY route with the router. + */ + public function query(string $uri, array|string|callable $action): Route; + /** * Register a new route with the given verbs. */ diff --git a/src/database/src/Concerns/CompilesJsonPaths.php b/src/database/src/Concerns/CompilesJsonPaths.php index 57b39d6af6..a594f15920 100644 --- a/src/database/src/Concerns/CompilesJsonPaths.php +++ b/src/database/src/Concerns/CompilesJsonPaths.php @@ -30,6 +30,7 @@ protected function wrapJsonPath(string $value, string $delimiter = '->'): string { $value = preg_replace("/([\\\\]+)?\\'/", "'", $value); + // Keep this explode() to avoid an extra Stringable allocation per call. $jsonPath = (new Collection(explode($delimiter, $value))) ->map(fn ($segment) => $this->wrapJsonPathSegment($segment)) ->join('.'); diff --git a/src/database/src/Console/MonitorCommand.php b/src/database/src/Console/MonitorCommand.php index 5d01159912..3d1ee36dcf 100644 --- a/src/database/src/Console/MonitorCommand.php +++ b/src/database/src/Console/MonitorCommand.php @@ -8,6 +8,7 @@ use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Events\DatabaseBusy; use Hypervel\Support\Collection; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'db:monitor')] @@ -65,7 +66,7 @@ public function handle(): void */ protected function parseDatabases(?string $databases): Collection { - return (new Collection(explode(',', $databases ?? '')))->map(function ($database) { + return (new Stringable($databases))->explode(',')->map(function ($database) { if ($database === '') { $database = $this->hypervel->make('config')->string('database.default'); } diff --git a/src/database/src/Grammar.php b/src/database/src/Grammar.php index 77cc86445b..96ce294be6 100755 --- a/src/database/src/Grammar.php +++ b/src/database/src/Grammar.php @@ -61,6 +61,7 @@ public function wrapTable(Expression|string $table, ?string $prefix = null): str if (str_contains($table, '.')) { $table = substr_replace($table, '.' . $prefix, strrpos($table, '.'), 1); + // Keep this explode() to avoid an extra Stringable allocation per call. return (new Collection(explode('.', $table))) ->map($this->wrapValue(...)) ->implode('.'); diff --git a/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php index d1f27862f1..d0a1df761e 100755 --- a/src/database/src/Schema/Grammars/MySqlGrammar.php +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -10,6 +10,7 @@ use Hypervel\Database\Schema\ColumnDefinition; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; +use Hypervel\Support\Stringable; use Override; /** @@ -612,7 +613,7 @@ public function compileTableComment(Blueprint $blueprint, Fluent $command): stri public function escapeNames(array $names): array { return array_map( - fn ($name) => (new Collection(explode('.', $name)))->map($this->wrapValue(...))->implode('.'), + fn ($name) => (new Stringable($name))->explode('.')->map($this->wrapValue(...))->implode('.'), $names ); } diff --git a/src/database/src/Schema/Grammars/PostgresGrammar.php b/src/database/src/Schema/Grammars/PostgresGrammar.php index db2676b47c..bcab3bb32e 100755 --- a/src/database/src/Schema/Grammars/PostgresGrammar.php +++ b/src/database/src/Schema/Grammars/PostgresGrammar.php @@ -6,8 +6,8 @@ use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; -use Hypervel\Support\Collection; use Hypervel\Support\Fluent; +use Hypervel\Support\Stringable; use Override; class PostgresGrammar extends Grammar @@ -639,7 +639,7 @@ public function compileTableComment(Blueprint $blueprint, Fluent $command): stri public function escapeNames(array $names): array { return array_map( - fn ($name) => (new Collection(explode('.', $name)))->map($this->wrapValue(...))->implode('.'), + fn ($name) => (new Stringable($name))->explode('.')->map($this->wrapValue(...))->implode('.'), $names ); } diff --git a/src/docs/http-tests.md b/src/docs/http-tests.md index 3c8dd22d73..0209ab21a9 100644 --- a/src/docs/http-tests.md +++ b/src/docs/http-tests.md @@ -70,6 +70,8 @@ $response = $this->query('/search', ['filter' => 'active']); $response = $this->queryJson('/search', ['filter' => 'active']); ``` +Because these requests are simulated, they aren't affected by the [HTTP/1.1 limitation on `QUERY` routes](/docs/{{version}}/routing#available-router-methods) that applies in production. + Instead of returning an `Hypervel\Http\Response` instance, test request methods return an instance of `Hypervel\Testing\TestResponse`, which provides a [variety of helpful assertions](#available-assertions) that allow you to inspect your application's responses: ```php tab=Pest diff --git a/src/docs/routing.md b/src/docs/routing.md index 50c6e2f98b..86130c8b5a 100644 --- a/src/docs/routing.md +++ b/src/docs/routing.md @@ -108,8 +108,14 @@ Route::put($uri, $callback); Route::patch($uri, $callback); Route::delete($uri, $callback); Route::options($uri, $callback); +Route::query($uri, $callback); ``` +The `query` method registers a route for HTTP `QUERY` requests. Like `GET`, a `QUERY` request only reads data, but it sends its query in the request body, which suits searches that are too large or complex to fit in a URL. + +> [!WARNING] +> Swoole 6.2.2 rejects HTTP `QUERY` requests sent over HTTP/1.1 with a `400` response before they reach your application. Sending `QUERY` requests requires HTTP/2 on the connection to Hypervel, including the connection from any reverse proxy. + Sometimes you may need to register a route that responds to multiple HTTP verbs. You may do so using the `match` method. Or, you may even register a route that responds to all HTTP verbs using the `any` method: ```php @@ -123,7 +129,7 @@ Route::any('/', function () { ``` > [!NOTE] -> When defining multiple routes that share the same URI, routes using the `get`, `post`, `put`, `patch`, `delete`, and `options` methods should be defined before routes using the `any`, `match`, and `redirect` methods. This ensures the incoming request is matched with the correct route. +> When defining multiple routes that share the same URI, routes using the `get`, `post`, `put`, `patch`, `delete`, `options`, and `query` methods should be defined before routes using the `any`, `match`, and `redirect` methods. This ensures the incoming request is matched with the correct route. #### Dependency Injection diff --git a/src/filesystem/src/FileResponseBuilder.php b/src/filesystem/src/FileResponseBuilder.php index 41c439e35a..b16672aa3e 100644 --- a/src/filesystem/src/FileResponseBuilder.php +++ b/src/filesystem/src/FileResponseBuilder.php @@ -49,9 +49,7 @@ public function build( ); } - $headers['Accept-Ranges'] = in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true) - ? 'bytes' - : 'none'; + $headers['Accept-Ranges'] = $request->isMethodSafe() ? 'bytes' : 'none'; $response = new IterableStreamedResponse([]); diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index a728c548f8..29448d207f 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -292,7 +292,7 @@ protected static function addToSection(string $section, callable|string|array $d */ protected function sections(): array { - return (new Collection(explode(',', $this->option('only') ?? ''))) + return (new Stringable($this->option('only') ?? ''))->explode(',') ->filter() ->map(fn ($only) => $this->toSearchKeyword($only)) ->all(); diff --git a/src/foundation/src/Console/MailMakeCommand.php b/src/foundation/src/Console/MailMakeCommand.php index b72e08210d..76ffa1b378 100644 --- a/src/foundation/src/Console/MailMakeCommand.php +++ b/src/foundation/src/Console/MailMakeCommand.php @@ -7,8 +7,8 @@ use Hypervel\Console\Concerns\CreatesMatchingTest; use Hypervel\Console\GeneratorCommand; use Hypervel\Foundation\Inspiring; -use Hypervel\Support\Collection; use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Override; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -163,7 +163,7 @@ protected function getView(): string if (! $view) { $name = str_replace('\\', '/', $this->argument('name')); - $view = 'mail.' . (new Collection(explode('/', $name))) + $view = 'mail.' . (new Stringable($name))->explode('/') ->map(fn ($part) => Str::kebab($part)) ->implode('.'); } diff --git a/src/foundation/src/Console/NotificationMakeCommand.php b/src/foundation/src/Console/NotificationMakeCommand.php index 9c2e573c57..c1ff5fea40 100644 --- a/src/foundation/src/Console/NotificationMakeCommand.php +++ b/src/foundation/src/Console/NotificationMakeCommand.php @@ -6,8 +6,8 @@ use Hypervel\Console\Concerns\CreatesMatchingTest; use Hypervel\Console\GeneratorCommand; -use Hypervel\Support\Collection; use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -130,7 +130,7 @@ protected function afterPromptingForMissingArguments(InputInterface $input, Outp $wantsMarkdownView = confirm('Would you like to create a markdown view?'); if ($wantsMarkdownView) { - $defaultMarkdownView = (new Collection(explode('/', str_replace('\\', '/', $this->argument('name'))))) + $defaultMarkdownView = (new Stringable($this->argument('name')))->replace('\\', '/')->explode('/') ->map(fn ($path) => Str::kebab($path)) ->prepend('mail') ->implode('.'); diff --git a/src/foundation/src/Console/OptimizeClearCommand.php b/src/foundation/src/Console/OptimizeClearCommand.php index bf40e1d230..5c8949f0b4 100644 --- a/src/foundation/src/Console/OptimizeClearCommand.php +++ b/src/foundation/src/Console/OptimizeClearCommand.php @@ -7,6 +7,7 @@ use Hypervel\Console\Command; use Hypervel\Support\Collection; use Hypervel\Support\ServiceProvider; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'optimize:clear')] @@ -29,7 +30,7 @@ public function handle(): void { $this->components->info('Clearing cached bootstrap files.'); - $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) + $exceptions = (new Stringable($this->option('except') ?? ''))->explode(',') ->map(fn ($except) => trim($except)) ->filter() ->unique() diff --git a/src/foundation/src/Console/OptimizeCommand.php b/src/foundation/src/Console/OptimizeCommand.php index 06e22e4d74..cca0c6ec25 100644 --- a/src/foundation/src/Console/OptimizeCommand.php +++ b/src/foundation/src/Console/OptimizeCommand.php @@ -7,6 +7,7 @@ use Hypervel\Console\Command; use Hypervel\Support\Collection; use Hypervel\Support\ServiceProvider; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'optimize')] @@ -29,7 +30,7 @@ public function handle(): void { $this->components->info('Caching framework bootstrap, configuration, and metadata.'); - $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) + $exceptions = (new Stringable($this->option('except') ?? ''))->explode(',') ->map(fn ($except) => trim($except)) ->filter() ->unique() diff --git a/src/foundation/src/Console/ReloadCommand.php b/src/foundation/src/Console/ReloadCommand.php index a0f28859b3..c2b5a31247 100644 --- a/src/foundation/src/Console/ReloadCommand.php +++ b/src/foundation/src/Console/ReloadCommand.php @@ -7,6 +7,7 @@ use Hypervel\Console\Command; use Hypervel\Support\Collection; use Hypervel\Support\ServiceProvider; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'reload')] @@ -29,7 +30,7 @@ public function handle(): void { $this->components->info('Reloading services.'); - $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) + $exceptions = (new Stringable($this->option('except') ?? ''))->explode(',') ->map(fn ($except) => trim($except)) ->filter() ->unique() diff --git a/src/foundation/src/Console/RouteListCommand.php b/src/foundation/src/Console/RouteListCommand.php index bf41c72d5e..a79d22a2c3 100644 --- a/src/foundation/src/Console/RouteListCommand.php +++ b/src/foundation/src/Console/RouteListCommand.php @@ -65,6 +65,7 @@ class RouteListCommand extends Command 'GET' => 'blue', 'HEAD' => '#6C7280', 'OPTIONS' => '#6C7280', + 'QUERY' => '#6C7280', 'POST' => 'yellow', 'PUT' => 'yellow', 'PATCH' => 'yellow', @@ -357,7 +358,7 @@ protected function forCli(Collection $routes): array $routes = $routes->map( fn ($route) => array_merge($route, [ 'action' => $this->formatActionForCli($route), - 'method' => $route['method'] === 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS' ? 'ANY' : $route['method'], + 'method' => $route['method'] === implode('|', Router::$verbs) ? 'ANY' : $route['method'], 'uri' => $route['domain'] ? ($route['domain'] . '/' . ltrim($route['uri'], '/')) : $route['uri'], ]), ); @@ -449,7 +450,7 @@ protected function formatActionForCli(array $route): ?string : false; if (is_string($path) && str_starts_with($path, base_path('vendor'))) { - $actionCollection = new Collection(explode('\\', $action)); + $actionCollection = (new Stringable($action))->explode('\\'); return $name . $actionCollection->take(2)->implode('\\') . ' ' . $actionCollection->last(); } diff --git a/src/foundation/src/Http/Middleware/PreventRequestForgery.php b/src/foundation/src/Http/Middleware/PreventRequestForgery.php index 0d7094da67..8172f438f6 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestForgery.php +++ b/src/foundation/src/Http/Middleware/PreventRequestForgery.php @@ -94,7 +94,7 @@ public function handle(Request $request, Closure $next): Response */ protected function isReading(Request $request): bool { - return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS'], true); + return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS', 'QUERY'], true); } /** diff --git a/src/http/src/Middleware/SetCacheHeaders.php b/src/http/src/Middleware/SetCacheHeaders.php index f0e5c5f876..30c549c357 100644 --- a/src/http/src/Middleware/SetCacheHeaders.php +++ b/src/http/src/Middleware/SetCacheHeaders.php @@ -83,6 +83,7 @@ public function handle(Request $request, Closure $next, array|string $options = */ protected function parseOptions(string $options): array { + // Keep this explode() to avoid an extra Stringable allocation per call. return (new Collection(explode(';', rtrim($options, ';'))))->mapWithKeys(function ($option) { $data = explode('=', $option, 2); diff --git a/src/queue/src/Console/MonitorCommand.php b/src/queue/src/Console/MonitorCommand.php index baef10b4c5..02acea51f9 100644 --- a/src/queue/src/Console/MonitorCommand.php +++ b/src/queue/src/Console/MonitorCommand.php @@ -11,6 +11,7 @@ use Hypervel\Queue\Events\QueueBusy; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'queue:monitor')] @@ -74,7 +75,7 @@ public function handle(): void */ protected function parseQueues(string $queues): Collection { - return (new Collection(explode(',', $queues)))->map(function (string $queue): array { + return (new Stringable($queues))->explode(',')->map(function (string $queue): array { [$connection, $queue] = array_pad(explode(':', $queue, 2), 2, null); if (! isset($queue)) { diff --git a/src/routing/src/RouteRegistrar.php b/src/routing/src/RouteRegistrar.php index 09f002d4c9..82db600b6a 100644 --- a/src/routing/src/RouteRegistrar.php +++ b/src/routing/src/RouteRegistrar.php @@ -21,6 +21,7 @@ * @method Route patch(string $uri, callable|array|string|null $action = null) * @method Route post(string $uri, callable|array|string|null $action = null) * @method Route put(string $uri, callable|array|string|null $action = null) + * @method Route query(string $uri, callable|array|string|null $action = null) * @method $this as(string $value) * @method $this can(UnitEnum|string $ability, array|string $models = []) * @method $this controller(string $controller) @@ -60,7 +61,7 @@ class RouteRegistrar * @var string[] */ protected array $passthru = [ - 'get', 'post', 'put', 'patch', 'delete', 'options', 'any', + 'get', 'post', 'put', 'patch', 'delete', 'options', 'query', 'any', ]; /** diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index 99522031c7..48e0362529 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -120,7 +120,7 @@ class Router implements BindingRegistrar, RegistrarContract * * @var array */ - public static array $verbs = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; + public static array $verbs = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'QUERY']; /** * Create a new Router instance. @@ -180,6 +180,14 @@ public function options(string $uri, array|string|callable|null $action = null): return $this->addRoute('OPTIONS', $uri, $action); } + /** + * Register a new QUERY route with the router. + */ + public function query(string $uri, array|string|callable|null $action = null): Route + { + return $this->addRoute('QUERY', $uri, $action); + } + /** * Register a new route responding to all verbs. */ diff --git a/src/routing/src/UrlGenerator.php b/src/routing/src/UrlGenerator.php index 220cf814cc..6b3555919e 100755 --- a/src/routing/src/UrlGenerator.php +++ b/src/routing/src/UrlGenerator.php @@ -407,6 +407,7 @@ public function hasCorrectSignature(Request $request, bool $absolute = true, Clo $url = $absolute ? $request->url() : '/' . $request->path(); // REMOVED: Vapor's VAPOR_RAW_QUERY_STRING override; Swoole supplies the raw QUERY_STRING. + // Keep this explode() to avoid an extra Stringable allocation per call. $queryString = (new Collection(explode('&', (string) $request->server->get('QUERY_STRING')))) ->reject(function ($parameter) use ($ignoreQuery) { $parameter = Str::before($parameter, '='); diff --git a/src/support/src/Facades/Route.php b/src/support/src/Facades/Route.php index f7d3571a3e..8a2bc2609f 100644 --- a/src/support/src/Facades/Route.php +++ b/src/support/src/Facades/Route.php @@ -64,6 +64,7 @@ * @method static \Hypervel\Routing\Router prependMiddlewareToGroup(string $group, string $middleware) * @method static \Hypervel\Routing\Router pushMiddlewareToGroup(string $group, string $middleware) * @method static \Hypervel\Routing\Route put(string $uri, callable|array|string|null $action = null) + * @method static \Hypervel\Routing\Route query(string $uri, callable|array|string|null $action = null) * @method static \Hypervel\Routing\Route redirect(string $uri, string $destination, int $status = 302) * @method static \Hypervel\Routing\Router removeMiddlewareFromGroup(string $group, array|string $middleware) * @method static array resolveMiddleware(array $middleware, array $excluded = []) diff --git a/tests/Filesystem/FileResponseBuilderTest.php b/tests/Filesystem/FileResponseBuilderTest.php index b337ac14cd..a723d8cb90 100644 --- a/tests/Filesystem/FileResponseBuilderTest.php +++ b/tests/Filesystem/FileResponseBuilderTest.php @@ -87,6 +87,29 @@ function (?int $start, ?int $end) use (&$resolverCalls): mixed { $this->assertSame('bytes', $response->headers->get('Accept-Ranges')); } + #[DataProvider('acceptRangesProvider')] + public function testAcceptRangesFollowsWhetherTheRequestMethodIsSafe(string $method, string $acceptRanges): void + { + $response = $this->build( + Request::create('/file.txt', $method), + fn (?int $start, ?int $end): mixed => $this->stream('body'), + 4, + ); + + $this->assertSame($acceptRanges, $response->headers->get('Accept-Ranges')); + } + + /** + * Get request methods with their expected Accept-Ranges header. + */ + public static function acceptRangesProvider(): array + { + return [ + 'safe query' => ['QUERY', 'bytes'], + 'unsafe post' => ['POST', 'none'], + ]; + } + public function testBodyContainingOnlyZeroIsWritten(): void { $response = $this->build( diff --git a/tests/Http/Middleware/PreventRequestForgeryTest.php b/tests/Http/Middleware/PreventRequestForgeryTest.php index 2fc1238b54..90f8ce0cdc 100644 --- a/tests/Http/Middleware/PreventRequestForgeryTest.php +++ b/tests/Http/Middleware/PreventRequestForgeryTest.php @@ -27,6 +27,16 @@ public function testSameOriginHeaderPasses(): void $this->assertSame('OK', $response->getContent()); } + public function testQueryRequestsPassWithoutToken(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(method: 'QUERY'); + + $response = $middleware->handle($request, fn (): Response => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + public function testSameSiteHeaderRejectedByDefault(): void { $middleware = $this->createMiddleware(); @@ -131,11 +141,11 @@ public function testOriginOnlyModePassesSameOrigin(): void /** * Create a request with the given headers and token. */ - protected function createRequest(array $server = [], ?string $token = null): Request + protected function createRequest(array $server = [], ?string $token = null, string $method = 'POST'): Request { $request = Request::create( 'http://example.com/test', - 'POST', + $method, $token ? ['_token' => $token] : [], [], [], diff --git a/tests/Integration/Routing/Fixtures/query_routes.php b/tests/Integration/Routing/Fixtures/query_routes.php new file mode 100644 index 0000000000..0402820b83 --- /dev/null +++ b/tests/Integration/Routing/Fixtures/query_routes.php @@ -0,0 +1,14 @@ +json([ + 'method' => request()->method(), + 'term' => request()->query('term'), + 'filter' => request()->input('filter'), + ]); +}); diff --git a/tests/Integration/Routing/RouteCachingTest.php b/tests/Integration/Routing/RouteCachingTest.php index 03f650b5ba..d6a21df19f 100644 --- a/tests/Integration/Routing/RouteCachingTest.php +++ b/tests/Integration/Routing/RouteCachingTest.php @@ -32,6 +32,17 @@ public function testRedirectRoutes() $this->get('/foo/1')->assertRedirect('/foo/1/bar'); } + public function testQueryRoutes(): void + { + $this->defineCacheRoutes(file_get_contents(__DIR__ . '/Fixtures/query_routes.php')); + + $this->call('QUERY', '/search?term=hypervel', ['filter' => 'framework'])->assertExactJson([ + 'method' => 'QUERY', + 'term' => 'hypervel', + 'filter' => 'framework', + ]); + } + public function testSetContainerInvalidatesControllerDispatcherCache() { $container1 = new Container; diff --git a/tests/Routing/RouteRegistrarTest.php b/tests/Routing/RouteRegistrarTest.php index 81c4e0f81b..8fc8ef9e38 100644 --- a/tests/Routing/RouteRegistrarTest.php +++ b/tests/Routing/RouteRegistrarTest.php @@ -223,6 +223,17 @@ public function testCanRegisterPostRouteWithClosureAction() $this->seeMiddleware('post-middleware'); } + public function testCanRegisterQueryRouteWithClosureAction(): void + { + $this->router->middleware('query-middleware')->query('users', function (): string { + return 'found'; + }); + + $this->assertTrue($this->getRoute()->matches(Request::create('users', 'QUERY'))); + $this->assertSame(['QUERY'], $this->getRoute()->methods()); + $this->seeMiddleware('query-middleware'); + } + public function testCanRegisterAnyRouteWithClosureAction() { $this->router->middleware('test-middleware')->any('users', function () { diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php index d4a169c172..377ab7cdd8 100644 --- a/tests/Routing/RoutingRouteTest.php +++ b/tests/Routing/RoutingRouteTest.php @@ -99,8 +99,12 @@ public function testBasicDispatchingOfRoutes() $router->post('foo/bar', function () { return 'post hello'; }); + $router->query('foo/bar', function (): string { + return 'query hello'; + }); $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'GET'))->getContent()); $this->assertSame('post hello', $router->dispatch(Request::create('foo/bar', 'POST'))->getContent()); + $this->assertSame('query hello', $router->dispatch(Request::create('foo/bar', 'QUERY'))->getContent()); $router = $this->getRouter(); $router->get('foo/{bar}', function ($name) { @@ -170,6 +174,7 @@ public function testBasicDispatchingOfRoutes() return 'hello'; }); $this->assertEmpty($router->dispatch(Request::create('foo/bar', 'HEAD'))->getContent()); + $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'QUERY'))->getContent()); $router = $this->getRouter(); $router->get('foo/bar', function () { From b5f7d95ed99907cfbf2c637b8763b4edf95e062f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:52:42 +0000 Subject: [PATCH 3/6] Render trusted mail component content raw Complete laravel/framework#55149 (https://github.com/laravel/framework/pull/55149) against framework master (cd6e81dff3). Its Markdown renderer and parser changes and tests were already ported. The button, header, layout and message views still escaped their slots, header, subcopy and footer, where Laravel outputs them raw. For slots and parsed Markdown the output is unchanged, since EncodedHtmlString already passes Htmlable values through. The difference is plain-string data passed straight to a component: it now renders as markup, as in Laravel. Values interpolated with {{ }} in the mailable template itself stay encoded. The existing table fixture's string subcopy now contains markup, and both encoding tests assert it renders. Both failed before the change. Validation: all mail and notification tests pass. Claude-Session: https://claude.ai/code/session_01WveaB7yyFo2T6yo7cnEpW9 --- src/mail/resources/views/html/button.blade.php | 2 +- src/mail/resources/views/html/header.blade.php | 2 +- src/mail/resources/views/html/layout.blade.php | 8 ++++---- src/mail/resources/views/html/message.blade.php | 4 ++-- .../Mail/Fixtures/table-with-template.blade.php | 2 +- .../Integration/Mail/MailableWithSecuredEncodingTest.php | 2 +- .../Mail/MailableWithoutSecuredEncodingTest.php | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/mail/resources/views/html/button.blade.php b/src/mail/resources/views/html/button.blade.php index 4a9bf7d004..050e969d21 100644 --- a/src/mail/resources/views/html/button.blade.php +++ b/src/mail/resources/views/html/button.blade.php @@ -12,7 +12,7 @@
-{{ $slot }} +{!! $slot !!}
diff --git a/src/mail/resources/views/html/header.blade.php b/src/mail/resources/views/html/header.blade.php index 459960b702..2178c39731 100644 --- a/src/mail/resources/views/html/header.blade.php +++ b/src/mail/resources/views/html/header.blade.php @@ -5,7 +5,7 @@ @if (trim($slot) === 'Hypervel') @else -{{ $slot }} +{!! $slot !!} @endif diff --git a/src/mail/resources/views/html/layout.blade.php b/src/mail/resources/views/html/layout.blade.php index dde46c6fc1..04b4825839 100644 --- a/src/mail/resources/views/html/layout.blade.php +++ b/src/mail/resources/views/html/layout.blade.php @@ -31,7 +31,7 @@ -{{ $header ?? '' }} +{!! $header ?? '' !!} @@ -40,16 +40,16 @@ -{{ $footer ?? '' }} +{!! $footer ?? '' !!} diff --git a/src/mail/resources/views/html/message.blade.php b/src/mail/resources/views/html/message.blade.php index d2dc7bebf4..42f06910f8 100644 --- a/src/mail/resources/views/html/message.blade.php +++ b/src/mail/resources/views/html/message.blade.php @@ -7,13 +7,13 @@ {{-- Body --}} -{{ $slot }} +{!! $slot !!} {{-- Subcopy --}} @isset($subcopy) -{{ $subcopy }} +{!! $subcopy !!} @endisset diff --git a/tests/Integration/Mail/Fixtures/table-with-template.blade.php b/tests/Integration/Mail/Fixtures/table-with-template.blade.php index ecdac4dc37..e70665704c 100644 --- a/tests/Integration/Mail/Fixtures/table-with-template.blade.php +++ b/tests/Integration/Mail/Fixtures/table-with-template.blade.php @@ -1,4 +1,4 @@ - + *Hi* {{ $user->name }} diff --git a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php index 3fb163c1d0..32fb6facfe 100644 --- a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php +++ b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php @@ -70,7 +70,7 @@ public function build(): static }; $mailable->assertSeeInHtml($expected, false); - $mailable->assertSeeInHtml('

This is a subcopy

', false); + $mailable->assertSeeInHtml('

This is a subcopy

', false); $mailable->assertSeeInHtml(<<<'TABLE' diff --git a/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php b/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php index 87332e0d50..22e90bbe9b 100644 --- a/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php +++ b/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php @@ -70,7 +70,7 @@ public function build(): static }; $mailable->assertSeeInHtml($expected, false); - $mailable->assertSeeInHtml('

This is a subcopy

', false); + $mailable->assertSeeInHtml('

This is a subcopy

', false); $mailable->assertSeeInHtml(<<<'TABLE'
From 6eb6ded33014302c9dab28bdc0603cf08409a23a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:05:29 +0000 Subject: [PATCH 4/6] Register payload callbacks directly on the queue manager Port laravel/framework#61367 (https://github.com/laravel/framework/pull/61367) from framework master (cd6e81dff3). Queue::createPayloadUsing() through the facade reached QueueManager::__call(), which resolved the default connection first. Registering a callback during boot therefore threw when that connection wasn't configured. QueueManager now registers the callback directly, with the same boot-only warning as Queue::createPayloadUsing(). QueueManager now imports the queue contract as QueueContract, like the other queue classes, so Queue refers to the base queue class as in Laravel. The regenerated Queue facade lists the method under the manager. laravel/framework#52147 (https://github.com/laravel/framework/pull/52147) (whereLike) was already covered: source, grammars, unit and integration tests, and docs match master, apart from the deliberate MySQL CAST(... AS BINARY) form and the removed SQL Server cases. Validation: a regression test registers a callback with an unconfigured default connection. The queue test suite, FacadeDocblocksTest and the full parallel suite pass. Claude-Session: https://claude.ai/code/session_01WveaB7yyFo2T6yo7cnEpW9 --- src/queue/src/QueueManager.php | 20 ++++++++++++++++---- src/support/src/Facades/Queue.php | 2 +- tests/Queue/QueueManagerTest.php | 12 ++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index a492215f03..0638a7e85c 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -12,7 +12,7 @@ use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\Factory as FactoryContract; use Hypervel\Contracts\Queue\Monitor as MonitorContract; -use Hypervel\Contracts\Queue\Queue; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Queue\Connectors\ConnectorInterface; use Hypervel\Queue\Events\JobExceptionOccurred; @@ -34,7 +34,7 @@ use function Hypervel\Support\enum_value; /** - * @mixin Queue + * @mixin QueueContract */ class QueueManager implements FactoryContract, MonitorContract { @@ -357,7 +357,7 @@ public function connected(UnitEnum|string|null $name = null): bool /** * Resolve a queue connection instance. */ - public function connection(UnitEnum|string|null $name = null): Queue + public function connection(UnitEnum|string|null $name = null): QueueContract { if ($name instanceof UnitEnum) { $name = (string) enum_value($name); @@ -382,7 +382,7 @@ public function connection(UnitEnum|string|null $name = null): Queue * * @throws InvalidArgumentException */ - protected function resolve(string $name): Queue + protected function resolve(string $name): QueueContract { $config = $this->getConfig($name); @@ -570,6 +570,18 @@ public function setApplication(Container $app): static return $this; } + /** + * Register a callback to be executed when creating job payloads. + * + * Boot-only. The callback persists in a static property for the worker + * lifetime and runs on every subsequent payload creation across all + * coroutines. Passing null clears the registry. + */ + public function createPayloadUsing(?callable $callback): void + { + Queue::createPayloadUsing($callback); + } + /** * Dynamically pass calls to the default connection. */ diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index e25067fc02..8b1fa5c035 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -15,6 +15,7 @@ * @method static void before(mixed $callback) * @method static bool connected(\UnitEnum|string|null $name = null) * @method static \Hypervel\Contracts\Queue\Queue connection(\UnitEnum|string|null $name = null) + * @method static void createPayloadUsing(callable|null $callback) * @method static void exceptionOccurred(mixed $callback) * @method static void extend(string $driver, \Closure $resolver) * @method static void failing(mixed $callback) @@ -58,7 +59,6 @@ * @method static int reservedSize(\UnitEnum|string|null $queue = null) * @method static \Hypervel\Contracts\Queue\Queue setConnectionName(string $name) * @method static int size(\UnitEnum|string|null $queue = null) - * @method static void createPayloadUsing(callable|null $callback) * @method static void flushState() * @method static array getConfig() * @method static \Hypervel\Contracts\Container\Container getContainer() diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index ac9be0067a..13ef340b9f 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -429,6 +429,18 @@ public function testSetApplicationUpdatesCachedDirectQueueInPlace(): void $this->assertSame($queue, $manager->connection('sync')); } + public function testPayloadCallbacksCanBeRegisteredWithoutResolvingTheDefaultConnection(): void + { + $container = $this->getContainer(); + $container->make('config')->set('queue.default', 'missing'); + + $manager = new QueueManager($container); + + $manager->createPayloadUsing(static fn (string $connection, ?string $queue, array $payload): array => []); + + $this->assertFalse($manager->connected('missing')); + } + protected function getContainer(): Container { $container = new Container; From f6315b2c5caf7ba7dad7ad9830da336ca45a7dda Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:05:29 +0000 Subject: [PATCH 5/6] Add native types to the database testing assertions Type all InteractsWithDatabase methods, completing the typing that castAsJson() started: - Tables accept iterables, models and table or model class names. - Connections accept enums, strings or null, like castAsJson() and the database manager. - The fluent assertions return static. The soft-delete column stays nullable, since an explicit null lets a soft-deletable model supply its own column. The existing model-class tests now cover that for both assertions. Validation: FoundationInteractsWithDatabaseTest, PHPStan and the full parallel suite pass. Claude-Session: https://claude.ai/code/session_01WveaB7yyFo2T6yo7cnEpW9 --- .../Concerns/InteractsWithDatabase.php | 68 ++++++------------- .../FoundationInteractsWithDatabaseTest.php | 6 +- 2 files changed, 23 insertions(+), 51 deletions(-) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index 825ecc6e21..1b35aac1fa 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -26,10 +26,8 @@ trait InteractsWithDatabase * * @param class-string|iterable|Model|string $table * @param array $data - * @param null|string $connection - * @return $this */ - protected function assertDatabaseHas($table, array $data = [], $connection = null) + protected function assertDatabaseHas(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null): static { if (is_iterable($table)) { foreach ($table as $item) { @@ -67,10 +65,8 @@ protected function assertDatabaseHas($table, array $data = [], $connection = nul * * @param class-string|iterable|Model|string $table * @param array $data - * @param null|string $connection - * @return $this */ - protected function assertDatabaseMissing($table, array $data = [], $connection = null) + protected function assertDatabaseMissing(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null): static { if (is_iterable($table)) { foreach ($table as $item) { @@ -107,11 +103,9 @@ protected function assertDatabaseMissing($table, array $data = [], $connection = /** * Assert the count of table entries. * - * @param Model|string $table - * @param null|string $connection - * @return $this + * @param class-string|Model|string $table */ - protected function assertDatabaseCount($table, int $count, $connection = null) + protected function assertDatabaseCount(Model|string $table, int $count, UnitEnum|string|null $connection = null): static { $this->assertThat( $this->getTable($table), @@ -125,10 +119,8 @@ protected function assertDatabaseCount($table, int $count, $connection = null) * Assert that the given table or tables has no entries. * * @param class-string|iterable|Model|string>|Model|string $table - * @param null|string $connection - * @return $this */ - protected function assertDatabaseEmpty($table, $connection = null) + protected function assertDatabaseEmpty(iterable|Model|string $table, UnitEnum|string|null $connection = null): static { if (is_iterable($table)) { foreach ($table as $item) { @@ -151,11 +143,8 @@ protected function assertDatabaseEmpty($table, $connection = null) * * @param class-string|iterable|Model|string>|Model|string $table * @param array $data - * @param null|string $connection - * @param null|string $deletedAtColumn - * @return $this */ - protected function assertSoftDeleted($table, array $data = [], $connection = null, $deletedAtColumn = 'deleted_at') + protected function assertSoftDeleted(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null, ?string $deletedAtColumn = 'deleted_at'): static { if (is_iterable($table)) { foreach ($table as $item) { @@ -199,11 +188,8 @@ protected function assertSoftDeleted($table, array $data = [], $connection = nul * * @param class-string|iterable|Model|string>|Model|string $table * @param array $data - * @param null|string $connection - * @param null|string $deletedAtColumn - * @return $this */ - protected function assertNotSoftDeleted($table, array $data = [], $connection = null, $deletedAtColumn = 'deleted_at') + protected function assertNotSoftDeleted(iterable|Model|string $table, array $data = [], UnitEnum|string|null $connection = null, ?string $deletedAtColumn = 'deleted_at'): static { if (is_iterable($table)) { foreach ($table as $item) { @@ -246,9 +232,8 @@ protected function assertNotSoftDeleted($table, array $data = [], $connection = * Assert the given model exists in the database. * * @param class-string|iterable|Model|string $model - * @return $this */ - protected function assertModelExists($model) + protected function assertModelExists(iterable|Model|string $model): static { return $this->assertDatabaseHas($model); } @@ -257,21 +242,16 @@ protected function assertModelExists($model) * Assert the given model does not exist in the database. * * @param class-string|iterable|Model|string $model - * @return $this */ - protected function assertModelMissing($model) + protected function assertModelMissing(iterable|Model|string $model): static { return $this->assertDatabaseMissing($model); } /** * Specify the number of database queries that should occur throughout the test. - * - * @param int $expected - * @param null|string $connection - * @return $this */ - public function expectsDatabaseQueryCount($expected, $connection = null) + public function expectsDatabaseQueryCount(int $expected, UnitEnum|string|null $connection = null): static { with($this->getConnection($connection), function ($connectionInstance) use ($expected, $connection) { $actual = 0; @@ -297,12 +277,9 @@ public function expectsDatabaseQueryCount($expected, $connection = null) /** * Determine if the argument is a soft deletable model. * - * @param mixed $model - * @return bool - * * @phpstan-assert-if-true Model $model */ - protected function isSoftDeletableModel($model) + protected function isSoftDeletableModel(mixed $model): bool { return $model instanceof Model && $model::isSoftDeletable(); } @@ -330,11 +307,9 @@ public function castAsJson(array|object|string $value, UnitEnum|string|null $con /** * Get the database connection. * - * @param null|string $connection * @param null|class-string|Model|string $table - * @return Connection */ - protected function getConnection($connection = null, $table = null) + protected function getConnection(UnitEnum|string|null $connection = null, Model|string|null $table = null): Connection { $database = $this->app->make('db'); @@ -353,9 +328,8 @@ protected function getConnection($connection = null, $table = null) * Get the table name from the given model or string. * * @param class-string|Model|string $table - * @return string */ - protected function getTable($table) + protected function getTable(Model|string $table): string { if ($table instanceof Model) { return $table->getTable(); @@ -367,10 +341,9 @@ protected function getTable($table) /** * Get the table connection specified in the given model. * - * @param class-string|Model|string $table - * @return null|string + * @param null|class-string|Model|string $table */ - protected function getTableConnection($table) + protected function getTableConnection(Model|string|null $table): ?string { if ($table instanceof Model) { return $table->getConnectionName(); @@ -382,11 +355,9 @@ protected function getTableConnection($table) /** * Get the table column name used for soft deletes. * - * @param string $table - * @param string $defaultColumnName - * @return string + * @param class-string|Model|string $table */ - protected function getDeletedAtColumn($table, $defaultColumnName = 'deleted_at') + protected function getDeletedAtColumn(Model|string $table, ?string $defaultColumnName = 'deleted_at'): ?string { return $this->newModelFor($table)?->getDeletedAtColumn() ?: $defaultColumnName; } @@ -394,10 +365,9 @@ protected function getDeletedAtColumn($table, $defaultColumnName = 'deleted_at') /** * Get the model entity from the given model or string. * - * @param Model|string $table - * @return null|Model + * @param null|class-string|Model|string $table */ - protected function newModelFor($table) + protected function newModelFor(Model|string|null $table): ?Model { return is_subclass_of($table, Model::class) ? (new $table) : null; } diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index aff36ddc81..9e0709cb5b 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -335,11 +335,12 @@ public function testAssertSoftDeletedInDatabaseFindsResults() $this->assertSoftDeleted($this->table, $this->data); } - public function testAssertSoftDeletedSupportModelStrings() + public function testAssertSoftDeletedSupportModelStrings(): void { $this->mockCountBuilder(true); $this->assertSoftDeleted(ProductStub::class, $this->data); + $this->assertSoftDeleted(ProductStub::class, $this->data, deletedAtColumn: null); } public function testAssertSoftDeletedInDatabaseDoesNotFindResults(): void @@ -401,11 +402,12 @@ public function testAssertNotSoftDeletedInDatabaseFindsResults() $this->assertNotSoftDeleted($this->table, $this->data); } - public function testAssertNotSoftDeletedSupportModelStrings() + public function testAssertNotSoftDeletedSupportModelStrings(): void { $this->mockCountBuilder(true); $this->assertNotSoftDeleted(ProductStub::class, $this->data); + $this->assertNotSoftDeleted(ProductStub::class, $this->data, deletedAtColumn: null); } public function testAssertNotSoftDeletedOnlyFindsMatchingModels(): void From c75b1d378989313dea8d64e6da06f7759bcbc725 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:26:23 +0000 Subject: [PATCH 6/6] Correct exception placement and timing notes from review The collection casts' InvalidArgumentException is thrown when the returned caster reads an attribute, not when castUsing() builds it. Move the tag to each caster's get(). Laravel has the same misplaced tag. orderedLazyById() only throws InvalidArgumentException when called; the missing-column RuntimeException comes later, while the returned lazy collection is iterated. Keep that tag and say when it happens. Benchmark durations are integers whenever the nanosecond difference divides exactly, and averages are never null because the iteration range is never empty. The measure() and value() return types now say float|int. Validation: lint and full PHPStan pass. Docblock changes only. Claude-Session: https://claude.ai/code/session_01WveaB7yyFo2T6yo7cnEpW9 --- src/database/src/Concerns/BuildsQueries.php | 2 +- src/database/src/Eloquent/Casts/AsCollection.php | 4 ++-- src/database/src/Eloquent/Casts/AsEncryptedCollection.php | 4 ++-- src/support/src/Benchmark.php | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 75e0f673f8..0abb2de37b 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -310,7 +310,7 @@ public function lazyByIdDesc(int $chunkSize = 1000, ?string $column = null, ?str * @return LazyCollection * * @throws InvalidArgumentException - * @throws RuntimeException + * @throws RuntimeException if the ID column is missing while iterating the results */ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): LazyCollection { diff --git a/src/database/src/Eloquent/Casts/AsCollection.php b/src/database/src/Eloquent/Casts/AsCollection.php index 78c9a2cc65..7f0d63635f 100644 --- a/src/database/src/Eloquent/Casts/AsCollection.php +++ b/src/database/src/Eloquent/Casts/AsCollection.php @@ -18,8 +18,6 @@ class AsCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @return CastsAttributes, iterable> - * - * @throws InvalidArgumentException */ public static function castUsing(array $arguments): CastsAttributes { @@ -34,6 +32,8 @@ public function __construct(protected array $arguments) /** * Transform the attribute from the underlying model values. + * + * @throws InvalidArgumentException */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { diff --git a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php index bb323581db..d377c28ed6 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php @@ -19,8 +19,6 @@ class AsEncryptedCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @return CastsAttributes, iterable> - * - * @throws InvalidArgumentException */ public static function castUsing(array $arguments): CastsAttributes { @@ -35,6 +33,8 @@ public function __construct(protected array $arguments) /** * Transform the attribute from the underlying model values. + * + * @throws InvalidArgumentException */ public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { diff --git a/src/support/src/Benchmark.php b/src/support/src/Benchmark.php index 46a29c1abd..65f1741451 100644 --- a/src/support/src/Benchmark.php +++ b/src/support/src/Benchmark.php @@ -15,7 +15,7 @@ class Benchmark * Measure a callable or array of callables over the given number of iterations. * * @param array|Closure $benchmarkables - * @return array|float + * @return array|float */ public static function measure(Closure|array $benchmarkables, int $iterations = 1): array|float { @@ -42,7 +42,7 @@ public static function measure(Closure|array $benchmarkables, int $iterations = * @template TReturn of mixed * * @param (callable(): TReturn) $callback - * @return array{0: TReturn, 1: float} + * @return array{0: TReturn, 1: float|int} */ public static function value(callable $callback): array {