Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6fcbe21
Fix pagination loop detection for advancing continuation links and cu…
binaryfire Oct 23, 2026
30795dc
Fix retained database migration state in tests
binaryfire Oct 23, 2026
c414f40
Add Pennant database write guards and shared cache flushing
binaryfire Oct 24, 2026
f7cfbc3
Merge 0.4 into framework maintenance
binaryfire Oct 24, 2026
4a5065a
fix(testing): catch native alarms during coroutine timeout cancellation
binaryfire Oct 24, 2026
4d70530
Merge branch '0.4' into fix/framework-maintenance
binaryfire Oct 25, 2026
ede0375
Clarify verification timing and scope
binaryfire Oct 25, 2026
becb745
Correct OAuth client-credentials request encoding
binaryfire Oct 25, 2026
9aec8c5
Port queue job durations, timeout notification, flush filters and enu…
binaryfire Oct 25, 2026
2844140
Port LazyCollection between-clause tests
binaryfire Oct 25, 2026
f6b8507
Port refreshed attribute test for increment and decrement
binaryfire Oct 25, 2026
2d5900a
Ignore unusable translation groups and reject more unsafe locales
binaryfire Oct 25, 2026
0a48c87
Fix Number parsing above 32 bits and negative zero formatting
binaryfire Oct 25, 2026
84002cf
Port Redis lock refresh tests for serialized connections
binaryfire Oct 25, 2026
383c0ef
Fix collection mode, lazy combine and shift edge cases
binaryfire Oct 25, 2026
78c55ea
Respect morph owner keys and fix array conditions in Eloquent whereNot
binaryfire Oct 25, 2026
df795f6
Enforce the morph map when resolving morph types
binaryfire Oct 25, 2026
cb7a9d9
Report disabled restarts from queue:restart
binaryfire Oct 25, 2026
c2e10ea
Restore omitted about command JSON assertions
binaryfire Oct 25, 2026
f21e15c
Choose plural forms by absolute value
binaryfire Oct 25, 2026
bc51347
Rethrow database lock concurrency errors inside transactions
binaryfire Oct 25, 2026
e43dfaa
Accept arrays in assertDatabaseCount
binaryfire Oct 25, 2026
ca65b86
Resend multipart streams on retry and fix HTTP option layer merging
binaryfire Oct 25, 2026
4f03764
Accept enums in environment checks
binaryfire Oct 25, 2026
2f1bc70
Count collection mode floats by their exact value
binaryfire Oct 25, 2026
f0aaf22
Skip recursive merging for scalar HTTP options
binaryfire Oct 25, 2026
4b29a90
Keep option maps on empty arrays and report raw request bodies
binaryfire Oct 25, 2026
482efd8
Use each target's own key when a MorphTo owner key is inferred
binaryfire Oct 25, 2026
9a92f90
Stop link and cursor pagination when a page and its continuation repeat
binaryfire Oct 25, 2026
e572929
Skip outbox restart polling when interruption polling is disabled
binaryfire Oct 25, 2026
325c968
Document null job durations and correct the locale validation note
binaryfire Oct 25, 2026
e880a75
Document inherited request payloads
binaryfire Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,17 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi

### Verification

**Run verification commands sequentially** — Never overlap test, lint, formatting, or static-analysis commands. Wait for each to finish before starting another. A tool’s built-in parallelism, such as ParaTest workers, is fine.
**Run verification commands sequentially** — Never overlap formatting, static analysis or tests. Wait for each command to finish before starting another. A tool’s built-in parallelism, such as ParaTest workers, is fine.

During implementation, run new or changed test files immediately. After completing a coherent implementation slice, run the affected package or focused test suite.
**During implementation**, run each new or changed test file immediately with `./vendor/bin/phpunit --no-progress path/to/TestClass.php`.

Use checks that match the change. For isolated changes, run `composer lint:fix`, `composer analyse`, and the affected tests. Run a single affected test file with PHPUnit. Use ParaTest when the affected tests span multiple files. Only run `composer fix` when changes could affect code beyond the affected tests; it already runs formatting, analysis, and all test suites, so do not run those checks separately first.
Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation.

After a failure, fix the cause and rerun only affected tests, formatting or static analysis. Then finish any verification not yet run. Repeat the full test suite only if the fix itself has framework-wide impact.
**At the end of a coherent implementation slice**, run `composer lint:fix`, `composer analyse`, and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection.

Run the full test suite only when the change has framework-wide impact. In that case, use `composer fix`, which includes formatting, analysis and all test suites; do not run those checks separately first. For focused changes, include separate Testbench or dogfood checks when the changed behavior affects those workflows.

After a failure or review correction, fix the cause, repeat only checks affected by the correction, and finish any outstanding verification. Do not restart the entire sequence merely because another review round occurred.

## Development Conventions

Expand Down
14 changes: 10 additions & 4 deletions src/cache/src/DatabaseLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,16 @@ public function acquire(): bool
*/
public function release(): bool
{
$connection = $this->connection();

try {
return $this->connection()->table($this->table)
return $connection->table($this->table)
->where('key', $this->name)
->where('owner', $this->owner)
->delete() > 0;
} catch (Throwable $e) {
if ($this->causedByConcurrencyError($e)) {
// Inside a transaction the error may have aborted the transaction, so the caller must see it.
if ($this->causedByConcurrencyError($e) && $connection->transactionLevel() === 0) {
return true;
}

Expand All @@ -165,12 +168,15 @@ public function forceRelease(): void
*/
public function pruneExpiredLocks(): void
{
$connection = $this->connection();

try {
$this->connection()->table($this->table)
$connection->table($this->table)
->where('expiration', '<=', $this->currentTime())
->delete();
} catch (Throwable $e) {
if (! $this->causedByConcurrencyError($e)) {
// Inside a transaction the error may have aborted the transaction, so the caller must see it.
if (! $this->causedByConcurrencyError($e) || $connection->transactionLevel() > 0) {
throw $e;
}
}
Expand Down
34 changes: 24 additions & 10 deletions src/collections/src/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,23 +136,37 @@ public function median(string|array|null $key = null): float|int|null
*/
public function mode(string|array|null $key = null): ?array
{
if ($this->isEmpty()) {
$collection = (isset($key) ? $this->pluck($key) : $this)
->reject(fn ($item) => is_null($item));

if ($collection->isEmpty()) {
return null;
}

$collection = isset($key) ? $this->pluck($key) : $this;

$counts = $this->newInstance();

// @phpstan-ignore offsetAssign.valueType (PHPStan infers empty collection as Collection<*NEVER*, *NEVER*>)
$collection->each(fn ($value) => $counts[$value] = isset($counts[$value]) ? $counts[$value] + 1 : 1);
$floats = [];

$collection->each(function ($value) use ($counts, &$floats) {
if (is_float($value)) {
// A string cast rounds to the precision setting, so key floats by their exact value.
// Adding zero turns -0.0, which equals 0.0, into 0.0.
$floats[$key = sprintf('%.17h', $value + 0.0)] = $value;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
$key = $value;
}

// @phpstan-ignore offsetAssign.valueType (PHPStan infers empty collection as Collection<*NEVER*, *NEVER*>)
$counts[$key] = isset($counts[$key]) ? $counts[$key] + 1 : 1;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
});

$sorted = $counts->sort();

$highestValue = $sorted->last();

// Whole-number float keys such as "2" become integer keys, so look up every key.
return $sorted->filter(fn ($value) => $value == $highestValue)
->sort()->keys()->all();
->sort()->keys()->map(fn ($key) => $floats[$key] ?? $key)->all();
}

/**
Expand Down Expand Up @@ -1227,10 +1241,6 @@ public function shift(int $count = 1): mixed
throw new InvalidArgumentException('Number of shifted items may not be less than zero.');
}

if ($this->isEmpty()) {
return null;
}

if ($count === 0) {
return $this->newInstance();
}
Expand All @@ -1239,6 +1249,10 @@ public function shift(int $count = 1): mixed
return array_shift($this->items);
}

if ($this->isEmpty()) {
return $this->newInstance();
}

$results = [];

$collectionCount = $this->count();
Expand Down
7 changes: 3 additions & 4 deletions src/collections/src/LazyCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Stringable as BaseStringable;
use Traversable;
use UnitEnum;
use ValueError;

/**
* @template TKey of array-key
Expand Down Expand Up @@ -917,9 +918,7 @@ public function combine(Arrayable|iterable|callable $values): static

foreach ($this as $key) {
if (! $values->valid()) {
trigger_error($errorMessage, E_USER_WARNING);

break;
throw new ValueError($errorMessage);
}

yield $key => $values->current();
Expand All @@ -928,7 +927,7 @@ public function combine(Arrayable|iterable|callable $values): static
}

if ($values->valid()) {
trigger_error($errorMessage, E_USER_WARNING);
throw new ValueError($errorMessage);
}
});
}
Expand Down
10 changes: 8 additions & 2 deletions src/console/src/Scheduling/ManagesAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use DateTimeZone;
use Hypervel\Support\Reflector;
use UnitEnum;

use function Hypervel\Support\enum_value;

trait ManagesAttributes
{
Expand Down Expand Up @@ -89,9 +92,12 @@ trait ManagesAttributes
/**
* Limit the environments the command should run in.
*/
public function environments(mixed $environments): static
public function environments(UnitEnum|array|string $environments): static
{
$this->environments = is_array($environments) ? $environments : func_get_args();
$this->environments = array_map(
enum_value(...),
is_array($environments) ? $environments : func_get_args()
);

return $this;
}
Expand Down
3 changes: 2 additions & 1 deletion src/contracts/src/Foundation/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;

interface Application extends Container
{
Expand Down Expand Up @@ -105,7 +106,7 @@ public function joinPaths(?string $basePath, string $path = ''): string;
/**
* Get or check the current application environment.
*/
public function environment(array|string ...$environments): bool|string;
public function environment(UnitEnum|array|string ...$environments): bool|string;

/**
* Determine if the application is in the local environment.
Expand Down
6 changes: 6 additions & 0 deletions src/database/src/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|stri
*/
public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static
{
if (is_array($column)) {
$this->query->whereNot($column, $operator, $value, $boolean);

return $this;
}

return $this->where($column, $operator, $value, $boolean . ' not');
}

Expand Down
19 changes: 17 additions & 2 deletions src/database/src/Eloquent/Concerns/HasRelationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -414,14 +414,17 @@ protected function morphInstanceTo(string|int $target, string $name, string $typ
static::getActualClassNameForMorph($target)
);

return $this->newMorphTo(
$relation = $this->newMorphTo(
$instance->newQuery(),
$this,
$id,
$ownerKey ?? $instance->getKeyName(),
$type,
$name
);

// The loaded type's key serves this relation, but other target types may use a different one.
return $ownerKey === null ? $relation->withInferredOwnerKey() : $relation;
}

/**
Expand All @@ -441,10 +444,22 @@ protected function newMorphTo(Builder $query, Model $parent, string $foreignKey,

/**
* Retrieve the actual class name for a given morph class.
*
* @throws ClassMorphViolationException
*/
public static function getActualClassNameForMorph(string|int $class): string
{
return Arr::get(Relation::morphMap() ?: [], $class, (string) $class);
$morphMap = Relation::morphMap() ?: [];

if (array_key_exists($class, $morphMap)) {
return $morphMap[$class];
}

if (Relation::requiresMorphMap()) {
throw new ClassMorphViolationException((string) $class);
}

return (string) $class;
}

/**
Expand Down
55 changes: 32 additions & 23 deletions src/database/src/Eloquent/Concerns/QueriesRelationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ protected function getBelongsToRelation(MorphTo $relation, string $type): Belong
return $this->model->belongsTo(
$type,
$relation->getForeignKeyName(),
$relation->getOwnerKeyName()
$relation->getDeclaredOwnerKeyName()
);
});

Expand Down Expand Up @@ -562,19 +562,24 @@ public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string|
public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $boolean = 'and'): static
{
if (is_string($relation)) {
/** @var MorphTo<*, *> $relation */
$relation = $this->getRelationWithoutConstraints($relation);
}

// The morph columns belong to the declaring model. A relation built for a loaded morph type queries
// the target's table instead, so qualifying through the relation would name the wrong table.
$morphType = $relation->getParent()->qualifyColumn($relation->getMorphType());
$foreignKey = $relation->getQualifiedForeignKeyName();

if (is_null($model)) {
// @phpstan-ignore method.notFound, return.type (getMorphType exists on MorphTo; mixin returns $this at runtime)
return $this->whereNull($relation->qualifyColumn($relation->getMorphType()), $boolean);
// @phpstan-ignore return.type (mixin returns $this at runtime)
return $this->whereNull($morphType, $boolean);
}

if (is_string($model)) {
$model = $this->getMorphTypeForQuery($model);

// @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation)
return $this->where($relation->qualifyColumn($relation->getMorphType()), $model, null, $boolean);
return $this->where($morphType, $model, null, $boolean);
}

$models = BaseCollection::wrap($model);
Expand All @@ -583,13 +588,13 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b
throw new InvalidArgumentException('Collection given to whereMorphedTo method may not be empty.');
}

return $this->where(function ($query) use ($relation, $models) {
$models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) {
$query->orWhere(function ($query) use ($relation, $models) {
// @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation)
$query->where($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass())
// @phpstan-ignore method.notFound (getForeignKeyName exists on MorphTo, not base Relation)
->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey());
return $this->where(function ($query) use ($relation, $models, $morphType, $foreignKey) {
$models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation, $morphType, $foreignKey) {
$query->orWhere(function ($query) use ($relation, $models, $morphType, $foreignKey) {
$query->where($morphType, $models->first()->getMorphClass())
->whereIn($foreignKey, $models->map(
fn ($model) => $model->getAttribute($relation->getDeclaredOwnerKeyName() ?? $model->getKeyName())
));
});
});
}, null, null, $boolean);
Expand All @@ -607,20 +612,24 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b
public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $boolean = 'and'): static
{
if (is_string($relation)) {
/** @var MorphTo<*, *> $relation */
$relation = $this->getRelationWithoutConstraints($relation);
}

// Qualify through the declaring model, as whereMorphedTo() does.
$morphType = $relation->getParent()->qualifyColumn($relation->getMorphType());
$foreignKey = $relation->getQualifiedForeignKeyName();

if (is_null($model)) {
// @phpstan-ignore method.notFound, return.type (getMorphType exists on MorphTo; mixin returns $this at runtime)
return $this->whereNotNull($relation->qualifyColumn($relation->getMorphType()), $boolean);
// @phpstan-ignore return.type (mixin returns $this at runtime)
return $this->whereNotNull($morphType, $boolean);
}

if (is_string($model)) {
$model = $this->getMorphTypeForQuery($model);

return $this->whereNot(fn ($query) => $query->whereNullSafeEquals(
// @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation)
$relation->qualifyColumn($relation->getMorphType()),
$morphType,
$model
), null, null, $boolean);
}
Expand All @@ -631,13 +640,13 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string
throw new InvalidArgumentException('Collection given to whereNotMorphedTo method may not be empty.');
}

return $this->whereNot(function ($query) use ($relation, $models) {
$models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) {
$query->orWhere(function ($query) use ($relation, $models) {
// @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation)
$query->whereNullSafeEquals($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass())
// @phpstan-ignore method.notFound (getForeignKeyName exists on MorphTo, not base Relation)
->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey());
return $this->whereNot(function ($query) use ($relation, $models, $morphType, $foreignKey) {
$models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation, $morphType, $foreignKey) {
$query->orWhere(function ($query) use ($relation, $models, $morphType, $foreignKey) {
$query->whereNullSafeEquals($morphType, $models->first()->getMorphClass())
->whereIn($foreignKey, $models->map(
fn ($model) => $model->getAttribute($relation->getDeclaredOwnerKeyName() ?? $model->getKeyName())
));
});
});
}, null, null, $boolean);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function attributesFor(Model $model): array

return $relationship instanceof MorphTo ? [
$relationship->getMorphType() => $this->factory instanceof Factory ? $this->factory->newModel()->getMorphClass() : $this->factory->getMorphClass(),
$relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()),
$relationship->getForeignKeyName() => $this->resolver($relationship->getDeclaredOwnerKeyName()),
] : [
$relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()),
];
Expand Down
Loading
Loading