diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9098acb699..fc3f7d9168 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,10 +58,14 @@ jobs:
core.setFailed('Workflow failed. Release version does not match with selected target branch. Did you select the correct branch?')
- name: Update Application.php version
- run: sed -i "s/const VERSION = '.*';/const VERSION = '${{ steps.version.outputs.version }}';/g" src/foundation/src/Application.php
+ run: |
+ sed -i "s/const string VERSION = '.*';/const string VERSION = '${VERSION}';/g" src/foundation/src/Application.php
+ grep -Fq -- "const string VERSION = '${VERSION}';" src/foundation/src/Application.php
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
- name: Commit version change
- uses: stefanzweifel/git-auto-commit-action@v5
+ uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
with:
commit_message: "Update version to v${{ steps.version.outputs.version }}"
diff --git a/README.md b/README.md
index ac12337b49..a94c6cf15e 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@

-
+
diff --git a/bin/release.sh b/bin/release.sh
index 9d03efdb9c..10a1902cd7 100755
--- a/bin/release.sh
+++ b/bin/release.sh
@@ -18,7 +18,7 @@ fi
# Initialize variables
NOW=$(date +%s)
-RELEASE_BRANCH="0.3"
+RELEASE_BRANCH="0.4"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
BASEPATH=$(cd `dirname $0`; cd ../src/; pwd)
VERSION=$1
@@ -106,4 +106,4 @@ done
TIME=$(echo "$(date +%s) - $NOW" | bc)
-printf "Execution time: %f seconds" $TIME
\ No newline at end of file
+printf "Execution time: %f seconds" $TIME
diff --git a/src/auth/src/Recaller.php b/src/auth/src/Recaller.php
index 0d7b6f9d0d..9e9742e49b 100644
--- a/src/auth/src/Recaller.php
+++ b/src/auth/src/Recaller.php
@@ -21,7 +21,8 @@ class Recaller
*/
public function __construct(string $recaller)
{
- $this->recaller = @unserialize($recaller, ['allowed_classes' => false]) ?: $recaller;
+ // Cookie middleware owns serialization; legacy cookie decoding is intentionally omitted.
+ $this->recaller = $recaller;
$this->segments = explode('|', $this->recaller);
}
diff --git a/src/broadcasting/src/AnonymousEvent.php b/src/broadcasting/src/AnonymousEvent.php
index 28da52f06f..f5e34c2bdd 100644
--- a/src/broadcasting/src/AnonymousEvent.php
+++ b/src/broadcasting/src/AnonymousEvent.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Support\Arrayable;
use Hypervel\Foundation\Events\Dispatchable;
use Hypervel\Support\Arr;
+use Hypervel\Support\Collection;
class AnonymousEvent implements ShouldBroadcast
{
@@ -75,7 +76,7 @@ public function with(array|Arrayable $payload): static
{
$this->payload = $payload instanceof Arrayable
? $payload->toArray()
- : collect($payload)->map(
+ : (new Collection($payload))->map(
fn ($p) => $p instanceof Arrayable ? $p->toArray() : $p
)->all();
diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php
index 5725801abf..50b4d5f198 100644
--- a/src/cache/src/DatabaseLock.php
+++ b/src/cache/src/DatabaseLock.php
@@ -33,8 +33,10 @@ class DatabaseLock extends Lock implements RefreshableLock
/**
* The prune probability odds.
+ *
+ * @var null|array{int, int}|array{}
*/
- protected array $lottery;
+ protected ?array $lottery;
/**
* The default number of seconds that a lock should be held.
@@ -43,6 +45,8 @@ class DatabaseLock extends Lock implements RefreshableLock
/**
* Create a new lock instance.
+ *
+ * @param null|array{int, int}|array{} $lottery the prune probability odds, or null to disable automatic pruning
*/
public function __construct(
ConnectionResolverInterface $resolver,
@@ -51,7 +55,7 @@ public function __construct(
string $table,
int $seconds,
?string $owner = null,
- array $lottery = [2, 100],
+ ?array $lottery = [2, 100],
int $defaultTimeoutInSeconds = 86400
) {
parent::__construct($name, $seconds, $owner);
@@ -73,6 +77,8 @@ protected function connection(): ConnectionInterface
/**
* Attempt to acquire the lock.
+ *
+ * @throws Throwable
*/
public function acquire(): bool
{
@@ -99,7 +105,7 @@ public function acquire(): bool
$acquired = $updated >= 1;
}
- if (count($this->lottery) === 2 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
+ if (count($this->lottery ?? []) === 2 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
$this->pruneExpiredLocks();
}
diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php
index 1b9297a382..d5ef7b19b1 100644
--- a/src/collections/src/Collection.php
+++ b/src/collections/src/Collection.php
@@ -14,8 +14,10 @@
use Hypervel\Support\Traits\Macroable;
use Hypervel\Support\Traits\TransformsToResourceCollection;
use InvalidArgumentException;
+use Override;
use SortDirection;
use stdClass;
+use Stringable as BaseStringable;
use Traversable;
use UnitEnum;
@@ -505,7 +507,18 @@ public function getOrPut(mixed $key, mixed $value): mixed
/**
* Group an associative array by a field or using a callback.
+ *
+ * @template TGroupKey of array-key|bool|null|UnitEnum|BaseStringable
+ *
+ * @param array|(callable(TValue, TKey): (array|TGroupKey))|string $groupBy
+ * @return static<
+ * ($groupBy is (array|string)
+ * ? array-key
+ * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))),
+ * static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)>
+ * >
*/
+ #[Override]
public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static
{
if (! $this->useAsCallable($groupBy) && is_array($groupBy)) {
@@ -529,7 +542,7 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal
$groupKey = match (true) {
is_bool($groupKey) => (int) $groupKey,
$groupKey instanceof UnitEnum => enum_value($groupKey),
- $groupKey instanceof \Stringable => (string) $groupKey,
+ $groupKey instanceof BaseStringable => (string) $groupKey,
is_null($groupKey) => (string) $groupKey,
default => $groupKey,
};
@@ -554,13 +567,8 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal
/**
* Key an associative array by a field or using a callback.
- *
- * @template TNewKey of array-key|\UnitEnum
- *
- * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy
- * @return static<($keyBy is (array|string) ? array-key : (TNewKey is UnitEnum ? array-key : TNewKey)), TValue>
- * @phpstan-ignore method.childReturnType (complex conditional types PHPStan can't match)
*/
+ #[Override]
public function keyBy(callable|array|string $keyBy): static
{
$keyBy = $this->valueRetriever($keyBy);
@@ -1572,7 +1580,7 @@ protected function sortByMany(array $comparisons = [], int $options = SORT_REGUL
}
} else {
$result = match ($options) {
- SORT_NUMERIC => (int) $values[0] <=> (int) $values[1],
+ SORT_NUMERIC => (float) $values[0] <=> (float) $values[1],
SORT_STRING => strcmp((string) $values[0], (string) $values[1]),
SORT_NATURAL => strnatcmp((string) $values[0], (string) $values[1]),
SORT_LOCALE_STRING => strcoll((string) $values[0], (string) $values[1]),
@@ -1816,9 +1824,9 @@ public function count(): int
/**
* Count the number of items in the collection by a field or using a callback.
*
- * @param null|(callable(TValue, TKey): (array-key|UnitEnum))|string $countBy
* @return static
*/
+ #[Override]
public function countBy(callable|string|null $countBy = null): Collection
{
return $this->newInstance($this->lazy()->countBy($countBy)->all());
diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php
index 65d53a68b4..3fa328bc9e 100644
--- a/src/collections/src/Enumerable.php
+++ b/src/collections/src/Enumerable.php
@@ -15,8 +15,10 @@
use JsonException;
use JsonSerializable;
use SortDirection;
+use Stringable as BaseStringable;
use Traversable;
use UnexpectedValueException;
+use UnitEnum;
/**
* Some transformations may return a base collection when an implementation
@@ -435,26 +437,25 @@ public function get(mixed $key, mixed $default = null): mixed;
/**
* Group an associative array by a field or using a callback.
*
- * @template TGroupKey of array-key|\UnitEnum|\Stringable
+ * @template TGroupKey of array-key|bool|null|UnitEnum|BaseStringable
*
* @param array|(callable(TValue, TKey): (array|TGroupKey))|string $groupBy
* @return static<
* ($groupBy is (array|string)
* ? array-key
- * : (TGroupKey is \UnitEnum ? array-key : (TGroupKey is \Stringable ? string : TGroupKey))),
- * static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)>
+ * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))),
+ * Collection<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)>
* >
- * @phpstan-ignore generics.notSubtype (PHPStan cannot prove normalized conditional group keys satisfy array-key)
*/
public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static;
/**
* Key an associative array by a field or using a callback.
*
- * @template TNewKey of array-key
+ * @template TNewKey of array-key|UnitEnum|BaseStringable
*
* @param array|(callable(TValue, TKey): TNewKey)|string $keyBy
- * @return static<($keyBy is string ? array-key : ($keyBy is array ? array-key : TNewKey)), TValue>
+ * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is BaseStringable ? string : array-key))), TValue>
*/
public function keyBy(callable|array|string $keyBy): static;
@@ -1085,8 +1086,8 @@ public function count(): int;
/**
* Count the number of items in the collection by a field or using a callback.
*
- * @param null|(callable(TValue, TKey): array-key)|string $countBy
- * @return static
+ * @param null|(callable(TValue, TKey): (array-key|bool|UnitEnum))|string $countBy
+ * @return Collection|static
*/
public function countBy(callable|string|null $countBy = null): Collection|static;
diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php
index d6418fceaa..5ea4f4371e 100644
--- a/src/collections/src/LazyCollection.php
+++ b/src/collections/src/LazyCollection.php
@@ -354,9 +354,9 @@ public function crossJoin(Arrayable|iterable ...$arrays): static
/**
* Count the number of items in the collection by a field or using a callback.
*
- * @param null|(callable(TValue, TKey): (array-key|UnitEnum))|string $countBy
* @return static
*/
+ #[Override]
public function countBy(callable|string|null $countBy = null): static
{
$countBy = is_null($countBy)
@@ -557,13 +557,8 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal
/**
* Key an associative array by a field or using a callback.
- *
- * @template TNewKey of array-key|\UnitEnum
- *
- * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy
- * @return static<($keyBy is (array|string) ? array-key : (TNewKey is UnitEnum ? array-key : TNewKey)), TValue>
- * @phpstan-ignore method.childReturnType (complex conditional return type PHPStan can't verify)
*/
+ #[Override]
public function keyBy(callable|array|string $keyBy): static
{
return $this->newInstance(function () use ($keyBy) {
diff --git a/src/conditionable/README.md b/src/conditionable/README.md
new file mode 100644
index 0000000000..2104ef5297
--- /dev/null
+++ b/src/conditionable/README.md
@@ -0,0 +1,6 @@
+Conditionable for Hypervel
+===
+
+[](https://deepwiki.com/hypervel/conditionable)
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Conditionable
diff --git a/src/console/src/GeneratorCommand.php b/src/console/src/GeneratorCommand.php
index bff08873d7..60225dbcf1 100644
--- a/src/console/src/GeneratorCommand.php
+++ b/src/console/src/GeneratorCommand.php
@@ -485,10 +485,9 @@ protected function userProviderModel(): ?string
*/
protected function isReservedName(string $name): bool
{
- return in_array(
- strtolower($name),
- array_map('strtolower', $this->reservedNames)
- );
+ $name = strtolower($name);
+
+ return array_any($this->reservedNames, fn ($reservedName) => strtolower($reservedName) === $name);
}
/**
diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php
index f26b7aa83f..c855d21cb3 100755
--- a/src/database/src/Connection.php
+++ b/src/database/src/Connection.php
@@ -215,6 +215,7 @@ public function __construct(string $database = '', string $tablePrefix = '', arr
$this->configuredTablePrefix = $tablePrefix;
$this->config = $config;
+ $this->config['mask_bindings_in_exception_messages'] = (bool) ($config['mask_bindings_in_exception_messages'] ?? false);
$this->readWriteType = $config[self::READ_WRITE_TYPE_CONFIG_KEY] ?? null;
@@ -616,8 +617,8 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal
}
// If an exception occurs when attempting to run a query, we'll format the error
- // message to include the bindings with SQL, which will make this exception a
- // lot more helpful to the developer instead of just the database's errors.
+ // message to include the SQL and, unless masked, its bindings. This provides
+ // more context for the developer than just the database's original error.
catch (CanceledException $exception) {
throw $exception;
} catch (Exception $e) {
@@ -634,6 +635,7 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal
$e,
$this->getConnectionDetails(),
$this->latestReadWriteTypeUsed(),
+ $this->getConfig('mask_bindings_in_exception_messages'),
);
if ($isUniqueConstraintError && $queryException instanceof UniqueConstraintViolationException) {
diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php
index bd20cb174f..4fdda4fa10 100644
--- a/src/database/src/Eloquent/Collection.php
+++ b/src/database/src/Eloquent/Collection.php
@@ -653,7 +653,7 @@ public function makeHidden(array|string $attributes): static
public function mergeHidden(array|string $attributes): static
{
// @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel)
- return $this->each->mergeHidden($attributes);
+ return $this->each->mergeHidden((array) $attributes);
}
/**
@@ -686,7 +686,7 @@ public function makeVisible(array|string $attributes): static
public function mergeVisible(array|string $attributes): static
{
// @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel)
- return $this->each->mergeVisible($attributes);
+ return $this->each->mergeVisible((array) $attributes);
}
/**
@@ -993,7 +993,7 @@ public function toQuery(): Builder
$class = get_class($model);
- if ($this->reject(fn ($model) => $model instanceof $class)->isNotEmpty()) {
+ if ($this->contains(fn ($model) => ! $model instanceof $class)) {
throw new LogicException('Unable to create query for collection with mixed types.');
}
diff --git a/src/database/src/Eloquent/ModelInfo.php b/src/database/src/Eloquent/ModelInfo.php
index 62f053efc0..dfc1ed2a81 100644
--- a/src/database/src/Eloquent/ModelInfo.php
+++ b/src/database/src/Eloquent/ModelInfo.php
@@ -12,12 +12,18 @@
use LogicException;
/**
+ * @template TModel of Model = Model
+ *
* @implements Arrayable
+ *
+ * @internal
*/
class ModelInfo implements Arrayable, ArrayAccess
{
/**
- * @param class-string $class the model's fully-qualified class
+ * Create a new model info instance.
+ *
+ * @param class-string $class the model's fully-qualified class
* @param null|string $database the database connection name
* @param string $table the database table name
* @param null|class-string $policy the policy that applies to the model
@@ -25,22 +31,22 @@ class ModelInfo implements Arrayable, ArrayAccess
* @param BaseCollection}> $relations the relations defined on the model
* @param BaseCollection $events the events that the model dispatches
* @param BaseCollection}> $observers the observers registered for the model
- * @param class-string> $collection the Collection class that collects the models
- * @param class-string> $builder the Builder class registered for the model
+ * @param class-string> $collection the Collection class that collects the models
+ * @param class-string> $builder the Builder class registered for the model
* @param null|class-string $resource the JSON resource class that represents the model
*/
public function __construct(
- public $class,
- public $database,
- public $table,
- public $policy,
- public $attributes,
- public $relations,
- public $events,
- public $observers,
- public $collection,
- public $builder,
- public $resource
+ public string $class,
+ public ?string $database,
+ public string $table,
+ public ?string $policy,
+ public BaseCollection $attributes,
+ public BaseCollection $relations,
+ public BaseCollection $events,
+ public BaseCollection $observers,
+ public string $collection,
+ public string $builder,
+ public ?string $resource
) {
}
@@ -48,7 +54,7 @@ public function __construct(
* Convert the model info to an array.
*
* @return array{
- * "class": class-string,
+ * "class": class-string,
* database: null|string,
* table: string,
* policy: null|class-string,
@@ -56,8 +62,8 @@ public function __construct(
* relations: BaseCollection}>,
* events: BaseCollection,
* observers: BaseCollection}>,
- * collection: class-string>,
- * builder: class-string>,
+ * collection: class-string>,
+ * builder: class-string>,
* resource: null|class-string
* }
*/
@@ -78,21 +84,39 @@ public function toArray(): array
];
}
+ /**
+ * Determine if the given offset exists.
+ */
public function offsetExists(mixed $offset): bool
{
return property_exists($this, $offset);
}
+ /**
+ * Get the value for a given offset.
+ *
+ * @throws InvalidArgumentException
+ */
public function offsetGet(mixed $offset): mixed
{
return property_exists($this, $offset) ? $this->{$offset} : throw new InvalidArgumentException("Property {$offset} does not exist.");
}
+ /**
+ * Set the value at the given offset.
+ *
+ * @throws LogicException
+ */
public function offsetSet(mixed $offset, mixed $value): void
{
throw new LogicException(self::class . ' may not be mutated using array access.');
}
+ /**
+ * Unset the value at the given offset.
+ *
+ * @throws LogicException
+ */
public function offsetUnset(mixed $offset): void
{
throw new LogicException(self::class . ' may not be mutated using array access.');
diff --git a/src/database/src/QueryException.php b/src/database/src/QueryException.php
index e12135efda..881c97b50c 100644
--- a/src/database/src/QueryException.php
+++ b/src/database/src/QueryException.php
@@ -49,7 +49,8 @@ public function __construct(
array $bindings,
Throwable $previous,
array $connectionDetails = [],
- ?string $readWriteType = null
+ ?string $readWriteType = null,
+ bool $maskBindings = false
) {
parent::__construct('', 0, $previous);
@@ -59,7 +60,7 @@ public function __construct(
$this->connectionDetails = $connectionDetails;
$this->readWriteType = $readWriteType;
$this->code = $previous->getCode();
- $this->message = $this->formatMessage($connectionName, $sql, $bindings, $previous);
+ $this->message = $this->formatMessage($connectionName, $sql, $bindings, $previous, $maskBindings);
if ($previous instanceof PDOException) {
$this->errorInfo = $previous->errorInfo;
@@ -69,11 +70,18 @@ public function __construct(
/**
* Format the SQL error message.
*/
- protected function formatMessage(?string $connectionName, string $sql, array $bindings, Throwable $previous): string
- {
+ protected function formatMessage(
+ ?string $connectionName,
+ string $sql,
+ array $bindings,
+ Throwable $previous,
+ bool $maskBindings = false
+ ): string {
$details = $this->formatConnectionDetails();
- return $previous->getMessage() . ' (Connection: ' . $connectionName . $details . ', SQL: ' . Str::replaceArray('?', $bindings, $sql) . ')';
+ $sql = $maskBindings ? $sql : Str::replaceArray('?', $bindings, $sql);
+
+ return $previous->getMessage() . ' (Connection: ' . $connectionName . $details . ', SQL: ' . $sql . ')';
}
/**
diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php
index 42feec03b0..fdc6ddf931 100755
--- a/src/database/src/Schema/Blueprint.php
+++ b/src/database/src/Schema/Blueprint.php
@@ -706,7 +706,7 @@ public function foreign(array|string $columns, ?string $name = null): ForeignKey
}
/**
- * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615).
+ * Create a new auto-incrementing big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615).
*/
public function id(string $column = 'id'): ColumnDefinition
{
@@ -714,7 +714,7 @@ public function id(string $column = 'id'): ColumnDefinition
}
/**
- * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295).
+ * Create a new auto-incrementing integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295).
*/
public function increments(string $column): ColumnDefinition
{
@@ -722,7 +722,7 @@ public function increments(string $column): ColumnDefinition
}
/**
- * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295).
+ * Create a new auto-incrementing integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295).
*/
public function integerIncrements(string $column): ColumnDefinition
{
@@ -730,7 +730,7 @@ public function integerIncrements(string $column): ColumnDefinition
}
/**
- * Create a new auto-incrementing tiny integer column on the table (1-byte, 0 to 255).
+ * Create a new auto-incrementing tiny integer column on the table (MySQL/MariaDB: 1-byte, 0 to 255).
*/
public function tinyIncrements(string $column): ColumnDefinition
{
@@ -738,7 +738,7 @@ public function tinyIncrements(string $column): ColumnDefinition
}
/**
- * Create a new auto-incrementing small integer column on the table (2-byte, 0 to 65,535).
+ * Create a new auto-incrementing small integer column on the table (MySQL/MariaDB: 2-byte, 0 to 65,535).
*/
public function smallIncrements(string $column): ColumnDefinition
{
@@ -746,7 +746,7 @@ public function smallIncrements(string $column): ColumnDefinition
}
/**
- * Create a new auto-incrementing medium integer column on the table (3-byte, 0 to 16,777,215).
+ * Create a new auto-incrementing medium integer column on the table (MySQL/MariaDB: 3-byte, 0 to 16,777,215).
*/
public function mediumIncrements(string $column): ColumnDefinition
{
@@ -754,7 +754,7 @@ public function mediumIncrements(string $column): ColumnDefinition
}
/**
- * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615).
+ * Create a new auto-incrementing big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615).
*/
public function bigIncrements(string $column): ColumnDefinition
{
@@ -782,7 +782,7 @@ public function string(string $column, ?int $length = null): ColumnDefinition
}
/**
- * Create a new tiny text column on the table (up to 255 characters).
+ * Create a new tiny text column on the table (up to 255 bytes on MySQL/MariaDB).
*/
public function tinyText(string $column): ColumnDefinition
{
@@ -790,7 +790,7 @@ public function tinyText(string $column): ColumnDefinition
}
/**
- * Create a new text column on the table (up to 65,535 characters / ~64 KB).
+ * Create a new text column on the table (up to 65,535 bytes on MySQL/MariaDB).
*/
public function text(string $column): ColumnDefinition
{
@@ -798,7 +798,7 @@ public function text(string $column): ColumnDefinition
}
/**
- * Create a new medium text column on the table (up to 16,777,215 characters / ~16 MB).
+ * Create a new medium text column on the table (up to 16,777,215 bytes on MySQL/MariaDB).
*/
public function mediumText(string $column): ColumnDefinition
{
@@ -806,7 +806,7 @@ public function mediumText(string $column): ColumnDefinition
}
/**
- * Create a new long text column on the table (up to 4,294,967,295 characters / ~4 GB).
+ * Create a new long text column on the table (up to 4,294,967,295 bytes on MySQL/MariaDB).
*/
public function longText(string $column): ColumnDefinition
{
@@ -814,8 +814,8 @@ public function longText(string $column): ColumnDefinition
}
/**
- * Create a new integer (4-byte) column on the table.
- * Range: -2,147,483,648 to 2,147,483,647 (signed) or 0 to 4,294,967,295 (unsigned).
+ * Create a new integer column on the table.
+ * MySQL/MariaDB (4-byte): -2,147,483,648 to 2,147,483,647 (signed) or 0 to 4,294,967,295 (unsigned).
*/
public function integer(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition
{
@@ -823,8 +823,8 @@ public function integer(string $column, bool $autoIncrement = false, bool $unsig
}
/**
- * Create a new tiny integer (1-byte) column on the table.
- * Range: -128 to 127 (signed) or 0 to 255 (unsigned).
+ * Create a new tiny integer column on the table.
+ * MySQL/MariaDB (1-byte): -128 to 127 (signed) or 0 to 255 (unsigned).
*/
public function tinyInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition
{
@@ -832,8 +832,8 @@ public function tinyInteger(string $column, bool $autoIncrement = false, bool $u
}
/**
- * Create a new small integer (2-byte) column on the table.
- * Range: -32,768 to 32,767 (signed) or 0 to 65,535 (unsigned).
+ * Create a new small integer column on the table.
+ * MySQL/MariaDB (2-byte): -32,768 to 32,767 (signed) or 0 to 65,535 (unsigned).
*/
public function smallInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition
{
@@ -841,8 +841,8 @@ public function smallInteger(string $column, bool $autoIncrement = false, bool $
}
/**
- * Create a new medium integer (3-byte) column on the table.
- * Range: -8,388,608 to 8,388,607 (signed) or 0 to 16,777,215 (unsigned).
+ * Create a new medium integer column on the table.
+ * MySQL/MariaDB (3-byte): -8,388,608 to 8,388,607 (signed) or 0 to 16,777,215 (unsigned).
*/
public function mediumInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition
{
@@ -850,8 +850,8 @@ public function mediumInteger(string $column, bool $autoIncrement = false, bool
}
/**
- * Create a new big integer (8-byte) column on the table.
- * Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (signed) or 0 to 18,446,744,073,709,551,615 (unsigned).
+ * Create a new big integer column on the table.
+ * MySQL/MariaDB (8-byte): -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (signed) or 0 to 18,446,744,073,709,551,615 (unsigned).
*/
public function bigInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition
{
@@ -859,7 +859,7 @@ public function bigInteger(string $column, bool $autoIncrement = false, bool $un
}
/**
- * Create a new unsigned integer column on the table (4-byte, 0 to 4,294,967,295).
+ * Create a new unsigned integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295).
*/
public function unsignedInteger(string $column, bool $autoIncrement = false): ColumnDefinition
{
@@ -867,7 +867,7 @@ public function unsignedInteger(string $column, bool $autoIncrement = false): Co
}
/**
- * Create a new unsigned tiny integer column on the table (1-byte, 0 to 255).
+ * Create a new unsigned tiny integer column on the table (MySQL/MariaDB: 1-byte, 0 to 255).
*/
public function unsignedTinyInteger(string $column, bool $autoIncrement = false): ColumnDefinition
{
@@ -875,7 +875,7 @@ public function unsignedTinyInteger(string $column, bool $autoIncrement = false)
}
/**
- * Create a new unsigned small integer column on the table (2-byte, 0 to 65,535).
+ * Create a new unsigned small integer column on the table (MySQL/MariaDB: 2-byte, 0 to 65,535).
*/
public function unsignedSmallInteger(string $column, bool $autoIncrement = false): ColumnDefinition
{
@@ -883,7 +883,7 @@ public function unsignedSmallInteger(string $column, bool $autoIncrement = false
}
/**
- * Create a new unsigned medium integer column on the table (3-byte, 0 to 16,777,215).
+ * Create a new unsigned medium integer column on the table (MySQL/MariaDB: 3-byte, 0 to 16,777,215).
*/
public function unsignedMediumInteger(string $column, bool $autoIncrement = false): ColumnDefinition
{
@@ -891,7 +891,7 @@ public function unsignedMediumInteger(string $column, bool $autoIncrement = fals
}
/**
- * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615).
+ * Create a new unsigned big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615).
*/
public function unsignedBigInteger(string $column, bool $autoIncrement = false): ColumnDefinition
{
@@ -899,7 +899,7 @@ public function unsignedBigInteger(string $column, bool $autoIncrement = false):
}
/**
- * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615).
+ * Create a new unsigned big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615).
*/
public function foreignId(string $column): ForeignIdColumnDefinition
{
diff --git a/src/di/LICENSE.md b/src/di/LICENSE.md
new file mode 100644
index 0000000000..63e1b7f542
--- /dev/null
+++ b/src/di/LICENSE.md
@@ -0,0 +1,23 @@
+The MIT License (MIT)
+
+Copyright (c) Hyperf
+
+Copyright (c) Hypervel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/src/di/README.md b/src/di/README.md
new file mode 100644
index 0000000000..43deb23195
--- /dev/null
+++ b/src/di/README.md
@@ -0,0 +1,6 @@
+DI for Hypervel
+===
+
+[](https://deepwiki.com/hypervel/di)
+
+Documentation: https://hypervel.org/docs/aop
diff --git a/src/docs/authentication.md b/src/docs/authentication.md
index 52281fcbe5..0d16b355e7 100644
--- a/src/docs/authentication.md
+++ b/src/docs/authentication.md
@@ -498,6 +498,16 @@ public function boot(): void
When the `guest` middleware names a guard and the request continues, that guard becomes the current default guard for the request. If multiple guards are listed, the first guard is selected.
+You may use the `RedirectIfAuthenticated` middleware's `using` method as an alternative to a middleware alias. For example, the following is equivalent to `guest:admin,web`:
+
+```php
+use Hypervel\Auth\Middleware\RedirectIfAuthenticated;
+use Hypervel\Support\Facades\Route;
+
+Route::get('/admin/login', fn () => view('auth.login'))
+ ->middleware(RedirectIfAuthenticated::using('admin', 'web'));
+```
+
#### Specifying a Guard
@@ -820,6 +830,8 @@ Route::middleware(['auth', 'auth.session'])->group(function () {
});
```
+Custom guards used with `auth.session` must provide a `hashPasswordForCookie` method that returns an HMAC of the password hash and use the same value when creating remember cookies. Extending `Hypervel\Auth\SessionGuard` provides this behavior.
+
Then, you may use the `logoutOtherDevices` method provided by the `Auth` facade. This method requires the user to confirm their current password, which your application should accept through an input form:
```php
diff --git a/src/docs/cache.md b/src/docs/cache.md
index 7d87226339..0ecf65d715 100644
--- a/src/docs/cache.md
+++ b/src/docs/cache.md
@@ -357,6 +357,16 @@ $value = Cache::get('key', function () {
});
```
+You may also use enums as cache keys. Backed enums use their values, while unit enums use their case names:
+
+```php
+use App\Enums\CacheKey;
+
+Cache::put(CacheKey::Visits, 10, 600);
+
+$visits = Cache::get(CacheKey::Visits);
+```
+
#### Determining Item Existence
@@ -839,6 +849,8 @@ Cache::lock('foo', 10)
});
```
+When using database locks, you may disable automatic pruning by setting your cache store's `lock_lottery` option to an empty array. You may then call `pruneExpiredLocks` on a database lock to remove expired locks explicitly.
+
### Managing Locks Across Processes
diff --git a/src/docs/database.md b/src/docs/database.md
index 53e4866572..5cdf1c8d79 100644
--- a/src/docs/database.md
+++ b/src/docs/database.md
@@ -2,6 +2,7 @@
- [Introduction](#introduction)
- [Configuration](#configuration)
+ - [Masking Bindings in Exception Messages](#masking-bindings-in-exception-messages)
- [Lock Timeouts](#lock-timeouts)
- [Read and Write Connections](#read-and-write-connections)
- [Connection Pooling](#connection-pooling)
@@ -54,6 +55,19 @@ By default, foreign key constraints are enabled for SQLite connections. If you w
DB_FOREIGN_KEYS=false
```
+
+#### Masking Bindings in Exception Messages
+
+By default, database exceptions include bound values in the SQL shown in their messages. To leave placeholders in that SQL instead, set the `DB_MASK_BINDINGS` environment variable:
+
+```ini
+DB_MASK_BINDINGS=true
+```
+
+For custom connections, set `mask_bindings_in_exception_messages` to `true` in the connection's configuration. Omitting this option or setting it to `null` disables masking.
+
+This option does not change the database's original error message, query logs, or query events. The exception's binding accessors remain available, and `getRawSql()` still returns SQL with the bindings included.
+
#### Lock Timeouts
diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md
index 76724bb739..24833e26df 100644
--- a/src/docs/eloquent-relationships.md
+++ b/src/docs/eloquent-relationships.md
@@ -880,8 +880,13 @@ If you would like your intermediate table to have `created_at` and `updated_at`
return $this->belongsToMany(Role::class)->withTimestamps();
```
-> [!WARNING]
-> Intermediate tables that utilize Eloquent's automatically maintained timestamps are required to have both `created_at` and `updated_at` timestamp columns.
+By default, the intermediate table must contain both timestamp columns. To use different column names, pass them to the `createdAt` and `updatedAt` arguments. You may pass `false` to either argument to disable that timestamp:
+
+```php
+return $this->belongsToMany(Role::class)->withTimestamps(updatedAt: false);
+```
+
+For custom pivot models, also override `getCreatedAtColumn` or `getUpdatedAtColumn` to return the renamed column or `null` for a disabled timestamp.
#### Customizing the `pivot` Attribute Name
diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md
index fa13b3edae..4ab568f769 100644
--- a/src/docs/eloquent-resources.md
+++ b/src/docs/eloquent-resources.md
@@ -1199,7 +1199,7 @@ JsonApiResource::maxRelationshipDepth(3);
### Resource Type and ID
-By default, the resource's `type` is derived from the resource class name. For example, `PostResource` produces the type `posts` and `BlogPostResource` produces `blog-posts`. The resource's `id` is resolved from the model's primary key.
+By default, the resource's `type` is derived from the resource class name. For example, `PostResource` produces the type `posts` and `BlogPostResource` produces `blog_posts`. The resource's `id` is resolved from the model's primary key.
If you need to customize these values, you may override the `toType` and `toId` methods on your resource:
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index 781cbe6c14..5215c35a55 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -509,7 +509,7 @@ If you would like to modify the host for URLs generated using the `Storage` faca
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
- 'url' => env('APP_URL').'/storage',
+ 'url' => rtrim((string) env('APP_URL'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
],
diff --git a/src/docs/http-client.md b/src/docs/http-client.md
index df115a61e2..8991740c94 100644
--- a/src/docs/http-client.md
+++ b/src/docs/http-client.md
@@ -66,6 +66,8 @@ $response->handlerStats() : array;
$response->toPsrResponse() : Psr\Http\Message\ResponseInterface;
```
+You may also use the response's [tap method](/docs/{{version}}/helpers#method-tap) to inspect it without interrupting a method chain.
+
The `Hypervel\Http\Client\Response` object also implements the PHP `ArrayAccess` interface, allowing you to access JSON response data directly on the response:
```php
@@ -1080,6 +1082,24 @@ Http::fake(function (Request $request) {
});
```
+
+#### Request Attributes
+
+To distinguish requests sent to the same URL, you may attach attributes using the `withAttributes` method. These attributes are available to fake callbacks and request assertions through the request's `attributes` method and are not sent to the remote server:
+
+```php
+use Hypervel\Http\Client\Request;
+use Hypervel\Support\Facades\Http;
+
+Http::fake(fn (Request $request) => match ($request->attributes()['name'] ?? null) {
+ 'products' => Http::response(['products' => []]),
+ default => Http::response(),
+});
+
+$response = Http::withAttributes(['name' => 'products'])
+ ->get('https://example.com/graphql');
+```
+
### Inspecting Requests
diff --git a/src/docs/localization.md b/src/docs/localization.md
index f61c3270d5..d5a0740c0a 100644
--- a/src/docs/localization.md
+++ b/src/docs/localization.md
@@ -233,10 +233,12 @@ If your placeholder contains all capital letters, or only has its first letter c
'goodbye' => 'Goodbye, :Name', // Goodbye, Dayle
```
+You may also pass enum cases as replacements. Backed enums use their value and pure enums use their case name, unless you register a custom formatting handler for the enum as described below.
+
#### Object Replacement Formatting
-If you attempt to provide an object as a translation placeholder, the object's `__toString` method will be invoked. The [__toString](https://www.php.net/manual/en/language.oop5.magic.php#object.tostring) method is one of PHP's built-in "magic methods". However, sometimes you may not have control over the `__toString` method of a given class, such as when the class that you are interacting with belongs to a third-party library.
+If you attempt to provide any other object as a translation placeholder, the object's `__toString` method will be invoked. The [__toString](https://www.php.net/manual/en/language.oop5.magic.php#object.tostring) method is one of PHP's built-in "magic methods". However, sometimes you may not have control over the `__toString` method of a given class, such as when the class that you are interacting with belongs to a third-party library.
In these cases, Hypervel allows you to register a custom formatting handler for that particular type of object. To accomplish this, you should invoke the translator's `stringable` method. The `stringable` method accepts a closure, which should type-hint the type of object that it is responsible for formatting. Typically, the `stringable` method should be invoked within the `boot` method of your application's `AppServiceProvider` class:
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index 2dc4397875..5868315a1e 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -603,6 +603,8 @@ Custom cache tag sets must declare `TagSet::reset(): bool` and `TagSet::flush():
### Sessions
+Custom guards used with `auth.session` must provide `hashPasswordForCookie()`; Hypervel does not fall back to raw password hashes when the method is missing. Guards extending `SessionGuard` already support it. See [session authentication](/docs/{{version}}/authentication#invalidating-sessions-on-other-devices).
+
Hypervel's persistent application session drivers are `file`, `cookie`, `database`, and `redis`. The non-persistent `array` and `null` drivers are available for testing. Redis sessions are stored directly in Redis and may select a named Redis connection using `SESSION_CONNECTION`.
Laravel's Memcached, APC / APCu, DynamoDB, and generic cache-backed session configurations do not port. Hypervel does not provide Laravel's cache session handler or `SESSION_STORE` setting. Select one of Hypervel's session drivers and review its requirements in the [session documentation](/docs/{{version}}/session).
diff --git a/src/docs/processes.md b/src/docs/processes.md
index d04b5afcb3..5b8ea14448 100644
--- a/src/docs/processes.md
+++ b/src/docs/processes.md
@@ -766,6 +766,14 @@ use Hypervel\Support\Facades\Process;
Process::assertRan('ls -la');
```
+When the process was invoked with an array of arguments, you may pass the same array to the assertion:
+
+```php
+Process::assertRan(['php', 'artisan', 'migrate']);
+```
+
+The `assertRanTimes` and `assertDidntRun` methods also accept array commands.
+
The `assertRan` method also accepts a closure, which will receive an instance of a process and a process result, allowing you to inspect the process' configured options. If this closure returns `true`, the assertion will "pass":
```php
@@ -818,6 +826,20 @@ Process::assertRanTimes(function (PendingProcess $process, ProcessResult $result
}, times: 3);
```
+
+#### assertRanInOrder
+
+Assert that processes were invoked in a given order:
+
+```php
+Process::assertRanInOrder([
+ 'git fetch',
+ 'composer install',
+]);
+```
+
+The `assertRanInOrder` method accepts command strings, arrays of command arguments, or closures like the other process assertions.
+
#### assertNothingRan
diff --git a/src/docs/redis.md b/src/docs/redis.md
index 48eb3a8bc7..8bcf0181c7 100644
--- a/src/docs/redis.md
+++ b/src/docs/redis.md
@@ -404,6 +404,24 @@ Redis::disableEvents();
These methods are intended for application boot. If a pool was created earlier in the same startup lifecycle with the other setting, Hypervel replaces that pool generation on its next use. Matching pools are left untouched. Connections already checked out from a replaced generation may finish their current work and are destroyed when returned.
+To listen for failed commands, register a callback using the `Redis` facade's `listenForFailures` method in the `boot` method of a service provider:
+
+```php
+use Hypervel\Redis\Events\CommandFailed;
+use Hypervel\Support\Facades\Log;
+use Hypervel\Support\Facades\Redis;
+
+Redis::listenForFailures(function (CommandFailed $event): void {
+ Log::error('Redis command failed.', [
+ 'connection' => $event->connectionName,
+ 'command' => $event->command,
+ 'exception' => $event->exception,
+ ]);
+});
+```
+
+Listening for a failure does not suppress the command's exception. You may also use `Redis::listen` to register a callback that receives a `Hypervel\Redis\Events\CommandExecuted` event after each successful command.
+
#### Holding a Pooled Connection
diff --git a/src/docs/requests.md b/src/docs/requests.md
index d3280a7ff1..85281043d4 100644
--- a/src/docs/requests.md
+++ b/src/docs/requests.md
@@ -471,6 +471,8 @@ You may also pass an array of keys to build the instance from only those input v
$user = $request->fluent(['name', 'role']);
```
+Fluent instances also provide the input retrieval methods described on this page, such as `integer`, `boolean`, `date`, and `enum`.
+
#### Retrieving Date Input Values
diff --git a/src/docs/session.md b/src/docs/session.md
index fbffced254..0bd82c3c79 100644
--- a/src/docs/session.md
+++ b/src/docs/session.md
@@ -133,7 +133,7 @@ $value = $request->session()->get('key', function () {
#### The Global Session Helper
-You may also use the global `session` PHP function to retrieve and store data in the session. When the `session` helper is called with a single, string argument, it will return the value of that session key. When the helper is called with an array of key / value pairs, those values will be stored in the session:
+You may also use the global `session` PHP function to retrieve and store data in the session. When the `session` helper is called with a single string or enum argument, it will return the value of that session key. When the helper is called with an array of key / value pairs, those values will be stored in the session:
```php
Route::get('/home', function () {
@@ -151,6 +151,24 @@ Route::get('/home', function () {
> [!NOTE]
> There is little practical difference between using the session via an HTTP request instance versus using the global `session` helper. Both methods are [testable](/docs/{{version}}/testing) via the `assertSessionHas` method which is available in all of your test cases.
+
+#### Enum Session Keys
+
+You may use enums as session keys. Backed enums use their value as the key, while unbacked enums use their case name:
+
+```php
+enum SessionKey: string
+{
+ case Cart = 'cart';
+}
+
+session()->put(SessionKey::Cart, $items);
+
+$items = session(SessionKey::Cart);
+
+session()->forget(SessionKey::Cart);
+```
+
#### Retrieving All Session Data
diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php
index 3acbb36bce..7336b3f9cc 100644
--- a/src/foundation/config/database.php
+++ b/src/foundation/config/database.php
@@ -51,6 +51,7 @@
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'prefix_indexes' => null,
+ 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false),
'foreign_key_constraints' => (bool) env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
@@ -72,6 +73,7 @@
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => env('DB_PREFIX', ''),
'prefix_indexes' => true,
+ 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false),
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
@@ -103,6 +105,7 @@
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => env('DB_PREFIX', ''),
'prefix_indexes' => true,
+ 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false),
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
@@ -132,6 +135,7 @@
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => env('DB_PREFIX', ''),
'prefix_indexes' => true,
+ 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false),
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
'options' => [
@@ -161,6 +165,7 @@
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => env('DB_PREFIX', ''),
'prefix_indexes' => true,
+ 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false),
'search_path' => 'public',
'sslmode' => env('DB_POOLED_SSLMODE', env('DB_SSLMODE', 'prefer')),
'options' => [
diff --git a/src/foundation/config/filesystems.php b/src/foundation/config/filesystems.php
index 563f5e503e..f4f4cdf94a 100644
--- a/src/foundation/config/filesystems.php
+++ b/src/foundation/config/filesystems.php
@@ -45,7 +45,7 @@
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
- 'url' => env('APP_URL') . '/storage',
+ 'url' => rtrim((string) env('APP_URL'), '/') . '/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
diff --git a/src/foundation/src/Exceptions/Renderer/Exception.php b/src/foundation/src/Exceptions/Renderer/Exception.php
index 1209eb5218..0d76a7a673 100644
--- a/src/foundation/src/Exceptions/Renderer/Exception.php
+++ b/src/foundation/src/Exceptions/Renderer/Exception.php
@@ -10,6 +10,7 @@
use Hypervel\Foundation\Bootstrap\HandleExceptions;
use Hypervel\Http\Request;
use Hypervel\Support\Collection;
+use Hypervel\Support\Str;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
class Exception
@@ -226,25 +227,22 @@ public function applicationRouteParametersContext(): ?string
/**
* Get the application's SQL queries.
*
- * @return array
+ * @return array
*/
public function applicationQueries(): array
{
- return array_map(function (array $query) {
- $sql = $query['sql'];
-
- foreach ($query['bindings'] as $binding) {
- $sql = match (gettype($binding)) {
- 'integer', 'double' => preg_replace('/\?/', (string) $binding, $sql, 1),
- 'NULL' => preg_replace('/\?/', 'NULL', $sql, 1),
- default => preg_replace('/\?/', "'{$binding}'", $sql, 1),
- };
- }
-
+ return array_map(function (array $query): array {
+ $bindings = array_map(static fn (mixed $binding): string => match (gettype($binding)) {
+ 'integer', 'double' => (string) $binding,
+ 'NULL' => 'NULL',
+ default => "'{$binding}'",
+ }, $query['bindings']);
+
+ // Fill original placeholders so question marks inside values are not replaced again.
return [
'connectionName' => $query['connectionName'],
'time' => $query['time'],
- 'sql' => $sql,
+ 'sql' => Str::replaceArray('?', $bindings, $query['sql']),
];
}, $this->listener->queries());
}
diff --git a/src/foundation/src/Exceptions/Renderer/Listener.php b/src/foundation/src/Exceptions/Renderer/Listener.php
index bb8ba99f84..6f717f2e0e 100644
--- a/src/foundation/src/Exceptions/Renderer/Listener.php
+++ b/src/foundation/src/Exceptions/Renderer/Listener.php
@@ -31,7 +31,7 @@ public function registerListeners(Dispatcher $events): void
/**
* Return the queries that have been executed.
*
- * @return array
+ * @return array
*/
public function queries(): array
{
diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php
index a1e2fcb572..e011e64797 100644
--- a/src/foundation/src/helpers.php
+++ b/src/foundation/src/helpers.php
@@ -862,9 +862,9 @@ function secure_url(string $path, mixed $parameters = []): string
*
* If an array is passed as the key, we will assume you want to set an array of values.
*
- * @return ($key is null ? SessionManager : ($key is string ? mixed : null))
+ * @return ($key is null ? SessionManager : ($key is array ? null : mixed))
*/
- function session(array|string|null $key = null, mixed $default = null): mixed
+ function session(array|UnitEnum|string|null $key = null, mixed $default = null): mixed
{
$session = app('session');
diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php
index 776f3de05a..5a786540c6 100644
--- a/src/http/src/Client/Factory.php
+++ b/src/http/src/Client/Factory.php
@@ -543,6 +543,8 @@ public function recorded(?callable $callback = null): Collection
/**
* Create a new pending request instance for this factory.
+ *
+ * @return PendingRequest
*/
public function createPendingRequest(): PendingRequest
{
@@ -556,6 +558,8 @@ public function createPendingRequest(): PendingRequest
/**
* Instantiate a new pending request instance for this factory.
+ *
+ * @return PendingRequest
*/
protected function newPendingRequest(): PendingRequest
{
@@ -565,7 +569,10 @@ protected function newPendingRequest(): PendingRequest
throw new InvalidArgumentException('The global HTTP client options callback must return an array.');
}
- return new PendingRequest($this, $this->globalMiddleware, $options);
+ /** @var PendingRequest $request */
+ $request = new PendingRequest($this, $this->globalMiddleware, $options);
+
+ return $request;
}
/**
diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php
index 6ee896ad0d..2381a60140 100644
--- a/src/http/src/Client/PendingRequest.php
+++ b/src/http/src/Client/PendingRequest.php
@@ -41,6 +41,9 @@
use Throwable;
use UnitEnum;
+/**
+ * @template TAsync of bool = bool
+ */
class PendingRequest implements Transient
{
use Conditionable;
@@ -155,6 +158,8 @@ class PendingRequest implements Transient
/**
* The callbacks that should execute after the response is built.
+ *
+ * @var Collection
*/
protected Collection $afterResponseCallbacks;
@@ -180,6 +185,8 @@ class PendingRequest implements Transient
/**
* Whether the requests should be asynchronous.
+ *
+ * @var TAsync
*/
protected bool $async = false;
@@ -690,7 +697,7 @@ public function beforeSending(callable $callback): static
/**
* Add a new callback to execute after the response is built.
*
- * @param callable(Response, null|Request): (null|Response) $callback
+ * @param callable(Response, null|Request): mixed $callback
*/
public function afterResponse(callable $callback): static
{
@@ -772,6 +779,8 @@ public function dd(): static
/**
* Issue a GET request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws ConnectionException
* @throws InvalidArgumentException
*/
@@ -789,6 +798,8 @@ public function get(string $url, Arrayable|array|JsonSerializable|string|null $q
/**
* Issue a HEAD request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws ConnectionException
* @throws InvalidArgumentException
*/
@@ -806,6 +817,8 @@ public function head(string $url, Arrayable|array|JsonSerializable|string|null $
/**
* Issue a QUERY request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws ConnectionException
* @throws InvalidArgumentException
*/
@@ -819,6 +832,8 @@ public function query(string $url, Arrayable|array|JsonSerializable $data = []):
/**
* Issue a POST request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws ConnectionException
* @throws InvalidArgumentException
*/
@@ -832,6 +847,8 @@ public function post(string $url, Arrayable|array|JsonSerializable $data = []):
/**
* Issue a PATCH request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws ConnectionException
* @throws InvalidArgumentException
*/
@@ -845,6 +862,8 @@ public function patch(string $url, Arrayable|array|JsonSerializable $data = []):
/**
* Issue a PUT request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws ConnectionException
* @throws InvalidArgumentException
*/
@@ -858,6 +877,8 @@ public function put(string $url, Arrayable|array|JsonSerializable $data = []): P
/**
* Issue a DELETE request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws ConnectionException
* @throws InvalidArgumentException
*/
@@ -880,6 +901,8 @@ public function delete(string $url, Arrayable|array|JsonSerializable $data = [])
/**
* Send the request to the given URL.
*
+ * @phpstan-return (TAsync is false ? Response : PromiseInterface)
+ *
* @throws Exception
* @throws ConnectionException|Throwable
* @throws InvalidArgumentException
@@ -1918,11 +1941,19 @@ public function isAllowedRequestUrl(string $url): bool
/**
* Toggle asynchronicity in requests.
+ *
+ * @template T of bool = true
+ *
+ * @param T $async
+ * @return static
+ *
+ * @phpstan-self-out static
*/
public function async(bool $async = true): static
{
$this->async = $async;
+ // @phpstan-ignore return.type (The fluent setter returns the same receiver with its new generic state.)
return $this;
}
diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php
index ecdce17a85..3570568bb8 100644
--- a/src/log/src/LogManager.php
+++ b/src/log/src/LogManager.php
@@ -263,13 +263,13 @@ protected function createStackDriver(array $config): LoggerInterface
$config['channels'] = explode(',', $config['channels']);
}
- $handlers = Collection::make($config['channels'])->flatMap(function ($channel) {
+ $handlers = (new Collection($config['channels']))->flatMap(function ($channel) {
return $channel instanceof LoggerInterface
? $channel->getHandlers() // @phpstan-ignore-line
: $this->channel($channel)->getHandlers(); // @phpstan-ignore-line
})->all();
- $processors = Collection::make($config['channels'])->flatMap(function ($channel) {
+ $processors = (new Collection($config['channels']))->flatMap(function ($channel) {
return $channel instanceof LoggerInterface
? $channel->getProcessors() // @phpstan-ignore-line
: $this->channel($channel)->getProcessors(); // @phpstan-ignore-line
@@ -382,7 +382,7 @@ protected function createMonologDriver(array $config): LoggerInterface
);
}
- Collection::make($config['processors'] ?? [])->each(function ($processor) {
+ (new Collection($config['processors'] ?? []))->each(function ($processor) {
$processor = $processor['processor'] ?? $processor;
if (! is_a($processor, ProcessorInterface::class, true)) {
@@ -409,7 +409,7 @@ protected function createMonologDriver(array $config): LoggerInterface
$config
);
- $processors = Collection::make($config['processors'] ?? [])
+ $processors = (new Collection($config['processors'] ?? []))
->map(function ($processor) {
$resolved = $this->app->make(
$processor['processor'] ?? $processor,
diff --git a/src/macroable/README.md b/src/macroable/README.md
new file mode 100644
index 0000000000..60b8bca7b6
--- /dev/null
+++ b/src/macroable/README.md
@@ -0,0 +1,6 @@
+Macroable for Hypervel
+===
+
+[](https://deepwiki.com/hypervel/macroable)
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Macroable
diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php
index 5ac77245a9..d67c72ac18 100644
--- a/src/mail/src/Mailable.php
+++ b/src/mail/src/Mailable.php
@@ -283,6 +283,8 @@ public function render(): string
/**
* Build the view for the message.
*
+ * @return array|string
+ *
* @throws ReflectionException
*/
protected function buildView(): array|string
@@ -1423,7 +1425,7 @@ protected function renderForAssertions(): array
return $this->assertionableRenderStrings;
}
- return $this->assertionableRenderStrings = $this->withLocale($this->locale, function () {
+ return $this->assertionableRenderStrings = $this->withLocale($this->locale, function (): array {
$this->prepareMailableForDelivery();
/** @var \Hypervel\Mail\Mailer $mailer */
@@ -1448,7 +1450,8 @@ protected function renderForAssertions(): array
);
}
- return [(string) $html, (string) $text];
+ // Match the mailer's conversion for Htmlable views, which need not be stringable.
+ return [(string) $html, $text instanceof Htmlable ? $text->toHtml() : (string) $text];
});
}
diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php
index 546403c4b2..1f58d24266 100644
--- a/src/mail/src/Mailer.php
+++ b/src/mail/src/Mailer.php
@@ -190,7 +190,7 @@ public function render(array|Closure|string $view, array $data = []): string
// First we need to parse the view, which could either be a string or an array
// containing both an HTML and plain text versions of the view which should
// be used when sending an e-mail. We will extract both of them out here.
- [$view, $plain, $raw] = $this->parseView($view);
+ [$view, $plain] = $this->parseView($view);
$data['message'] = $this->createMessage();
diff --git a/src/mail/src/Transport/SesV2Transport.php b/src/mail/src/Transport/SesV2Transport.php
index 5c605d8a8c..95c9cced8d 100644
--- a/src/mail/src/Transport/SesV2Transport.php
+++ b/src/mail/src/Transport/SesV2Transport.php
@@ -6,6 +6,7 @@
use Aws\Exception\AwsException;
use Aws\SesV2\SesV2Client;
+use Hypervel\Support\Collection;
use Stringable;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\Header\MetadataHeader;
@@ -26,6 +27,8 @@ public function __construct(
}
/**
+ * Send the given message.
+ *
* @throws TransportException
*/
protected function doSend(SentMessage $message): void
@@ -55,7 +58,7 @@ protected function doSend(SentMessage $message): void
[
'Source' => $message->getEnvelope()->getSender()->toString(),
'Destination' => [
- 'ToAddresses' => collect($message->getEnvelope()->getRecipients())
+ 'ToAddresses' => (new Collection($message->getEnvelope()->getRecipients()))
->map
->toString()
->values() // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->toString() returns Collection, not string)
diff --git a/src/process/src/Factory.php b/src/process/src/Factory.php
index 33dce94e86..96fb8501eb 100644
--- a/src/process/src/Factory.php
+++ b/src/process/src/Factory.php
@@ -157,15 +157,17 @@ public function preventingStrayProcesses(): bool
/**
* Assert that a process was recorded matching a given truth test.
+ *
+ * @param array|Closure|string $callback
*/
- public function assertRan(Closure|string $callback): static
+ public function assertRan(Closure|array|string $callback): static
{
- $callback = is_string($callback) ? fn ($process) => $process->command === $callback : $callback;
+ $callback = $callback instanceof Closure ? $callback : fn ($process) => $process->command === $callback;
PHPUnit::assertTrue(
- (new Collection($this->recorded))->filter(function ($pair) use ($callback) {
+ (new Collection($this->recorded))->contains(function ($pair) use ($callback) {
return $callback($pair[0], $pair[1]);
- })->count() > 0,
+ }),
'An expected process was not invoked.'
);
@@ -174,10 +176,12 @@ public function assertRan(Closure|string $callback): static
/**
* Assert that a process was recorded a given number of times matching a given truth test.
+ *
+ * @param array|Closure|string $callback
*/
- public function assertRanTimes(Closure|string $callback, int $times = 1): static
+ public function assertRanTimes(Closure|array|string $callback, int $times = 1): static
{
- $callback = is_string($callback) ? fn ($process) => $process->command === $callback : $callback;
+ $callback = $callback instanceof Closure ? $callback : fn ($process) => $process->command === $callback;
$count = (new Collection($this->recorded))
->filter(fn ($pair) => $callback($pair[0], $pair[1]))
@@ -192,17 +196,52 @@ public function assertRanTimes(Closure|string $callback, int $times = 1): static
return $this;
}
+ /**
+ * Assert that the given processes were run in the given order.
+ *
+ * @param list|Closure|string> $callbacks
+ */
+ public function assertRanInOrder(array $callbacks): static
+ {
+ $this->assertRanCount(count($callbacks));
+
+ foreach ($callbacks as $index => $callback) {
+ $callback = $callback instanceof Closure
+ ? $callback
+ : fn ($process) => $process->command === $callback;
+
+ PHPUnit::assertTrue(
+ $callback($this->recorded[$index][0], $this->recorded[$index][1]),
+ 'An expected process (#' . ($index + 1) . ') was not invoked.'
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Assert how many processes have been recorded.
+ */
+ protected function assertRanCount(int $count): static
+ {
+ PHPUnit::assertCount($count, $this->recorded);
+
+ return $this;
+ }
+
/**
* Assert that a process was not recorded matching a given truth test.
+ *
+ * @param array|Closure|string $callback
*/
- public function assertNotRan(Closure|string $callback): static
+ public function assertNotRan(Closure|array|string $callback): static
{
- $callback = is_string($callback) ? fn ($process) => $process->command === $callback : $callback;
+ $callback = $callback instanceof Closure ? $callback : fn ($process) => $process->command === $callback;
PHPUnit::assertTrue(
- (new Collection($this->recorded))->filter(function ($pair) use ($callback) {
+ (new Collection($this->recorded))->doesntContain(function ($pair) use ($callback) {
return $callback($pair[0], $pair[1]);
- })->count() === 0,
+ }),
'An unexpected process was invoked.'
);
@@ -211,8 +250,10 @@ public function assertNotRan(Closure|string $callback): static
/**
* Assert that a process was not recorded matching a given truth test.
+ *
+ * @param array|Closure|string $callback
*/
- public function assertDidntRun(Closure|string $callback): static
+ public function assertDidntRun(Closure|array|string $callback): static
{
return $this->assertNotRan($callback);
}
diff --git a/src/queue/src/Events/QueueFailedOver.php b/src/queue/src/Events/QueueFailedOver.php
index 83197ed841..cb4eae26c4 100644
--- a/src/queue/src/Events/QueueFailedOver.php
+++ b/src/queue/src/Events/QueueFailedOver.php
@@ -10,10 +10,13 @@ class QueueFailedOver
{
/**
* Create a new event instance.
+ *
+ * @param null|string $connectionName the queue connection that failed
+ * @param object|string $command the job instance
*/
public function __construct(
public ?string $connectionName,
- public mixed $command,
+ public object|string $command,
public Throwable $exception,
) {
}
diff --git a/src/queue/src/Jobs/Job.php b/src/queue/src/Jobs/Job.php
index ab543d44e8..e0fbcd9405 100644
--- a/src/queue/src/Jobs/Job.php
+++ b/src/queue/src/Jobs/Job.php
@@ -332,7 +332,7 @@ protected function failed(?Throwable $e): void
{
$payload = $this->payload();
- [$class, $method] = JobName::parse($payload['job']);
+ [$class] = JobName::parse($payload['job']);
if (method_exists($this->instance = $this->resolve($class), 'failed')) {
$this->instance->failed($payload['data'], $e, $payload['uuid'] ?? '', $this);
diff --git a/src/reflection/README.md b/src/reflection/README.md
new file mode 100644
index 0000000000..9d0e899938
--- /dev/null
+++ b/src/reflection/README.md
@@ -0,0 +1,6 @@
+Reflection for Hypervel
+===
+
+[](https://deepwiki.com/hypervel/reflection)
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Reflection
diff --git a/src/saloon/src/Http/Sender.php b/src/saloon/src/Http/Sender.php
index fdbf4cd0dc..8fd295da5e 100644
--- a/src/saloon/src/Http/Sender.php
+++ b/src/saloon/src/Http/Sender.php
@@ -7,7 +7,6 @@
use Hypervel\Contracts\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Telescope\TelescopeTag;
use Hypervel\Http\Client\Factory;
-use Hypervel\Http\Client\Response as HttpResponse;
use Psr\Http\Message\RequestInterface;
class Sender
@@ -74,7 +73,6 @@ public function send(PendingRequest $pendingRequest, array $transport): Response
return $request;
});
- /** @var HttpResponse $httpResponse */
$httpResponse = $httpRequest->send(
$pendingRequest->method()->value,
(string) $pendingRequest->uri(),
diff --git a/src/session/README.md b/src/session/README.md
index 62b859c3e7..1163d6cf07 100644
--- a/src/session/README.md
+++ b/src/session/README.md
@@ -8,7 +8,7 @@ Documentation: https://hypervel.org/docs/session
## Differences From Laravel
- `Store::passwordConfirmed(?string $guard = null)` stamps a guard-scoped key (`auth.password_confirmed_at_{guard}`) instead of Laravel's single shared key, resolving the current guard when none is given.
-- Password-hash session artifacts are HMAC-only. Laravel's raw-hash fallback for legacy sessions is intentionally omitted because Hypervel 0.4 has no released legacy sessions.
+- Password-hash session artifacts are HMAC-only. Laravel's raw-hash fallbacks for legacy sessions and guards without `hashPasswordForCookie()` are intentionally omitted. Custom guards used with `auth.session` must provide that method; extending `SessionGuard` supplies it.
- Hypervel's Redis session driver persists directly through Redis instead of Laravel's shared cache-backed handler. Laravel's APC, Memcached, DynamoDB, and shared cache-wrapper session drivers are not provided.
- Hypervel's generated sessions table uses a nullable indexed string for `user_id`, supporting integer, UUID, ULID, and application-defined identifiers, together with a nullable `auth_provider` for provider-qualified ownership. Its `ip_address` uses the semantic IP column type, including PostgreSQL's native `inet` type.
- `DatabaseSessionHandler::getDefaultPayload()` receives the session ID before the serialized data. Laravel's scalar `addUserInformation()` and `userId()` hooks are not provided because Hypervel stores the authentication provider and user ID as one ownership value.
diff --git a/src/session/src/Middleware/AuthenticateSession.php b/src/session/src/Middleware/AuthenticateSession.php
index 007de05074..29fd6b072c 100644
--- a/src/session/src/Middleware/AuthenticateSession.php
+++ b/src/session/src/Middleware/AuthenticateSession.php
@@ -7,6 +7,7 @@
use Closure;
use Hypervel\Auth\AuthenticationException;
use Hypervel\Contracts\Auth\Factory as AuthFactory;
+use Hypervel\Contracts\Auth\Guard;
use Hypervel\Contracts\Session\Middleware\AuthenticatesSessions;
use Hypervel\Http\Request;
@@ -83,8 +84,9 @@ protected function storePasswordHashInSession(Request $request): void
/**
* Validate the password hash against the stored value.
*
- * Only HMAC artifacts are valid; Hypervel has no released raw-hash
- * session artifacts to accept.
+ * Only HMAC artifacts are valid. Custom guards must provide
+ * hashPasswordForCookie(); the raw-hash and missing-method fallbacks
+ * are intentionally omitted.
*/
protected function validatePasswordHash(string $passwordHash, mixed $storedValue): bool
{
@@ -113,7 +115,7 @@ protected function logout(Request $request): void
/**
* Get the guard instance that should be used by the middleware.
*/
- protected function guard(): AuthFactory
+ protected function guard(): AuthFactory|Guard
{
return $this->auth;
}
diff --git a/src/session/src/Store.php b/src/session/src/Store.php
index adadb4c2a1..c512eae974 100644
--- a/src/session/src/Store.php
+++ b/src/session/src/Store.php
@@ -567,7 +567,9 @@ public function remove(UnitEnum|string $key): mixed
public function forget(array|UnitEnum|string $keys): void
{
$attributes = $this->getAttributes();
- Arr::forget($attributes, collect((array) $keys)->map(fn ($key) => enum_value($key))->all());
+
+ // Casting an enum to an array would make its name and value separate keys to remove.
+ Arr::forget($attributes, array_map(enum_value(...), Arr::wrap($keys)));
$this->setAttributes($attributes);
}
diff --git a/src/support/src/Facades/Auth.php b/src/support/src/Facades/Auth.php
index b44caa7f4c..86ed4bf088 100644
--- a/src/support/src/Facades/Auth.php
+++ b/src/support/src/Facades/Auth.php
@@ -5,6 +5,7 @@
namespace Hypervel\Support\Facades;
use Hypervel\Contracts\Auth\StatefulGuard;
+use Hypervel\Contracts\Auth\SupportsBasicAuth;
/**
* @method static void clearUserCache(mixed $identifier, \UnitEnum|string|null $guard = null)
@@ -30,19 +31,45 @@
* @method static void shouldUse(\UnitEnum|string|null $name)
* @method static \Closure userResolver()
* @method static \Hypervel\Auth\AuthManager viaRequest(string $driver, callable $callback)
+ * @method static void attempting(callable $callback)
+ * @method static bool attemptWhen(array $credentials = [], callable|array|null $callbacks = null, bool $remember = false)
+ * @method static \Hypervel\Contracts\Auth\Authenticatable authenticate()
+ * @method static void flushMacros()
+ * @method static void flushState()
+ * @method static \Hypervel\Auth\SessionGuard forgetUser()
+ * @method static \Hypervel\Contracts\Cookie\QueueingFactory getCookieJar()
+ * @method static \Hypervel\Contracts\Events\Dispatcher|null getDispatcher()
+ * @method static \Hypervel\Contracts\Auth\Authenticatable|null getLastAttempted()
+ * @method static string getName()
+ * @method static \Hypervel\Contracts\Auth\UserProvider|null getProvider()
+ * @method static string getRecallerName()
+ * @method static \Symfony\Component\HttpFoundation\Request getRequest()
+ * @method static \Hypervel\Contracts\Session\Session getSession()
+ * @method static \Hypervel\Support\Timebox getTimebox()
+ * @method static \Hypervel\Contracts\Auth\Authenticatable|null getUser()
+ * @method static string hashPasswordForCookie(string|null $passwordHash)
+ * @method static bool hasMacro(string $name)
+ * @method static void logoutCurrentDevice()
+ * @method static \Hypervel\Contracts\Auth\Authenticatable|null logoutOtherDevices(string $password)
+ * @method static void macro(string $name, callable|object $macro)
+ * @method static void mixin(object $mixin, bool $replace = true)
+ * @method static void setCookieJar(\Hypervel\Contracts\Cookie\QueueingFactory $cookie)
+ * @method static void setDispatcher(\Hypervel\Contracts\Events\Dispatcher $events)
+ * @method static void setProvider(\Hypervel\Contracts\Auth\UserProvider $provider)
+ * @method static \Hypervel\Auth\SessionGuard setRememberDuration(int $minutes)
*
* @see \Hypervel\Auth\AuthManager
- * @see \Hypervel\Contracts\Auth\Guard
- * @see \Hypervel\Contracts\Auth\StatefulGuard
+ * @see \Hypervel\Auth\SessionGuard
*
* @mixin \Hypervel\Contracts\Auth\StatefulGuard
+ * @mixin \Hypervel\Contracts\Auth\SupportsBasicAuth
*/
class Auth extends Facade
{
/**
* Get methods that should be excluded from the generated facade docblock.
*
- * The guard surface comes from the mixin because @method tags cannot carry
+ * The guard contracts come from mixins because @method tags cannot carry
* the contracts' @phpstan-impure metadata.
*
* The documenter excludes by name, so review this hook if AuthManager gains
@@ -52,7 +79,10 @@ class Auth extends Facade
*/
protected static function ignoredFacadeDocumenterMethods(): array
{
- return get_class_methods(StatefulGuard::class);
+ return [
+ ...get_class_methods(StatefulGuard::class),
+ ...get_class_methods(SupportsBasicAuth::class),
+ ];
}
/**
diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php
index c6875b5d61..dab43262dc 100644
--- a/src/support/src/Facades/Http.php
+++ b/src/support/src/Facades/Http.php
@@ -18,7 +18,7 @@
* @method static void assertSentInOrder(array $callbacks)
* @method static void assertSequencesAreEmpty()
* @method static \GuzzleHttp\ClientInterface createClient(\GuzzleHttp\HandlerStack $handlerStack, \GuzzleHttp\Cookie\CookieJar $cookies)
- * @method static \Hypervel\Http\Client\PendingRequest createPendingRequest()
+ * @method static \Hypervel\Http\Client\PendingRequest createPendingRequest()
* @method static \Closure failedConnection(string|null $message = null)
* @method static \Hypervel\Http\Client\RequestException failedRequest(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = [])
* @method static void flushMacros()
@@ -54,7 +54,7 @@
* @method static \Hypervel\Http\Client\PendingRequest asForm()
* @method static \Hypervel\Http\Client\PendingRequest asJson()
* @method static \Hypervel\Http\Client\PendingRequest asMultipart()
- * @method static \Hypervel\Http\Client\PendingRequest async(bool $async = true)
+ * @method static \Hypervel\Http\Client\PendingRequest async(bool $async = true)
* @method static \Hypervel\Http\Client\PendingRequest attach(array|string $name, resource|string $contents = '', string|null $filename = null, array $headers = [])
* @method static array attributes()
* @method static \Hypervel\Http\Client\PendingRequest baseUrl(string $url)
@@ -69,27 +69,27 @@
* @method static \Hypervel\Http\Client\PendingRequest connectTimeout(int|float $seconds)
* @method static \Hypervel\Http\Client\PendingRequest contentType(string $contentType)
* @method static \Hypervel\Http\Client\PendingRequest dd()
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response delete(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface delete(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
* @method static \Hypervel\Http\Client\PendingRequest dontTruncateExceptions()
* @method static \Hypervel\Http\Client\PendingRequest dump()
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response get(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null)
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface get(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null)
* @method static string|null getConnection()
* @method static array getOptions()
* @method static \GuzzleHttp\Promise\PromiseInterface|null getPromise()
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response head(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null)
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface head(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null)
* @method static bool isAllowedRequestUrl(string $url)
* @method static \Hypervel\Http\Client\PendingRequest maxRedirects(int $max)
* @method static array mergeOptions(mixed ...$options)
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response patch(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response post(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface patch(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface post(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
* @method static \Hypervel\Http\Client\PendingRequest prependMiddleware(callable $middleware)
* @method static \GuzzleHttp\HandlerStack pushHandlers(\GuzzleHttp\HandlerStack $handlerStack)
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response put(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response query(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface put(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface query(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = [])
* @method static \Hypervel\Http\Client\PendingRequest replaceHeaders(array $headers)
* @method static \Hypervel\Http\Client\PendingRequest retry(array|int $times, \Closure|int $sleepMilliseconds = 0, null|callable $when = null, bool $throw = true)
* @method static \Psr\Http\Message\RequestInterface runBeforeSendingCallbacks(\Psr\Http\Message\RequestInterface $request, array $options)
- * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response send(string $method, string $url, array $options = [])
+ * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface send(string $method, string $url, array $options = [])
* @method static \Hypervel\Http\Client\PendingRequest setClient(\GuzzleHttp\ClientInterface $client)
* @method static \Hypervel\Http\Client\PendingRequest setHandler(callable $handler)
* @method static \Hypervel\Http\Client\PendingRequest sink(resource|\Psr\Http\Message\StreamInterface|string $to)
diff --git a/src/support/src/Facades/Process.php b/src/support/src/Facades/Process.php
index 8c9d175332..5844c80ef2 100644
--- a/src/support/src/Facades/Process.php
+++ b/src/support/src/Facades/Process.php
@@ -24,11 +24,12 @@
* @method static mixed unless(mixed $value = null, null|callable $callback = null, null|callable $default = null)
* @method static mixed when(mixed $value = null, null|callable $callback = null, null|callable $default = null)
* @method static \Hypervel\Process\PendingProcess withFakeHandlers(array $fakeHandlers)
- * @method static \Hypervel\Process\Factory assertDidntRun(Closure|string $callback)
+ * @method static \Hypervel\Process\Factory assertDidntRun(array|Closure|string $callback)
* @method static \Hypervel\Process\Factory assertNothingRan()
- * @method static \Hypervel\Process\Factory assertNotRan(Closure|string $callback)
- * @method static \Hypervel\Process\Factory assertRan(Closure|string $callback)
- * @method static \Hypervel\Process\Factory assertRanTimes(Closure|string $callback, int $times = 1)
+ * @method static \Hypervel\Process\Factory assertNotRan(array|Closure|string $callback)
+ * @method static \Hypervel\Process\Factory assertRan(array|Closure|string $callback)
+ * @method static \Hypervel\Process\Factory assertRanInOrder(array|Closure|string> $callbacks)
+ * @method static \Hypervel\Process\Factory assertRanTimes(array|Closure|string $callback, int $times = 1)
* @method static \Hypervel\Process\ProcessPoolResults concurrently(callable $callback, callable|null $output = null)
* @method static \Hypervel\Process\FakeProcessDescription describe()
* @method static void flushMacros()
diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php
index 7812caea51..32c8244743 100644
--- a/src/support/src/Facades/Request.php
+++ b/src/support/src/Facades/Request.php
@@ -195,10 +195,10 @@
* @method static bool wantsJson()
* @method static bool wantsMarkdown()
* @method static mixed when(mixed $value = null, null|callable $callback = null, null|callable $default = null)
- * @method static mixed whenEnum(string $key, string $enumClass, callable $callback, callable|null $default = null)
- * @method static mixed whenFilled(string $key, callable $callback, callable|null $default = null)
- * @method static mixed whenHas(string $key, callable $callback, callable|null $default = null)
- * @method static mixed whenMissing(string $key, callable $callback, callable|null $default = null)
+ * @method static mixed whenEnum(string $key, string $enumClass, callable $callback, null|callable $default = null)
+ * @method static mixed whenFilled(string $key, callable $callback, null|callable $default = null)
+ * @method static mixed whenHas(string $key, callable $callback, null|callable $default = null)
+ * @method static mixed whenMissing(string $key, callable $callback, null|callable $default = null)
*
* @see \Hypervel\Http\Request
*/
diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php
index 77bdb4f696..b84fbb7bdf 100644
--- a/src/support/src/Testing/Fakes/BusFake.php
+++ b/src/support/src/Testing/Fakes/BusFake.php
@@ -375,7 +375,7 @@ protected function assertDispatchedWithChainOfObjects(string $command, array $ex
$chain = $expectedChain;
PHPUnit::assertTrue(
- $this->dispatched($command, $callback)->filter(function ($job) use ($chain) {
+ $this->dispatched($command, $callback)->contains(function ($job) use ($chain) {
if (count($chain) !== count($job->chained)) {
return false;
}
@@ -412,7 +412,7 @@ protected function assertDispatchedWithChainOfObjects(string $command, array $ex
}
return true;
- })->isNotEmpty(),
+ }),
'The expected chain was not dispatched.'
);
}
@@ -701,12 +701,12 @@ protected function shouldFakeJob(mixed $command): bool
return true;
}
- return Collection::make($this->jobsToFake)
- ->filter(function ($job) use ($command) {
+ return (new Collection($this->jobsToFake))
+ ->contains(function ($job) use ($command) {
return $job instanceof Closure
? $job($command)
: $job === get_class($command);
- })->isNotEmpty();
+ });
}
/**
@@ -714,12 +714,12 @@ protected function shouldFakeJob(mixed $command): bool
*/
protected function shouldDispatchCommand(mixed $command): bool
{
- return Collection::make($this->jobsToDispatch)
- ->filter(function ($job) use ($command) {
+ return (new Collection($this->jobsToDispatch))
+ ->contains(function ($job) use ($command) {
return $job instanceof Closure
? $job($command)
: $job === get_class($command);
- })->isNotEmpty();
+ });
}
/**
diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php
index 9f61cad4f4..7a261de013 100644
--- a/src/support/src/Testing/Fakes/QueueFake.php
+++ b/src/support/src/Testing/Fakes/QueueFake.php
@@ -224,10 +224,10 @@ public function assertPushedWithoutChain(string $job, ?callable $callback = null
*/
protected function assertPushedWithChainOfObjects(string $job, array $expectedChain, ?callable $callback): void
{
- $chain = Collection::make($expectedChain)->map(fn ($job) => serialize($job))->all();
+ $chain = (new Collection($expectedChain))->map(fn ($job) => serialize($job))->all();
PHPUnit::assertTrue(
- $this->pushed($job, $callback)->filter(fn ($job) => $job->chained === $chain)->isNotEmpty(),
+ $this->pushed($job, $callback)->contains(fn ($job) => $job->chained === $chain),
'The expected chain was not pushed.'
);
}
@@ -272,7 +272,7 @@ public function assertClosureNotPushed(?callable $callback = null): void
*/
protected function isChainOfObjects(array $chain): bool
{
- return ! Collection::make($chain)->contains(fn ($job) => ! is_object($job));
+ return (new Collection($chain))->doesntContain(fn ($job) => ! is_object($job));
}
/**
diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php
index e703b10b2c..16bf85150f 100644
--- a/src/support/src/Traits/InteractsWithData.php
+++ b/src/support/src/Traits/InteractsWithData.php
@@ -75,7 +75,12 @@ public function hasAny(array|string $keys): bool
/**
* Apply the callback if the instance contains the given key.
*
- * @return $this|mixed
+ * @template TReturn
+ * @template TReturnDefault = never
+ *
+ * @param callable(mixed): TReturn $callback
+ * @param null|(callable(): TReturnDefault) $default
+ * @return $this|TReturn|TReturnDefault
*/
public function whenHas(string $key, callable $callback, ?callable $default = null): mixed
{
@@ -141,7 +146,12 @@ public function anyFilled(array|string $keys): bool
/**
* Apply the callback if the instance contains a non-empty value for the given key.
*
- * @return $this|mixed
+ * @template TReturn
+ * @template TReturnDefault = never
+ *
+ * @param callable(mixed): TReturn $callback
+ * @param null|(callable(): TReturnDefault) $default
+ * @return $this|TReturn|TReturnDefault
*/
public function whenFilled(string $key, callable $callback, ?callable $default = null): mixed
{
@@ -160,10 +170,13 @@ public function whenFilled(string $key, callable $callback, ?callable $default =
* Apply the callback if the instance contains a valid enum value for the given key.
*
* @template TEnum of \BackedEnum
+ * @template TReturn
+ * @template TReturnDefault = never
*
* @param class-string $enumClass
- * @param callable(TEnum): mixed $callback
- * @return $this|mixed
+ * @param callable(TEnum): TReturn $callback
+ * @param null|(callable(): TReturnDefault) $default
+ * @return $this|TReturn|TReturnDefault
*/
public function whenEnum(string $key, string $enumClass, callable $callback, ?callable $default = null): mixed
{
@@ -190,6 +203,13 @@ public function missing(array|string $key): bool
/**
* Apply the callback if the instance is missing the given key.
+ *
+ * @template TReturn
+ * @template TReturnDefault = never
+ *
+ * @param callable(mixed): TReturn $callback
+ * @param null|(callable(): TReturnDefault) $default
+ * @return $this|TReturn|TReturnDefault
*/
public function whenMissing(string $key, callable $callback, ?callable $default = null): mixed
{
@@ -247,7 +267,7 @@ public function boolean(?string $key = null, bool $default = false): bool
*/
public function integer(string $key, int $default = 0): int
{
- return intval($this->data($key, $default));
+ return (int) $this->data($key, $default);
}
/**
@@ -255,7 +275,7 @@ public function integer(string $key, int $default = 0): int
*/
public function float(string $key, float $default = 0.0): float
{
- return floatval($this->data($key, $default));
+ return (float) $this->data($key, $default);
}
/**
diff --git a/src/support/src/Traits/Localizable.php b/src/support/src/Traits/Localizable.php
index 30ccedb125..6503d631d5 100644
--- a/src/support/src/Traits/Localizable.php
+++ b/src/support/src/Traits/Localizable.php
@@ -11,6 +11,11 @@ trait Localizable
{
/**
* Run the callback with the given locale.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
*/
public function withLocale(?string $locale, Closure $callback): mixed
{
diff --git a/src/validation/src/Rules/Contains.php b/src/validation/src/Rules/Contains.php
index e50362e685..38f0fca1d0 100644
--- a/src/validation/src/Rules/Contains.php
+++ b/src/validation/src/Rules/Contains.php
@@ -4,7 +4,6 @@
namespace Hypervel\Validation\Rules;
-use BackedEnum;
use Hypervel\Contracts\Support\Arrayable;
use Stringable;
use UnitEnum;
@@ -21,7 +20,7 @@ class Contains implements Stringable
/**
* Create a new contains rule instance.
*/
- public function __construct(array|Arrayable|BackedEnum|string|UnitEnum $values)
+ public function __construct(array|Arrayable|UnitEnum|string $values)
{
if ($values instanceof Arrayable) {
$values = $values->toArray();
diff --git a/src/validation/src/Rules/DoesntContain.php b/src/validation/src/Rules/DoesntContain.php
index 74ed48fa39..7c5580daf0 100644
--- a/src/validation/src/Rules/DoesntContain.php
+++ b/src/validation/src/Rules/DoesntContain.php
@@ -4,7 +4,6 @@
namespace Hypervel\Validation\Rules;
-use BackedEnum;
use Hypervel\Contracts\Support\Arrayable;
use Stringable;
use UnitEnum;
@@ -21,7 +20,7 @@ class DoesntContain implements Stringable
/**
* Create a new doesnt_contain rule instance.
*/
- public function __construct(array|Arrayable|BackedEnum|string|UnitEnum $values)
+ public function __construct(array|Arrayable|UnitEnum|string $values)
{
if ($values instanceof Arrayable) {
$values = $values->toArray();
diff --git a/src/validation/src/Rules/File.php b/src/validation/src/Rules/File.php
index 1678d08a7b..4f3cd3a8a9 100644
--- a/src/validation/src/Rules/File.php
+++ b/src/validation/src/Rules/File.php
@@ -206,8 +206,12 @@ public function encoding(string $encoding): static
/**
* Convert a potentially human-friendly file size to kilobytes.
+ *
+ * @return ($size is int ? int : float|int)
+ *
+ * @throws InvalidArgumentException
*/
- protected function toKilobytes(int|string $size): mixed
+ protected function toKilobytes(int|string $size): int|float
{
if (! is_string($size)) {
return $size;
@@ -215,7 +219,7 @@ protected function toKilobytes(int|string $size): mixed
$size = strtolower(trim($size));
- $value = floatval($size);
+ $value = (float) $size;
return round(match (true) {
Str::endsWith($size, 'kb') => $value * 1,
diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php
index c4d396750d..0c67f12f68 100755
--- a/tests/Auth/AuthGuardTest.php
+++ b/tests/Auth/AuthGuardTest.php
@@ -491,7 +491,7 @@ public function testLogoutCurrentDeviceFiresLogoutEvent()
$mock->logoutCurrentDevice();
}
- public function testLoginMethodQueuesCookieWhenRemembering()
+ public function testLoginMethodQueuesCookieWhenRemembering(): void
{
[$session, $provider, $request, $cookie, $timebox, $app] = $this->getMocks();
$guard = new SessionGuard('default', $provider, $session, $app);
@@ -503,9 +503,9 @@ public function testLoginMethodQueuesCookieWhenRemembering()
$guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo');
$session->shouldReceive('regenerate')->once();
$user = m::mock(Authenticatable::class);
- $user->shouldReceive('getAuthIdentifier')->andReturn('foo');
- $user->shouldReceive('getAuthPassword')->andReturn('bar');
- $user->shouldReceive('getRememberToken')->andReturn('recaller');
+ $user->shouldReceive('getAuthIdentifier')->times(2)->andReturn('foo');
+ $user->shouldReceive('getAuthPassword')->once()->andReturn('bar');
+ $user->shouldReceive('getRememberToken')->times(2)->andReturn('recaller');
$user->shouldReceive('setRememberToken')->never();
$provider->shouldReceive('updateRememberToken')->never();
$guard->login($user, true);
@@ -531,7 +531,7 @@ public function testLoginMethodQueuesCookieWhenRememberingPasswordlessUser()
$guard->login($user, true);
}
- public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride()
+ public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride(): void
{
[$session, $provider, $request, $cookie, $timebox, $app] = $this->getMocks();
$guard = new SessionGuard('default', $provider, $session, $app);
@@ -544,9 +544,9 @@ public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride()
$guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo');
$session->shouldReceive('regenerate')->once();
$user = m::mock(Authenticatable::class);
- $user->shouldReceive('getAuthIdentifier')->andReturn('foo');
- $user->shouldReceive('getAuthPassword')->andReturn('bar');
- $user->shouldReceive('getRememberToken')->andReturn('recaller');
+ $user->shouldReceive('getAuthIdentifier')->times(2)->andReturn('foo');
+ $user->shouldReceive('getAuthPassword')->once()->andReturn('bar');
+ $user->shouldReceive('getRememberToken')->times(2)->andReturn('recaller');
$user->shouldReceive('setRememberToken')->never();
$provider->shouldReceive('updateRememberToken')->never();
$guard->login($user, true);
diff --git a/tests/Auth/RecallerTest.php b/tests/Auth/RecallerTest.php
index c48955f958..1f1f579218 100644
--- a/tests/Auth/RecallerTest.php
+++ b/tests/Auth/RecallerTest.php
@@ -9,101 +9,90 @@
class RecallerTest extends TestCase
{
- public function testIdReturnsFirstSegment()
+ public function testIdReturnsFirstSegment(): void
{
$recaller = new Recaller('123|token|hash');
$this->assertSame('123', $recaller->id());
}
- public function testTokenReturnsSecondSegment()
+ public function testTokenReturnsSecondSegment(): void
{
$recaller = new Recaller('123|token|hash');
$this->assertSame('token', $recaller->token());
}
- public function testHashReturnsThirdSegment()
+ public function testHashReturnsThirdSegment(): void
{
$recaller = new Recaller('123|token|hash');
$this->assertSame('hash', $recaller->hash());
}
- public function testHashDoesNotIncludeFourthSegment()
+ public function testHashDoesNotIncludeFourthSegment(): void
{
$recaller = new Recaller('123|token|hash|extra');
$this->assertSame('hash', $recaller->hash());
}
- public function testSegmentsReturnsAllParts()
+ public function testSegmentsReturnsAllParts(): void
{
- $recaller = new Recaller('123|token|hash');
+ $recaller = new Recaller('123|token|hash|extra');
- $this->assertSame(['123', 'token', 'hash'], $recaller->segments());
+ $this->assertSame(['123', 'token', 'hash', 'extra'], $recaller->segments());
+ $this->assertTrue($recaller->valid());
}
- public function testValidReturnsTrueForProperRecaller()
+ public function testValidReturnsTrueForProperRecaller(): void
{
$recaller = new Recaller('123|token|hash');
$this->assertTrue($recaller->valid());
}
- public function testValidReturnsFalseWhenNoPipes()
+ public function testValidReturnsFalseWhenNoPipes(): void
{
$recaller = new Recaller('invalid');
$this->assertFalse($recaller->valid());
}
- public function testValidReturnsFalseWhenOnlyTwoSegments()
+ public function testValidReturnsFalseWhenOnlyTwoSegments(): void
{
$recaller = new Recaller('123|token');
$this->assertFalse($recaller->valid());
}
- public function testValidReturnsFalseWhenIdIsEmpty()
+ public function testValidReturnsFalseWhenIdIsEmpty(): void
{
$recaller = new Recaller('|token|hash');
$this->assertFalse($recaller->valid());
}
- public function testValidReturnsFalseWhenTokenIsEmpty()
+ public function testValidReturnsFalseWhenTokenIsEmpty(): void
{
$recaller = new Recaller('123||hash');
$this->assertFalse($recaller->valid());
}
- public function testValidReturnsFalseWhenIdIsWhitespace()
+ public function testValidReturnsFalseWhenIdIsWhitespace(): void
{
$recaller = new Recaller(' |token|hash');
$this->assertFalse($recaller->valid());
}
- public function testRawStringFallsBackWhenUnserializeFails()
+ public function testPlainCookieStringPreservesIdentifierAndToken(): void
{
- // The constructor attempts unserialize — a non-serialized string
- // fails unserialize and falls back to the raw string.
$raw = '123|token|hash';
$recaller = new Recaller($raw);
$this->assertSame('123', $recaller->id());
$this->assertSame('token', $recaller->token());
}
-
- public function testSerializedStringIsUnserializedInConstructor()
- {
- // The constructor successfully unserializes a serialized string.
- $raw = '123|token|hash';
- $recaller = new Recaller(serialize($raw));
-
- $this->assertSame('123', $recaller->id());
- $this->assertSame('token', $recaller->token());
- }
}
diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php
index 6231f5b290..b93ae4f62d 100644
--- a/tests/Cache/CacheDatabaseLockTest.php
+++ b/tests/Cache/CacheDatabaseLockTest.php
@@ -15,6 +15,7 @@
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
class CacheDatabaseLockTest extends TestCase
{
@@ -121,6 +122,17 @@ public function testExpiredLocksAreDeletedDuringAcquisition(): void
$this->assertTrue($lock->acquire());
}
+ #[TestWith([null])]
+ #[TestWith([[]])]
+ public function testLockCanBeAcquiredWithoutAutomaticPruning(?array $lottery): void
+ {
+ [$lock, $table] = $this->getLock(lockLottery: $lottery);
+
+ $table->shouldReceive('insert')->once()->andReturn(true);
+
+ $this->assertTrue($lock->acquire());
+ }
+
public function testLockCanBeReleased(): void
{
[$lock, $table] = $this->getLock();
@@ -367,7 +379,7 @@ public function testGetConnectionNameCanReturnNull(): void
/**
* Get a DatabaseLock instance with mocked dependencies.
*/
- protected function getLock(int $seconds = 10, array $lockLottery = [0, 1], ?string $connectionName = 'default'): array
+ protected function getLock(int $seconds = 10, ?array $lockLottery = [0, 1], ?string $connectionName = 'default'): array
{
$resolver = m::mock(ConnectionResolverInterface::class);
$connection = m::mock(ConnectionInterface::class);
diff --git a/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php
index 664d71ea15..75e9442be7 100755
--- a/tests/Database/DatabaseConnectionFactoryTest.php
+++ b/tests/Database/DatabaseConnectionFactoryTest.php
@@ -59,7 +59,7 @@ public function testConnectionCanBeCreated()
$this->assertInstanceOf(PDO::class, $this->db->getConnection('url')->getReadPdo());
}
- public function testConnectionFromUrlHasProperConfig()
+ public function testConnectionFromUrlHasProperConfig(): void
{
$this->db->addConnection([
'url' => 'mysql://root:pass@db/local?strict=true',
@@ -86,6 +86,7 @@ public function testConnectionFromUrlHasProperConfig()
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
+ 'mask_bindings_in_exception_messages' => false,
], $this->db->getConnection('url-config')->getConfig());
}
diff --git a/tests/Database/DatabaseEloquentCollectionTest.php b/tests/Database/DatabaseEloquentCollectionTest.php
index dbac3cf7ed..745ad12b17 100755
--- a/tests/Database/DatabaseEloquentCollectionTest.php
+++ b/tests/Database/DatabaseEloquentCollectionTest.php
@@ -15,6 +15,7 @@
use Hypervel\Tests\TestCase;
use LogicException;
use Mockery as m;
+use PHPUnit\Framework\Attributes\DataProvider;
use stdClass;
class DatabaseEloquentCollectionTest extends TestCase
@@ -24,6 +25,8 @@ class DatabaseEloquentCollectionTest extends TestCase
*/
protected function setUp(): void
{
+ parent::setUp();
+
$db = new DB;
$db->addConnection([
@@ -731,22 +734,35 @@ public function testMakeVisibleRemovesHiddenFromEntireCollection()
$this->assertEquals([], $c[0]->getHidden());
}
- public function testMergeHiddenAddsHiddenOnEntireCollection()
+ #[DataProvider('mergeAttributesProvider')]
+ public function testMergeHiddenAddsHiddenOnEntireCollection(array|string $attributes): void
{
$c = new Collection([new CollectionModel]);
- $c = $c->mergeHidden(['merged']);
+ $c = $c->mergeHidden($attributes);
$this->assertEquals(['hidden', 'merged'], $c[0]->getHidden());
}
- public function testMergeVisibleRemovesHiddenFromEntireCollection()
+ #[DataProvider('mergeAttributesProvider')]
+ public function testMergeVisibleRemovesHiddenFromEntireCollection(array|string $attributes): void
{
$c = new Collection([new CollectionModel]);
- $c = $c->mergeVisible(['merged']);
+ $c = $c->mergeVisible($attributes);
$this->assertEquals(['visible', 'merged'], $c[0]->getVisible());
}
+ /**
+ * Provide attributes to merge across the collection.
+ */
+ public static function mergeAttributesProvider(): array
+ {
+ return [
+ 'array' => [['merged']],
+ 'string' => ['merged'],
+ ];
+ }
+
public function testSetVisibleReplacesVisibleOnEntireCollection()
{
$c = new Collection([new CollectionModel]);
diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php
index a2db98468a..d759169d4f 100755
--- a/tests/Database/DatabasePdoConnectionTest.php
+++ b/tests/Database/DatabasePdoConnectionTest.php
@@ -734,6 +734,57 @@ public function testOnLostConnectionPDOIsNotSwappedWithinATransaction(): void
$connection->statement('foo');
}
+ public function testQueryExceptionEmbedsBindingsByDefault(): void
+ {
+ foreach ([[], ['mask_bindings_in_exception_messages' => null]] as $config) {
+ $connection = new PdoConnection($this->getFailingPdo(), '', '', $config);
+
+ try {
+ $connection->statement('SELECT * FROM users WHERE email = ?', ['foo@example.com']);
+
+ $this->fail('A QueryException was not thrown.');
+ } catch (QueryException $e) {
+ $this->assertStringContainsString('SQL: SELECT * FROM users WHERE email = foo@example.com', $e->getMessage());
+ }
+ }
+ }
+
+ public function testQueryExceptionMasksBindingsWhenEnabledOnTheConnection(): void
+ {
+ foreach ([true, '1'] as $maskBindings) {
+ $connection = new PdoConnection($this->getFailingPdo(), '', '', [
+ 'mask_bindings_in_exception_messages' => $maskBindings,
+ ]);
+
+ try {
+ $connection->statement('SELECT * FROM users WHERE email = ?', ['foo@example.com']);
+
+ $this->fail('A QueryException was not thrown.');
+ } catch (QueryException $e) {
+ $this->assertStringContainsString('SQL: SELECT * FROM users WHERE email = ?', $e->getMessage());
+ $this->assertStringNotContainsString('foo@example.com', $e->getMessage());
+ $this->assertSame(['foo@example.com'], $e->getBindings());
+ }
+ }
+ }
+
+ /**
+ * Create a PDO connection whose statement execution fails.
+ */
+ protected function getFailingPdo(): PDO
+ {
+ $statement = m::mock(PDOStatement::class);
+ $statement->shouldReceive('bindValue')->once();
+ $statement->shouldReceive('execute')->once()->andThrow(
+ new PDOException('SQLSTATE[42S02]: Base table or view not found')
+ );
+
+ $pdo = m::mock(PDO::class);
+ $pdo->shouldReceive('prepare')->once()->andReturn($statement);
+
+ return $pdo;
+ }
+
public function testOnLostConnectionPDOIsSwappedOutsideTransaction(): void
{
$pdo = m::mock(PDO::class);
diff --git a/tests/Database/DatabaseQueryExceptionTest.php b/tests/Database/DatabaseQueryExceptionTest.php
index 405dac094d..ce79123170 100755
--- a/tests/Database/DatabaseQueryExceptionTest.php
+++ b/tests/Database/DatabaseQueryExceptionTest.php
@@ -151,6 +151,31 @@ public function testBackwardCompatibilityWithoutConnectionInfo()
$this->assertSame([], $exception->getConnectionDetails());
}
+ public function testBindingsAreEmbeddedInTheMessageByDefault(): void
+ {
+ $pdoException = new PDOException('Mock SQL error');
+ $exception = new QueryException('mysql', 'SELECT * FROM users WHERE email = ?', ['foo@example.com'], $pdoException);
+
+ $this->assertSame('Mock SQL error (Connection: mysql, SQL: SELECT * FROM users WHERE email = foo@example.com)', $exception->getMessage());
+ }
+
+ public function testBindingsCanBeMaskedInTheMessage(): void
+ {
+ $pdoException = new PDOException('Mock SQL error');
+ $exception = new QueryException('mysql', 'SELECT * FROM users WHERE email = ?', ['foo@example.com'], $pdoException, [], null, true);
+
+ $this->assertSame('Mock SQL error (Connection: mysql, SQL: SELECT * FROM users WHERE email = ?)', $exception->getMessage());
+ }
+
+ public function testMaskingBindingsDoesNotAffectTheAccessors(): void
+ {
+ $pdoException = new PDOException('Mock SQL error');
+ $exception = new QueryException('mysql', 'SELECT * FROM users WHERE email = ?', ['foo@example.com'], $pdoException, [], null, true);
+
+ $this->assertSame(['foo@example.com'], $exception->getBindings());
+ $this->assertSame('SELECT * FROM users WHERE email = ?', $exception->getSql());
+ }
+
protected function getMockConnection()
{
$connection = m::mock(Connection::class);
diff --git a/tests/Database/EloquentHasOneOrManyDeprecationTest.php b/tests/Database/EloquentHasOneOrManyDeprecationTest.php
index 06443a7f22..f06bf3f66f 100644
--- a/tests/Database/EloquentHasOneOrManyDeprecationTest.php
+++ b/tests/Database/EloquentHasOneOrManyDeprecationTest.php
@@ -30,7 +30,7 @@ public function testHasManyMatchWithNullLocalKey(): void
$model2 = new HasOneOrManyDeprecationModelStub;
$model2->id = null;
- $relation->getRelated()->shouldReceive('newCollection')->andReturnUsing(function ($array) {
+ $relation->getRelated()->expects('newCollection')->andReturnUsing(function (array $array): Collection {
return new Collection($array);
});
@@ -68,43 +68,41 @@ public function testHasManyMatchWithNullForeignKey(): void
$model = new HasOneOrManyDeprecationModelStub;
$model->id = '';
- $relation->getRelated()->shouldReceive('newCollection')->andReturnUsing(function ($array) {
- return new Collection($array);
- });
-
$models = $relation->match([$model], new Collection([$result]), 'foo');
$this->assertNull($models[0]->foo);
}
+ /**
+ * Create a has-many relation with mocked query constraints.
+ */
protected function getHasManyRelation(): HasMany
{
$queryBuilder = m::mock(QueryBuilder::class);
$builder = m::mock(Builder::class, [$queryBuilder]);
- $builder->shouldReceive('whereNotNull')->with('table.foreign_key');
- $builder->shouldReceive('where')->with('table.foreign_key', '=', 1);
+ $builder->expects('whereNotNull')->with('table.foreign_key');
+ $builder->expects('where')->with('table.foreign_key', '=', 1);
$related = m::mock(Model::class);
- $builder->shouldReceive('getModel')->andReturn($related);
+ $builder->expects('getModel')->andReturn($related);
$parent = m::mock(Model::class);
- $parent->shouldReceive('getAttribute')->with('id')->andReturn(1);
- $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at');
- $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at');
+ $parent->expects('getAttribute')->with('id')->andReturn(1);
return new HasMany($builder, $parent, 'table.foreign_key', 'id');
}
+ /**
+ * Create a has-one relation with mocked query constraints.
+ */
protected function getHasOneRelation(): HasOne
{
$queryBuilder = m::mock(QueryBuilder::class);
$builder = m::mock(Builder::class, [$queryBuilder]);
- $builder->shouldReceive('whereNotNull')->with('table.foreign_key');
- $builder->shouldReceive('where')->with('table.foreign_key', '=', 1);
+ $builder->expects('whereNotNull')->with('table.foreign_key');
+ $builder->expects('where')->with('table.foreign_key', '=', 1);
$related = m::mock(Model::class);
- $builder->shouldReceive('getModel')->andReturn($related);
+ $builder->expects('getModel')->andReturn($related);
$parent = m::mock(Model::class);
- $parent->shouldReceive('getAttribute')->with('id')->andReturn(1);
- $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at');
- $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at');
+ $parent->expects('getAttribute')->with('id')->andReturn(1);
return new HasOne($builder, $parent, 'table.foreign_key', 'id');
}
diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php
index 597e34e52b..1bebdf9b5b 100644
--- a/tests/Foundation/Console/KernelTest.php
+++ b/tests/Foundation/Console/KernelTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Foundation\Console;
+use Composer\Autoload\ClassLoader;
use Hypervel\Console\Application as ConsoleApplication;
use Hypervel\Console\Command;
use Hypervel\Console\Scheduling\CacheEventMutex;
@@ -13,6 +14,7 @@
use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Events\Dispatcher;
+use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\Application;
use Hypervel\Foundation\Bootstrap\BootProviders;
use Hypervel\Foundation\Console\Kernel;
@@ -24,6 +26,7 @@
use ReflectionProperty;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
+use Symfony\Component\Console\Command\Command as SymfonyCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\BufferedOutput;
@@ -184,6 +187,81 @@ public function testConfiguredScheduleCacheUsesTheSelectedStoreForBothMutexes():
$this->assertSame('scheduling', $this->app->make(CacheSchedulingMutex::class)->store);
}
+ public function testLoadIgnoresTestFiles(): void
+ {
+ $files = new Filesystem;
+ $directory = $this->app->path('Console/Commands/Discovery');
+ $loader = new ClassLoader;
+ $loader->addPsr4('App\Console\Commands\Discovery\\', $directory);
+
+ try {
+ $files->ensureDirectoryExists($directory);
+ $files->put($directory . '/ExampleCommand.php', <<<'PHP'
+put($directory . '/ExampleCommandTest.php', <<<'PHP'
+put($directory . '/ExampleCommandUnitTest.php', <<<'PHP'
+assertTrue(true);
+ }
+}
+PHP);
+ $loader->register();
+
+ $kernel = new Kernel($this->app, $this->app->make('events'));
+ $kernel->addCommandPaths([$directory]);
+
+ $commands = collect($kernel->getArtisan()->all())
+ ->map(static fn (SymfonyCommand $command): string => $command::class)->all();
+
+ $this->assertContains('App\Console\Commands\Discovery\ExampleCommand', $commands);
+ $this->assertContains('App\Console\Commands\Discovery\ExampleCommandTest', $commands);
+ $this->assertNotContains('App\Console\Commands\Discovery\ExampleCommandUnitTest', $commands);
+ } finally {
+ $loader->unregister();
+ $files->deleteDirectory($directory);
+ }
+ }
+
public function testSetArtisanSynchronizesTheKernelAndContainerBeforeReboundCallbacks(): void
{
$kernel = $this->app->make(KernelContract::class);
diff --git a/tests/Foundation/Exceptions/Renderer/ExceptionTest.php b/tests/Foundation/Exceptions/Renderer/ExceptionTest.php
new file mode 100644
index 0000000000..c65374bbe7
--- /dev/null
+++ b/tests/Foundation/Exceptions/Renderer/ExceptionTest.php
@@ -0,0 +1,46 @@
+shouldReceive('getName')->once()->andReturn(null);
+ $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ $listener = new Listener;
+ $listener->onQueryExecuted(new QueryExecuted(
+ 'select * from t where a = ? and b = ? and c = ? and d = ? and e = ?',
+ ['$1 off?', 'next \1', 7, 1.5, null],
+ null,
+ $connection,
+ ));
+
+ $exception = new Exception(
+ FlattenException::createFromThrowable(new RuntimeException('Example exception.')),
+ Request::create('/'),
+ $listener,
+ __DIR__,
+ );
+
+ $this->assertSame([[
+ 'connectionName' => null,
+ 'time' => null,
+ 'sql' => "select * from t where a = '$1 off?' and b = 'next \\1' and c = 7 and d = 1.5 and e = NULL",
+ ]], $exception->applicationQueries());
+ }
+}
diff --git a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php
index 634aeacc54..346b84fcf4 100644
--- a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php
+++ b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php
@@ -14,23 +14,6 @@
class ListenerContextIsolationTest extends TestCase
{
- public function testQueryCapStopsAtMaxQueries(): void
- {
- $listener = new Listener;
-
- $connection = m::mock(Connection::class);
- $connection->shouldReceive('getName')->andReturn('testing');
- $connection->shouldReceive('prepareBindings')->andReturn([]);
-
- for ($i = 0; $i < 110; ++$i) {
- $listener->onQueryExecuted(
- new QueryExecuted("SELECT {$i}", [], 1.0, $connection)
- );
- }
-
- $this->assertCount(100, $listener->queries());
- }
-
public function testQueriesAreIsolatedBetweenCoroutines(): void
{
$results = parallel([
diff --git a/tests/Foundation/Exceptions/Renderer/ListenerTest.php b/tests/Foundation/Exceptions/Renderer/ListenerTest.php
index 330507bfad..0de4e32ad3 100644
--- a/tests/Foundation/Exceptions/Renderer/ListenerTest.php
+++ b/tests/Foundation/Exceptions/Renderer/ListenerTest.php
@@ -12,12 +12,12 @@
class ListenerTest extends TestCase
{
- public function testQueriesReturnsExpectedShapeAfterQueryExecuted()
+ public function testQueriesReturnsExpectedShapeAfterQueryExecuted(): void
{
$connection = m::mock(Connection::class);
- $connection->shouldReceive('getName')->andReturn('testing');
- $connection->shouldReceive('prepareBindings')->with(['foo'])->andReturn(['foo']);
+ $connection->shouldReceive('getName')->once()->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->once()->with(['foo'])->andReturn(['foo']);
$event = new QueryExecuted('select * from users where id = ?', ['foo'], 5.2, $connection);
@@ -37,12 +37,143 @@ public function testQueriesReturnsExpectedShapeAfterQueryExecuted()
$this->assertArrayHasKey('sql', $query);
$this->assertArrayHasKey('bindings', $query);
- $this->assertEquals('testing', $query['connectionName']);
- $this->assertEquals(5.2, $query['time']);
- $this->assertEquals('select * from users where id = ?', $query['sql']);
+ $this->assertSame('testing', $query['connectionName']);
+ $this->assertSame(5.2, $query['time']);
+ $this->assertSame('select * from users where id = ?', $query['sql']);
$this->assertEquals(['foo'], $query['bindings']);
}
+ public function testListenerCapsAt100Queries(): void
+ {
+ $listener = new Listener;
+
+ $connection = m::mock(Connection::class);
+ $connection->shouldReceive('getName')->times(150)->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->times(100)->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ for ($index = 0; $index < 150; ++$index) {
+ $listener->onQueryExecuted(
+ new QueryExecuted("select {$index}", [], 1.0, $connection)
+ );
+ }
+
+ $this->assertCount(100, $listener->queries());
+ $this->assertSame('select 0', $listener->queries()[0]['sql']);
+ $this->assertSame('select 99', $listener->queries()[99]['sql']);
+ }
+
+ public function testLargeSqlIsTruncated(): void
+ {
+ $listener = new Listener;
+
+ $connection = m::mock(Connection::class);
+ $connection->shouldReceive('getName')->once()->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ $largeSql = str_repeat('x', 5000);
+ $listener->onQueryExecuted(
+ new QueryExecuted($largeSql, [], 1.0, $connection)
+ );
+
+ $this->assertLessThanOrEqual(2000, strlen($listener->queries()[0]['sql']));
+ }
+
+ public function testBindingsMatchPlaceholderCountInTruncatedSql(): void
+ {
+ $listener = new Listener;
+
+ $connection = m::mock(Connection::class);
+ $connection->shouldReceive('getName')->once()->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ // Build SQL with 1000 placeholders so truncation to 2000 bytes removes
+ // some placeholders and their corresponding bindings.
+ $placeholders = implode(', ', array_fill(0, 1000, '?'));
+ $sql = "INSERT INTO t (a) VALUES ({$placeholders})";
+ $bindings = array_fill(0, 1000, 'value');
+
+ $listener->onQueryExecuted(
+ new QueryExecuted($sql, $bindings, 1.0, $connection)
+ );
+
+ $storedQuery = $listener->queries()[0];
+ $storedPlaceholders = substr_count($storedQuery['sql'], '?');
+
+ $this->assertSame(2000, strlen($storedQuery['sql']));
+ $this->assertCount($storedPlaceholders, $storedQuery['bindings']);
+ }
+
+ public function testExcessBindingsAreTrimmedToMatchPlaceholders(): void
+ {
+ $listener = new Listener;
+
+ $connection = m::mock(Connection::class);
+ $connection->shouldReceive('getName')->once()->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ // 1 placeholder but 1000 bindings — only 1 binding should be kept
+ $listener->onQueryExecuted(
+ new QueryExecuted('select ?', array_fill(0, 1000, 'v'), 1.0, $connection)
+ );
+
+ $this->assertCount(1, $listener->queries()[0]['bindings']);
+ }
+
+ public function testShortSqlAndBindingsAreNotModified(): void
+ {
+ $listener = new Listener;
+
+ $connection = m::mock(Connection::class);
+ $connection->shouldReceive('getName')->once()->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ $sql = 'select * from users where name = ?';
+ $listener->onQueryExecuted(
+ new QueryExecuted($sql, ['John'], 1.0, $connection)
+ );
+
+ $this->assertEquals($sql, $listener->queries()[0]['sql']);
+ $this->assertEquals(['John'], $listener->queries()[0]['bindings']);
+ }
+
+ public function testQueryWithNoBindingsIsUnchanged(): void
+ {
+ $listener = new Listener;
+
+ $connection = m::mock(Connection::class);
+ $connection->shouldReceive('getName')->once()->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ $listener->onQueryExecuted(
+ new QueryExecuted('select count(*) from users', [], 1.0, $connection)
+ );
+
+ $this->assertSame('select count(*) from users', $listener->queries()[0]['sql']);
+ $this->assertEmpty($listener->queries()[0]['bindings']);
+ }
+
+ public function testNormalQuerySkipsTruncation(): void
+ {
+ $listener = new Listener;
+
+ $connection = m::mock(Connection::class);
+ $connection->shouldReceive('getName')->once()->andReturn('testing');
+ $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings);
+
+ $sql = 'select * from users where id = ? and name = ? and email = ?';
+ $bindings = [1, 'John', 'john@example.com'];
+
+ $listener->onQueryExecuted(
+ new QueryExecuted($sql, $bindings, 1.0, $connection)
+ );
+
+ $storedQuery = $listener->queries()[0];
+
+ // Nothing should be modified — SQL is short and bindings match placeholders
+ $this->assertEquals($sql, $storedQuery['sql']);
+ $this->assertEquals($bindings, $storedQuery['bindings']);
+ }
+
public function testLongQueriesAndBindingsAreBounded(): void
{
$connection = m::mock(Connection::class);
diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php
index 6ed8f8f574..74620e9f70 100644
--- a/tests/Foundation/FoundationConfigTest.php
+++ b/tests/Foundation/FoundationConfigTest.php
@@ -340,6 +340,31 @@ public function testShippedFilesystemDisksDeclareVisibilityAndFailurePolicy(): v
}
}
+ #[DataProvider('publicDiskUrlProvider')]
+ public function testPublicDiskUrlsNormalizeTheApplicationUrl(?string $appUrl, string $expectedUrl): void
+ {
+ $config = $this->withEnvironmentValue(
+ 'APP_URL',
+ $appUrl,
+ fn (): array => $this->filesystemConfig(),
+ );
+ $disk = $this->app->make('filesystem')->build($config['disks']['public']);
+
+ $this->assertSame($expectedUrl, $disk->url('avatar.png'));
+ }
+
+ /**
+ * Provide application URLs and their public file URLs.
+ */
+ public static function publicDiskUrlProvider(): array
+ {
+ return [
+ 'without trailing slash' => ['https://example.test', 'https://example.test/storage/avatar.png'],
+ 'subpath with trailing slash' => ['https://example.test/app/', 'https://example.test/app/storage/avatar.png'],
+ 'absent application URL' => [null, '/storage/avatar.png'],
+ ];
+ }
+
public function testS3RootReadsTheAwsRootEnvironmentVariable(): void
{
$config = $this->withEnvironmentValue(
diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php
index 4174f90749..5ed42db5ad 100644
--- a/tests/Foundation/FoundationHelpersTest.php
+++ b/tests/Foundation/FoundationHelpersTest.php
@@ -223,6 +223,16 @@ public function testCache(): void
$this->assertSame('default', cache('baz', 'default'));
}
+ public function testSessionAcceptsEnumKeys(): void
+ {
+ session(['America/New_York' => 'string-backed', 1 => 'integer-backed', 'UTC' => 'unit']);
+
+ $this->assertSame('string-backed', session(StringEnum::NewYork));
+ $this->assertSame('integer-backed', session(IntEnum::One));
+ $this->assertSame('unit', session(UnitEnum::UTC));
+ $this->assertSame('default', session(UnitEnum::EST, 'default'));
+ }
+
public function testLogsResolvesAChannelNamedZero(): void
{
$manager = m::mock(LogManager::class);
diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php
index b15c0cae40..5b81fb82af 100644
--- a/tests/Http/HttpClientTest.php
+++ b/tests/Http/HttpClientTest.php
@@ -590,6 +590,21 @@ public function testDecodeUsingTakesPrecedenceOverJsonFlags(): void
}
public function testResponseObjectIsTappable(): void
+ {
+ $bar = null;
+ $this->factory->fake([
+ '*' => ['result' => ['foo' => 'bar']],
+ ]);
+
+ $this->factory->get('http://foo.com/api')
+ ->tap(function (Response $response) use (&$bar) {
+ $bar = $response['result']['foo'];
+ });
+
+ $this->assertSame('bar', $bar);
+ }
+
+ public function testResponseTapKeepsResponseAvailableForChaining(): void
{
$response = new Response($this->factory::psr7Response(['foo' => 'bar']));
@@ -618,6 +633,7 @@ public function testResponseObjectIsMacroable(): void
$response = $this->factory->get('http://www.omdbapi.com/?apikey=test_api_key&i=test_imdb_id');
+ $this->assertIsArray($response->movieFields());
$this->assertSame([
'title' => 'The Godfather',
'year' => 1972,
diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php
index 503e8fa7bc..128febed12 100644
--- a/tests/Http/HttpRequestTest.php
+++ b/tests/Http/HttpRequestTest.php
@@ -1123,9 +1123,9 @@ public function testEnumsMethod(): void
$this->assertEquals([TestEnumBacked::test, TestEnumBacked::test], $request->enums('valid_enum_values', TestEnumBacked::class));
- $this->assertEmpty($request->enums('invalid_enum_value', TestEnumBacked::class));
+ $this->assertEmpty($request->enums('invalid_enum_values', TestEnumBacked::class));
$this->assertEmpty($request->enums('empty_value_request', TestEnumBacked::class));
- $this->assertEmpty($request->enums('valid_enum_value', TestEnum::class));
+ $this->assertEmpty($request->enums('valid_enum_values', TestEnum::class));
$this->assertEquals([TestIntegerEnumBacked::minus_1, TestIntegerEnumBacked::zero], $request->enums('string.minus_1', TestIntegerEnumBacked::class));
$this->assertEquals([TestIntegerEnumBacked::zero], $request->enums('string.0', TestIntegerEnumBacked::class));
diff --git a/tests/Integration/Database/DatabaseConnectionsTest.php b/tests/Integration/Database/DatabaseConnectionsTest.php
index 1073908f7e..c5c4388e0e 100644
--- a/tests/Integration/Database/DatabaseConnectionsTest.php
+++ b/tests/Integration/Database/DatabaseConnectionsTest.php
@@ -10,6 +10,7 @@
use Hypervel\Support\Facades\DB;
use Hypervel\Testing\ParallelTesting;
use InvalidArgumentException;
+use PHPUnit\Framework\Attributes\DataProvider;
class DatabaseConnectionsTest extends DatabaseTestCase
{
@@ -183,9 +184,10 @@ public function testQueryExceptionsProvideReadWriteType(): void
}
}
- public function testQueryInEventListenerCannotInterfereWithReadWriteType(): void
+ #[DataProvider('readWriteExpectations')]
+ public function testQueryInEventListenerCannotInterfereWithReadWriteType(string $connectionName, array $expectedTypes, ?string $loggedType): void
{
- $connection = DB::connection('sqlite_readwrite');
+ $connection = DB::connection($connectionName);
$events = collect();
$connection->listen($events->push(...));
@@ -198,32 +200,42 @@ public function testQueryInEventListenerCannotInterfereWithReadWriteType(): void
});
$connection->statement('select 1');
- $this->assertSame('write', $events->shift()->readWriteType);
- $this->assertSame('read', $events->shift()->readWriteType);
+ $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType);
+ $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType);
$connection->select('select 1');
- $this->assertSame('read', $events->shift()->readWriteType);
- $this->assertSame('read', $events->shift()->readWriteType);
+ $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType);
+ $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType);
$connection->statement('select 1');
- $this->assertSame('write', $events->shift()->readWriteType);
- $this->assertSame('read', $events->shift()->readWriteType);
+ $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType);
+ $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType);
$connection->select('select 1');
- $this->assertSame('read', $events->shift()->readWriteType);
- $this->assertSame('read', $events->shift()->readWriteType);
+ $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType);
+ $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType);
$this->assertSame([
- ['query' => 'select 2', 'readWriteType' => 'read'],
- ['query' => 'select 1', 'readWriteType' => 'write'],
- ['query' => 'select 2', 'readWriteType' => 'read'],
- ['query' => 'select 1', 'readWriteType' => 'read'],
- ['query' => 'select 2', 'readWriteType' => 'read'],
- ['query' => 'select 1', 'readWriteType' => 'write'],
- ['query' => 'select 2', 'readWriteType' => 'read'],
- ['query' => 'select 1', 'readWriteType' => 'read'],
+ ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'],
+ ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'],
+ ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'],
+ ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'],
+ ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'],
+ ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'],
+ ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'],
+ ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'],
], Arr::select($connection->getQueryLog(), [
'query', 'readWriteType',
]));
}
+
+ /**
+ * Provide the expected query roles for split connections.
+ */
+ public static function readWriteExpectations(): iterable
+ {
+ yield 'sqlite' => ['sqlite_readwrite', ['write', 'read', 'write', 'read'], null];
+ yield 'sqlite::read' => ['sqlite_readwrite::read', ['read', 'read', 'read', 'read'], 'read'];
+ yield 'sqlite::write' => ['sqlite_readwrite::write', ['write', 'write', 'write', 'write'], 'write'];
+ }
}
diff --git a/tests/Integration/Database/ModelInspectorTest.php b/tests/Integration/Database/ModelInspectorTest.php
index 22ae53e1aa..b88028f1e3 100644
--- a/tests/Integration/Database/ModelInspectorTest.php
+++ b/tests/Integration/Database/ModelInspectorTest.php
@@ -46,7 +46,7 @@ protected function afterRefreshingDatabase(): void
});
}
- public function testExtractsModelData()
+ public function testExtractsModelData(): void
{
$extractor = new ModelInspector($this->app);
$modelInfo = $extractor->inspect(ModelInspectorTestModel::class);
@@ -65,11 +65,14 @@ public function testCommandReturnsJson(): void
$this->assertModelInfo($modelInfo);
}
- private function assertModelInfo(ModelInfo|array $modelInfo)
+ /**
+ * Assert the extracted model details.
+ */
+ private function assertModelInfo(ModelInfo|array $modelInfo): void
{
$this->assertEquals(ModelInspectorTestModel::class, $modelInfo['class']);
$this->assertEquals(Schema::getConnection()->getConfig()['name'], $modelInfo['database']);
- $this->assertEquals('model_info_extractor_test_model', $modelInfo['table']);
+ $this->assertSame('model_info_extractor_test_model', $modelInfo['table']);
$this->assertNull($modelInfo['policy']);
$this->assertCount(8, $modelInfo['attributes']);
@@ -178,14 +181,17 @@ private function assertModelInfo(ModelInfo|array $modelInfo)
$this->assertEmpty($modelInfo['events']);
$this->assertCount(1, $modelInfo['observers']);
- $this->assertEquals('created', $modelInfo['observers'][0]['event']);
+ $this->assertSame('created', $modelInfo['observers'][0]['event']);
$this->assertCount(1, $modelInfo['observers'][0]['observer']);
- $this->assertEquals('Hypervel\Tests\Integration\Database\ModelInspectorTestModelObserver@created', $modelInfo['observers'][0]['observer'][0]);
+ $this->assertSame('Hypervel\Tests\Integration\Database\ModelInspectorTestModelObserver@created', $modelInfo['observers'][0]['observer'][0]);
$this->assertEquals(ModelInspectorTestModelEloquentCollection::class, $modelInfo['collection']);
$this->assertEquals(ModelInspectorTestModelBuilder::class, $modelInfo['builder']);
}
- private function assertAttributes($expectedAttributes, $actualAttributes)
+ /**
+ * Assert the database-independent column attributes.
+ */
+ private function assertAttributes(array $expectedAttributes, array $actualAttributes): void
{
foreach (['name', 'increments', 'nullable', 'unique', 'fillable', 'hidden', 'appended', 'cast'] as $key) {
$this->assertEquals($expectedAttributes[$key], $actualAttributes[$key]);
@@ -211,6 +217,9 @@ class ModelInspectorTestModel extends Model
protected array $casts = ['nullable_date' => 'datetime', 'a_bool' => 'bool'];
+ /**
+ * Get the parent model relationship.
+ */
public function parentModel(): BelongsTo
{
return $this->belongsTo(ParentTestModel::class);
@@ -226,7 +235,10 @@ class ParentTestModel extends Model
class ModelInspectorTestModelObserver
{
- public function created()
+ /**
+ * Handle the model's created event.
+ */
+ public function created(): void
{
}
}
diff --git a/tests/Mail/MailMailableTest.php b/tests/Mail/MailMailableTest.php
index 0af5169282..d1f5edd793 100644
--- a/tests/Mail/MailMailableTest.php
+++ b/tests/Mail/MailMailableTest.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory;
use Hypervel\Contracts\Mail\Attachable;
use Hypervel\Contracts\Mail\Mailer as MailerContract;
+use Hypervel\Contracts\Support\Htmlable;
use Hypervel\Contracts\View\Factory as ViewFactory;
use Hypervel\Contracts\View\View as ViewContract;
use Hypervel\Filesystem\FilesystemAdapter;
@@ -20,6 +21,7 @@
use Hypervel\Mail\Message;
use Hypervel\Mail\Transport\ArrayTransport;
use Hypervel\Support\ClassInvoker;
+use Hypervel\Support\HtmlString;
use Hypervel\Testbench\TestCase;
use Mockery as m;
use PHPUnit\Framework\AssertionFailedError;
@@ -573,6 +575,38 @@ public function testMailableBuildsViewData(): void
$this->assertSame($expected, $mailable->buildViewData());
}
+ public function testMailableAssertionsRenderHtmlableText(): void
+ {
+ $mailable = new class extends Mailable {
+ /**
+ * Build the view for the message.
+ */
+ protected function buildView(): array
+ {
+ return [
+ 'html' => new HtmlString('HTML content
'),
+ 'text' => new class implements Htmlable {
+ /**
+ * Get content as a string of HTML.
+ */
+ public function toHtml(): string
+ {
+ return 'Plain content';
+ }
+ },
+ ];
+ }
+ };
+
+ $mailable->from('sender@example.com')->to('recipient@example.com');
+ $mailer = new Mailer('array', $this->app->make(ViewFactory::class), new ArrayTransport);
+ $sentMessage = $mailer->send($mailable);
+
+ $this->assertSame('Plain content', $sentMessage->getOriginalMessage()->getTextBody());
+
+ $mailable->assertSeeInHtml('HTML content')->assertSeeInText('Plain content');
+ }
+
public function testMailerMayBeSet(): void
{
$mailable = new WelcomeMailableStub;
diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php
index 8804bbc4c2..b4cf9cb834 100644
--- a/tests/Process/ProcessTest.php
+++ b/tests/Process/ProcessTest.php
@@ -18,6 +18,7 @@
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
use OutOfBoundsException;
+use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\RequiresOperatingSystem;
use RuntimeException;
use Symfony\Component\Process\Process as SymfonyProcess;
@@ -1549,6 +1550,85 @@ public function testBasicFakeAssertions()
});
}
+ public function testAssertRanWithFalsyCommandString(): void
+ {
+ $factory = new Factory;
+
+ $factory->fake();
+
+ $factory->run('0');
+
+ $factory->assertRan('0');
+ $factory->assertRanTimes('0', 1);
+ $factory->assertNotRan('ls -la');
+ }
+
+ public function testAssertRanWithFalsyStartedCommandString(): void
+ {
+ $factory = new Factory;
+
+ $factory->fake();
+
+ $factory->start('0')->wait();
+
+ $factory->assertRan('0');
+ }
+
+ public function testAssertingProcessesRanInOrder(): void
+ {
+ $factory = new Factory;
+ $factory->fake();
+
+ $factory->run('git fetch');
+ $factory->run('git reset --hard origin/main');
+ $factory->run('composer install --no-dev');
+
+ $factory->assertRanInOrder([
+ 'git fetch',
+ 'git reset --hard origin/main',
+ fn ($process) => str_starts_with($process->command, 'composer install'),
+ ]);
+ }
+
+ public function testAssertingProcessesRanInOrderFailsWhenOutOfOrder(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+
+ $factory = new Factory;
+ $factory->fake();
+
+ $factory->run('composer install');
+ $factory->run('git fetch');
+
+ $factory->assertRanInOrder(['git fetch', 'composer install']);
+ }
+
+ public function testAssertingProcessesRanInOrderFailsWhenCountDiffers(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+
+ $factory = new Factory;
+ $factory->fake();
+
+ $factory->run('git fetch');
+
+ $factory->assertRanInOrder(['git fetch', 'composer install']);
+ }
+
+ public function testFakeAssertionsWithArrayCommands(): void
+ {
+ $factory = new Factory;
+ $factory->fake();
+
+ $factory->run(['php', 'artisan', 'migrate']);
+
+ $factory->assertRan(['php', 'artisan', 'migrate']);
+ $factory->assertRanTimes(['php', 'artisan', 'migrate'], 1);
+ $factory->assertNotRan(['php', 'artisan', 'migrate:rollback']);
+ $factory->assertDidntRun(['php', 'artisan', 'migrate:rollback']);
+ $factory->assertRanInOrder([['php', 'artisan', 'migrate']]);
+ }
+
public function testAssertingThatNothingRan()
{
$factory = new Factory;
diff --git a/tests/Redis/RedisEventsTest.php b/tests/Redis/RedisEventsTest.php
index dadc0b71a4..a696e83fac 100644
--- a/tests/Redis/RedisEventsTest.php
+++ b/tests/Redis/RedisEventsTest.php
@@ -49,11 +49,9 @@ public function testCommandFailedEventIsDispatched(): void
$redis = $this->createRedis($connection);
- try {
- $redis->get('key');
- } catch (Exception) {
- // Expected
- }
+ $this->expectExceptionObject($exception);
+
+ $redis->get('key');
}
public function testCommandExecutedEventIsNotDispatchedWhenCommandFails(): void
@@ -258,6 +256,9 @@ public function testListenForFailuresNoOpsWhenEventsUnbound(): void
$this->assertTrue(true);
}
+ /**
+ * Create a Redis proxy using the given connection.
+ */
private function createRedis(m\MockInterface|RedisConnection $connection): RedisProxy
{
$pool = m::mock(RedisPool::class);
@@ -274,6 +275,9 @@ private function createRedis(m\MockInterface|RedisConnection $connection): Redis
);
}
+ /**
+ * Create a mock Redis connection for the given command.
+ */
private function createMockRedisConnection(
string $command = 'get',
mixed $returnValue = 'value',
@@ -284,6 +288,7 @@ private function createMockRedisConnection(
if ($exception !== null) {
$mockPhpRedis->shouldReceive($command)
+ ->once()
->andThrow($exception);
} else {
$mockPhpRedis->shouldReceive($command)
diff --git a/tests/Session/Middleware/AuthenticateSessionTest.php b/tests/Session/Middleware/AuthenticateSessionTest.php
index 6115daffa5..ed83795686 100644
--- a/tests/Session/Middleware/AuthenticateSessionTest.php
+++ b/tests/Session/Middleware/AuthenticateSessionTest.php
@@ -7,7 +7,10 @@
use Hypervel\Auth\AuthenticationException;
use Hypervel\Auth\AuthManager;
use Hypervel\Container\Container;
+use Hypervel\Contracts\Auth\Authenticatable;
use Hypervel\Contracts\Auth\Factory as AuthFactory;
+use Hypervel\Contracts\Auth\Guard;
+use Hypervel\Contracts\Auth\StatefulGuard;
use Hypervel\Http\Request;
use Hypervel\Session\ArraySessionHandler;
use Hypervel\Session\Middleware\AuthenticateSession;
@@ -27,7 +30,7 @@ public function testHandleWithoutSession(): void
$middleware = new AuthenticateSession($authFactory);
$response = $middleware->handle($request, $next);
- $this->assertEquals('next-1', $response);
+ $this->assertSame('next-1', $response);
}
public function testHandleWithSessionWithoutRequestUser(): void
@@ -43,13 +46,16 @@ public function testHandleWithSessionWithoutRequestUser(): void
$next = fn () => 'next-2';
$middleware = new AuthenticateSession($authFactory);
$response = $middleware->handle($request, $next);
- $this->assertEquals('next-2', $response);
+ $this->assertSame('next-2', $response);
}
public function testHandleWithSessionWithoutAuthPassword(): void
{
$user = new class {
- public function getAuthPassword()
+ /**
+ * Get the user's authentication password.
+ */
+ public function getAuthPassword(): ?string
{
return null;
}
@@ -69,13 +75,16 @@ public function getAuthPassword()
$middleware = new AuthenticateSession($authFactory);
$response = $middleware->handle($request, $next);
- $this->assertEquals('next-3', $response);
+ $this->assertSame('next-3', $response);
}
public function testHandleWithSessionWithUserAuthPasswordOnRequestViaRememberFalse(): void
{
$user = new class {
- public function getAuthPassword()
+ /**
+ * Get the user's authentication password.
+ */
+ public function getAuthPassword(): string
{
return 'my-pass-(*&^%$#!@';
}
@@ -88,23 +97,26 @@ public function getAuthPassword()
$request->setHypervelSession($session);
$authFactory = m::mock(AuthFactory::class);
- $authFactory->shouldReceive('viaRemember')->andReturn(false);
- $authFactory->shouldReceive('getDefaultDriver')->andReturn('web');
- $authFactory->shouldReceive('user')->andReturn(null);
+ $authFactory->shouldReceive('viaRemember')->once()->andReturn(false);
+ $authFactory->shouldReceive('getDefaultDriver')->times(3)->andReturn('web');
+ $authFactory->shouldReceive('user')->once()->andReturn(null);
// expected MAC for current password when storing in session:
- $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
+ $authFactory->shouldReceive('hashPasswordForCookie')->times(2)->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
$middleware = new AuthenticateSession($authFactory);
$response = $middleware->handle($request, fn () => 'next-4');
- $this->assertEquals('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web'));
- $this->assertEquals('next-4', $response);
+ $this->assertSame('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web'));
+ $this->assertSame('next-4', $response);
}
public function testHandleWithInvalidPasswordHash(): void
{
$user = new class {
- public function getAuthPassword()
+ /**
+ * Get the user's authentication password.
+ */
+ public function getAuthPassword(): string
{
return 'my-pass-(*&^%$#!@';
}
@@ -120,13 +132,12 @@ public function getAuthPassword()
$request->setHypervelSession($session);
$authFactory = m::mock(AuthFactory::class);
- $authFactory->shouldReceive('viaRemember')->andReturn(true);
+ $authFactory->shouldReceive('viaRemember')->once()->andReturn(true);
$authFactory->shouldReceive('getRecallerName')->once()->andReturn('recaller-name');
$authFactory->shouldReceive('logoutCurrentDevice')->once()->andReturn(null);
- $authFactory->shouldReceive('getDefaultDriver')->andReturn('web');
- $authFactory->shouldReceive('user')->andReturn(null);
+ $authFactory->shouldReceive('getDefaultDriver')->once()->andReturn('web');
// expected MAC for current password (won't match cookie):
- $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
+ $authFactory->shouldReceive('hashPasswordForCookie')->once()->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
$this->assertNotNull($session->get('a'));
$this->assertNotNull($session->get('b'));
@@ -140,9 +151,9 @@ public function getAuthPassword()
$middleware->handle($request, fn () => 'next-7');
} catch (AuthenticationException $e) {
$message = $e->getMessage();
- $this->assertEquals('i-wanna-go-home', $e->redirectTo($request));
+ $this->assertSame('i-wanna-go-home', $e->redirectTo($request));
}
- $this->assertEquals('Unauthenticated.', $message);
+ $this->assertSame('Unauthenticated.', $message);
// ensure session is flushed:
$this->assertNull($session->get('a'));
@@ -192,7 +203,10 @@ public function getAuthPassword(): string
public function testHandleWithInvalidIncookiePasswordHashViaRememberTrue(): void
{
$user = new class {
- public function getAuthPassword()
+ /**
+ * Get the user's authentication password.
+ */
+ public function getAuthPassword(): string
{
return 'my-pass-(*&^%$#!@';
}
@@ -208,13 +222,12 @@ public function getAuthPassword()
$request->setHypervelSession($session);
$authFactory = m::mock(AuthFactory::class);
- $authFactory->shouldReceive('viaRemember')->andReturn(true);
+ $authFactory->shouldReceive('viaRemember')->once()->andReturn(true);
$authFactory->shouldReceive('getRecallerName')->once()->andReturn('recaller-name');
$authFactory->shouldReceive('logoutCurrentDevice')->once();
- $authFactory->shouldReceive('getDefaultDriver')->andReturn('web');
- $authFactory->shouldReceive('user')->andReturn(null);
+ $authFactory->shouldReceive('getDefaultDriver')->once()->andReturn('web');
// expected MAC for current password (won't match cookie):
- $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
+ $authFactory->shouldReceive('hashPasswordForCookie')->once()->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
$middleware = new AuthenticateSession($authFactory);
// act:
@@ -224,7 +237,7 @@ public function getAuthPassword()
} catch (AuthenticationException $e) {
$message = $e->getMessage();
}
- $this->assertEquals('Unauthenticated.', $message);
+ $this->assertSame('Unauthenticated.', $message);
// ensure session is flushed
$this->assertNull($session->get('password_hash_web'));
@@ -235,7 +248,10 @@ public function getAuthPassword()
public function testHandleWithValidIncookieInvalidInsessionHashViaRememberTrue(): void
{
$user = new class {
- public function getAuthPassword()
+ /**
+ * Get the user's authentication password.
+ */
+ public function getAuthPassword(): string
{
return 'my-pass-(*&^%$#!@';
}
@@ -252,13 +268,12 @@ public function getAuthPassword()
$request->setHypervelSession($session);
$authFactory = m::mock(AuthFactory::class);
- $authFactory->shouldReceive('viaRemember')->andReturn(true);
+ $authFactory->shouldReceive('viaRemember')->once()->andReturn(true);
$authFactory->shouldReceive('getRecallerName')->once()->andReturn('recaller-name');
$authFactory->shouldReceive('logoutCurrentDevice')->once()->andReturn(null);
- $authFactory->shouldReceive('getDefaultDriver')->andReturn('web');
- $authFactory->shouldReceive('user')->andReturn(null);
+ $authFactory->shouldReceive('getDefaultDriver')->times(3)->andReturn('web');
// expected MAC for current password (matches cookie but not session):
- $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
+ $authFactory->shouldReceive('hashPasswordForCookie')->times(2)->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
// act:
$middleware = new AuthenticateSession($authFactory);
@@ -268,7 +283,7 @@ public function getAuthPassword()
} catch (AuthenticationException $e) {
$message = $e->getMessage();
}
- $this->assertEquals('Unauthenticated.', $message);
+ $this->assertSame('Unauthenticated.', $message);
// ensure session is flushed:
$this->assertNull($session->get('password_hash_web'));
@@ -279,7 +294,10 @@ public function getAuthPassword()
public function testHandleWithValidPasswordInSessionCookieIsEmptyGuardHasUser(): void
{
$user = new class {
- public function getAuthPassword()
+ /**
+ * Get the user's authentication password.
+ */
+ public function getAuthPassword(): string
{
return 'my-pass-(*&^%$#!@';
}
@@ -296,27 +314,60 @@ public function getAuthPassword()
$request->setHypervelSession($session);
$authFactory = m::mock(AuthFactory::class);
- $authFactory->shouldReceive('viaRemember')->andReturn(false);
+ $authFactory->shouldReceive('viaRemember')->once()->andReturn(false);
$authFactory->shouldReceive('getRecallerName')->never();
$authFactory->shouldReceive('logoutCurrentDevice')->never();
- $authFactory->shouldReceive('getDefaultDriver')->andReturn('web');
- $authFactory->shouldReceive('user')->andReturn($user);
+ $authFactory->shouldReceive('getDefaultDriver')->times(3)->andReturn('web');
+ $authFactory->shouldReceive('user')->once()->andReturn($user);
// expected MAC for current password:
- $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
+ $authFactory->shouldReceive('hashPasswordForCookie')->times(2)->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@');
// act:
$middleware = new AuthenticateSession($authFactory);
$response = $middleware->handle($request, fn () => 'next-8');
- $this->assertEquals('next-8', $response);
+ $this->assertSame('next-8', $response);
// ensure session is not flushed:
- $this->assertEquals('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web'));
- $this->assertEquals('1', $session->get('a'));
- $this->assertEquals('2', $session->get('b'));
+ $this->assertSame('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web'));
+ $this->assertSame('1', $session->get('a'));
+ $this->assertSame('2', $session->get('b'));
+ }
+
+ public function testGuardOverrideCanReturnAConcreteGuard(): void
+ {
+ $user = m::mock(Authenticatable::class);
+ $user->shouldReceive('getAuthPassword')->andReturn('password-hash');
+
+ $request = new Request;
+ $request->setUserResolver(fn () => $user);
+ $session = new Store('name', new ArraySessionHandler(1));
+ $request->setHypervelSession($session);
+
+ $guard = m::mock(StatefulGuard::class);
+ $guard->shouldReceive('viaRemember')->once()->andReturn(false);
+ $guard->shouldReceive('hashPasswordForCookie')->twice()->with('password-hash')->andReturn('password-mac');
+ $guard->shouldReceive('user')->once()->andReturn(null);
+
+ $authFactory = m::mock(AuthFactory::class);
+ $authFactory->shouldReceive('guard')->andReturn($guard);
+ $authFactory->shouldReceive('getDefaultDriver')->andReturn('web');
+
+ $middleware = new class($authFactory) extends AuthenticateSession {
+ /**
+ * Get the guard instance that should be used by the middleware.
+ */
+ protected function guard(): Guard
+ {
+ return $this->auth->guard();
+ }
+ };
+
+ $this->assertSame('next', $middleware->handle($request, fn () => 'next'));
+ $this->assertSame('password-mac', $session->get('password_hash_web'));
}
- // REMOVED: Laravel's OldFormatCookie* backward-compatibility tests;
- // Hypervel 0.4 is greenfield and only accepts HMAC artifacts.
+ // REMOVED: Laravel's OldFormatCookie* backward-compatibility tests,
+ // including guards without hashPasswordForCookie(); only HMAC artifacts are supported.
public function testHandleWithRawRememberCookiePasswordHashLogsOut(): void
{
$user = new class {
diff --git a/tests/Session/SessionStoreBackedEnumTest.php b/tests/Session/SessionStoreBackedEnumTest.php
index 0e98dac1f2..7933738b76 100644
--- a/tests/Session/SessionStoreBackedEnumTest.php
+++ b/tests/Session/SessionStoreBackedEnumTest.php
@@ -85,12 +85,6 @@ public function testPutWithArrayOfStringKeys(): void
$this->assertSame('abc123', $session->get(SessionKey::Token));
}
- /**
- * Test that put() normalizes enum keys in arrays.
- * Note: PHP auto-converts BackedEnums to their values when used as array keys,
- * so by the time the array reaches put(), keys are already strings.
- * This test verifies the overall behavior works correctly.
- */
public function testPutWithMixedArrayKeysUsingEnumValues(): void
{
$session = $this->getSession();
diff --git a/tests/Session/SessionStoreTest.php b/tests/Session/SessionStoreTest.php
index 72f1a6f0f0..33a332443a 100644
--- a/tests/Session/SessionStoreTest.php
+++ b/tests/Session/SessionStoreTest.php
@@ -818,10 +818,12 @@ public function testBackedEnumKeyForget(): void
{
$session = $this->getSession();
$session->put(SessionTestKey::User, 'Taylor');
+ $session->put('User', 'keep');
$this->assertTrue($session->has('user'));
$session->forget(SessionTestKey::User);
$this->assertFalse($session->has('user'));
+ $this->assertSame('keep', $session->get('User'));
$session->put(SessionTestKey::User, 'Taylor');
$session->put(SessionTestKey::Settings, 'dark-mode');
diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php
index 848aded671..0f9de1153e 100644
--- a/tests/Support/SupportCollectionTest.php
+++ b/tests/Support/SupportCollectionTest.php
@@ -2313,6 +2313,28 @@ public function testSortByMany($collection): void
setlocale(LC_ALL, $defaultLocale);
}
+ #[DataProvider('collectionClassProvider')]
+ public function testSortByManyWithNumericFractions(string $collection): void
+ {
+ $data = new $collection([
+ ['score' => 1.9, 'rank' => 5],
+ ['score' => '1.1', 'rank' => 2],
+ ['score' => 1.1, 'rank' => 1],
+ ['score' => -1.1, 'rank' => 0],
+ ['score' => '-1.9', 'rank' => 0],
+ ]);
+
+ $this->assertSame([4, 3, 2, 1, 0], $data->sortBy([
+ ['score', 'asc'],
+ ['rank', 'asc'],
+ ], SORT_NUMERIC)->keys()->all());
+
+ $this->assertSame([0, 2, 1, 3, 4], $data->sortBy([
+ ['score', 'desc'],
+ ['rank', 'asc'],
+ ], SORT_NUMERIC)->keys()->all());
+ }
+
#[DataProvider('collectionClassProvider')]
public function testNaturalSortByManyWithNull($collection): void
{
diff --git a/tests/Support/SupportFluentTest.php b/tests/Support/SupportFluentTest.php
index ed85cd7959..454eeffe2e 100644
--- a/tests/Support/SupportFluentTest.php
+++ b/tests/Support/SupportFluentTest.php
@@ -422,7 +422,7 @@ public function testEnumMethod()
$this->assertNull($fluent->enum('int.doesnt_exist', TestBackedEnum::class));
}
- public function testEnumsMethod()
+ public function testEnumsMethod(): void
{
$fluent = new Fluent([
'valid_enum_values' => ['A', 'B'],
@@ -444,9 +444,9 @@ public function testEnumsMethod()
$this->assertEquals([TestStringBackedEnum::A, TestStringBackedEnum::B], $fluent->enums('valid_enum_values', TestStringBackedEnum::class));
- $this->assertEmpty($fluent->enums('invalid_enum_value', TestStringBackedEnum::class));
+ $this->assertEmpty($fluent->enums('invalid_enum_values', TestStringBackedEnum::class));
$this->assertEmpty($fluent->enums('empty_value_request', TestStringBackedEnum::class));
- $this->assertEmpty($fluent->enums('valid_enum_value', TestEnum::class));
+ $this->assertEmpty($fluent->enums('valid_enum_values', TestEnum::class));
$this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('string.a', TestBackedEnum::class));
$this->assertEquals([TestBackedEnum::B], $fluent->enums('string.b', TestBackedEnum::class));
diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php
index 4d1d4106f7..cf0b9bc351 100644
--- a/tests/Support/ValidatedInputTest.php
+++ b/tests/Support/ValidatedInputTest.php
@@ -233,6 +233,37 @@ public function testWhenFilledMethod()
$this->assertFalse($bar);
}
+ public function testWhenEnumMethod(): void
+ {
+ $input = new ValidatedInput(['status' => 'Hello world', 'invalid' => 'invalid', 'age' => '']);
+
+ $status = $invalid = $age = $missing = $default = false;
+
+ $input->whenEnum('status', StringBackedEnum::class, function (StringBackedEnum $value) use (&$status): void {
+ $status = $value;
+ });
+
+ $input->whenEnum('invalid', StringBackedEnum::class, function (StringBackedEnum $value) use (&$invalid): void {
+ $invalid = $value;
+ });
+
+ $input->whenEnum('age', StringBackedEnum::class, function (StringBackedEnum $value) use (&$age): void {
+ $age = $value;
+ });
+
+ $input->whenEnum('missing', StringBackedEnum::class, function (StringBackedEnum $value) use (&$missing): void {
+ $missing = $value;
+ }, function () use (&$default): void {
+ $default = true;
+ });
+
+ $this->assertSame(StringBackedEnum::HelloWorld, $status);
+ $this->assertFalse($invalid);
+ $this->assertFalse($age);
+ $this->assertFalse($missing);
+ $this->assertTrue($default);
+ }
+
public function testMissingMethod()
{
$input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]);
diff --git a/tests/Validation/ValidationDateRuleTest.php b/tests/Validation/ValidationDateRuleTest.php
index f0a39d4b41..db9da6c90a 100644
--- a/tests/Validation/ValidationDateRuleTest.php
+++ b/tests/Validation/ValidationDateRuleTest.php
@@ -48,6 +48,24 @@ public function testBeforeTodayRule(): void
$this->assertEquals('date|before_or_equal:"today"', (string) $rule);
}
+ public function testPastRule(): void
+ {
+ $rule = Rule::date()->past();
+ $this->assertSame('date|before:"now"', (string) $rule);
+
+ $rule = Rule::date()->nowOrPast();
+ $this->assertSame('date|before_or_equal:"now"', (string) $rule);
+ }
+
+ public function testFutureRule(): void
+ {
+ $rule = Rule::date()->future();
+ $this->assertSame('date|after:"now"', (string) $rule);
+
+ $rule = Rule::date()->nowOrFuture();
+ $this->assertSame('date|after_or_equal:"now"', (string) $rule);
+ }
+
public function testAfterSpecificDateRule(): void
{
$rule = Rule::date()->after(CarbonImmutable::parse('2024-01-01'));
diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php
index fb535bc12f..3b7ed47f9d 100644
--- a/types/Collections/Collection.php
+++ b/types/Collections/Collection.php
@@ -28,7 +28,7 @@
assertType('Hypervel\Support\Collection', $collection->flatten());
assertType('Hypervel\Support\LazyCollection', $lazy->flatten());
assertType(
- 'Hypervel\Support\Collection>',
+ "Hypervel\\Support\\Collection<'even'|'odd', Hypervel\\Support\\Collection>",
$collection->groupBy(static fn (int $value): array => [$value % 2 === 0 ? 'even' : 'odd'])
);
@@ -58,6 +58,8 @@
assertType('Hypervel\Support\LazyCollection', LazyCollection::make($lazySource));
/**
+ * Check shared enumerable return and callback types.
+ *
* @param Enumerable $enumerable
*/
function assertEnumerableTypes(Enumerable $enumerable): void
@@ -67,6 +69,148 @@ function assertEnumerableTypes(Enumerable $enumerable): void
assertType('Hypervel\Support\Enumerable', $enumerable->random(2, true));
assertType('float|int', $enumerable->sum(static fn (int $value): int => $value));
assertType('mixed', $enumerable->sum('amount'));
+
+ assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->keyBy(static fn () => Digit::One));
+ assertType('Hypervel\Support\Enumerable', $enumerable->keyBy(static fn () => new Collection(['key'])));
+ assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn () => Digit::One));
+ assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn (int $value): bool => $value > 1));
+ assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn (int $value): bool => $value > 1));
+ assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn () => null, preserveKeys: true));
}
assertEnumerableTypes($collection);
+
+/**
+ * Check eager collection grouping and key inference.
+ *
+ * @param Collection $collection
+ */
+function assertCollectionGroupingTypes(Collection $collection): void
+{
+ assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name'));
+ assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true));
+ assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(['name', 'email']));
+ assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) {
+ assertType('User', $user);
+ assertType('int', $int);
+
+ return 'foo';
+ }));
+ assertType('Hypervel\Support\Collection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0));
+ assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One));
+ assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One));
+ assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One));
+
+ assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) {
+ return 'foo';
+ }, preserveKeys: true));
+
+ assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy('name'));
+ assertType("Hypervel\\Support\\Collection<'foo', User>", $collection->keyBy(function ($user, $int) {
+ assertType('User', $user);
+ assertType('int', $int);
+
+ return 'foo';
+ }));
+ assertType('Hypervel\Support\Collection<0, User>', $collection->keyBy(static fn ($user): int => 0));
+ assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One));
+ assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One));
+ assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One));
+
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([1])->countBy());
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string'));
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy('email'));
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email'));
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0));
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One));
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One));
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) {
+ assertType('string', $string);
+ assertType('int', $int);
+
+ return $string;
+ }));
+
+ assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn (): bool => true));
+ assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn () => null));
+ assertType('Hypervel\Support\Collection', $collection->keyBy(static fn () => new Collection(['key'])));
+ assertType('Hypervel\Support\Collection<(int|string), int>', $collection->countBy(static fn (): bool => true));
+}
+
+/**
+ * Check lazy collection grouping and key inference.
+ *
+ * @param LazyCollection $collection
+ */
+function assertLazyCollectionGroupingTypes(LazyCollection $collection): void
+{
+ assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name'));
+ assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true));
+ assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(['name', 'email']));
+ assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) {
+ assertType('User', $user);
+ assertType('int', $int);
+
+ return 'foo';
+ }));
+ assertType('Hypervel\Support\LazyCollection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0));
+ assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One));
+ assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One));
+ assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One));
+
+ assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) {
+ return 'foo';
+ }, preserveKeys: true));
+
+ assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy('name'));
+ assertType("Hypervel\\Support\\LazyCollection<'foo', User>", $collection->keyBy(function ($user, $int) {
+ assertType('User', $user);
+ assertType('int', $int);
+
+ return 'foo';
+ }));
+ assertType('Hypervel\Support\LazyCollection<0, User>', $collection->keyBy(static fn ($user): int => 0));
+ assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One));
+ assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One));
+ assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One));
+
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([1])->countBy());
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string'));
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy('email'));
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email'));
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0));
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One));
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One));
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) {
+ assertType('string', $string);
+ assertType('int', $int);
+
+ return $string;
+ }));
+
+ assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn (): bool => true));
+ assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn () => null));
+ assertType('Hypervel\Support\LazyCollection', $collection->keyBy(static fn () => new Collection(['key'])));
+ assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection->countBy(static fn (): bool => true));
+}
+
+enum Digit
+{
+ case One;
+ case Two;
+ case Three;
+}
+
+enum NamedDigit: string
+{
+ case One = 'one';
+ case Two = 'two';
+ case Three = 'three';
+}
+
+enum NumberedDigit: int
+{
+ case One = 1;
+ case Two = 2;
+ case Three = 3;
+}
diff --git a/types/Http/Client/PendingRequest.php b/types/Http/Client/PendingRequest.php
new file mode 100644
index 0000000000..beaab4948e
--- /dev/null
+++ b/types/Http/Client/PendingRequest.php
@@ -0,0 +1,65 @@
+{$method}('/foo'));
+ assertType('GuzzleHttp\Promise\PromiseInterface', Http::createPendingRequest()->async()->{$method}('/foo'));
+}
+
+// PHPStan carries async()'s self-out type onto repeated Http::createPendingRequest()
+// expressions in the same scope, although each call creates a fresh request.
+// Keep the state and callback checks below in separate scopes from that loop.
+function (bool $async): void {
+ assertType('Hypervel\Http\Client\Response', Http::createPendingRequest()->withHeaders([])->get('/foo'));
+ assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', Http::async()->get('/foo'));
+
+ $request = Http::createPendingRequest();
+ assertType('Hypervel\Http\Client\Response', $request->send('GET', '/foo'));
+ $request->async();
+ assertType('GuzzleHttp\Promise\PromiseInterface', $request->send('GET', '/foo'));
+ $request->async(false);
+ assertType('Hypervel\Http\Client\Response', $request->get('/foo'));
+
+ assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', Http::createPendingRequest()->async($async)->get('/foo'));
+};
+
+function (): void {
+ $request = Http::createPendingRequest()
+ ->afterResponse(function ($response, $request): string {
+ assertType('Hypervel\Http\Client\Response', $response);
+ assertType('Hypervel\Http\Client\Request|null', $request);
+
+ return 'ignored';
+ })
+ ->afterResponse(static function (Response $response): void {
+ })
+ ->afterResponse(static fn (Response $response): Response => new Response($response->toPsrResponse()));
+
+ assertType('Hypervel\Http\Client\PendingRequest', $request);
+ assertType('Hypervel\Http\Client\Response', $request->get('/foo'));
+};
+
+class PlainHttpPendingRequest extends PendingRequest
+{
+}
+
+assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', (new PlainHttpPendingRequest)->async()->get('/foo'));
+
+/**
+ * @template TAsync of bool = bool
+ * @extends PendingRequest
+ */
+class GenericHttpPendingRequest extends PendingRequest
+{
+}
+
+$genericRequest = (new GenericHttpPendingRequest)->async();
+assertType('GenericHttpPendingRequest', $genericRequest);
+assertType('GuzzleHttp\Promise\PromiseInterface', $genericRequest->get('/foo'));
diff --git a/types/Support/Auth.php b/types/Support/Auth.php
new file mode 100644
index 0000000000..6717b76e0f
--- /dev/null
+++ b/types/Support/Auth.php
@@ -0,0 +1,26 @@
+withLocale('en', fn () => 'foo'));
+ }
+};
+
+$interactsWithData = function (UriQueryString $query): void {
+ assertType('1|2|Hypervel\Support\UriQueryString', $query->whenEnum('foo', TestIntEnum::class, function ($enum) {
+ assertType('TestIntEnum', $enum);
+
+ return 1;
+ }, function () {
+ return 2;
+ }));
+
+ assertType('3|Hypervel\Support\UriQueryString', $query->whenEnum('foo', TestIntEnum::class, function ($enum) {
+ return 3;
+ }));
+
+ assertType('1|2|Hypervel\Support\UriQueryString', $query->whenHas('foo', function ($value) {
+ assertType('mixed', $value);
+
+ return 1;
+ }, function () {
+ return 2;
+ }));
+
+ assertType('3|Hypervel\Support\UriQueryString', $query->whenHas('foo', function ($value) {
+ return 3;
+ }));
+
+ assertType('1|2|Hypervel\Support\UriQueryString', $query->whenFilled('foo', function ($value) {
+ assertType('mixed', $value);
+
+ return 1;
+ }, function () {
+ return 2;
+ }));
+
+ assertType('3|Hypervel\Support\UriQueryString', $query->whenFilled('foo', function ($value) {
+ return 3;
+ }));
+
+ assertType('1|2|Hypervel\Support\UriQueryString', $query->whenMissing('foo', function ($value) {
+ assertType('mixed', $value);
+
+ return 1;
+ }, function () {
+ return 2;
+ }));
+
+ assertType('3|Hypervel\Support\UriQueryString', $query->whenMissing('foo', function ($value) {
+ return 3;
+ }));
+};
+
+enum TestIntEnum: int
+{
+}