Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
33f1077
Run the Nested Set node tests with UUID keys on every database
binaryfire Oct 7, 2026
1994f7d
Run the Nested Set scoped tests with UUID keys on every database
binaryfire Oct 7, 2026
a2898eb
Port the Nested Set metadata cache tests
binaryfire Oct 7, 2026
a2c029f
Honor custom builder properties on nested set models
binaryfire Oct 7, 2026
2738804
Index both nested set bounds and check the schema on every database
binaryfire Oct 7, 2026
8074f04
Credit Aimeos in the Nested Set license
binaryfire Oct 7, 2026
da5e268
Keep nested set saves retryable and reconcile node creation
binaryfire Oct 7, 2026
5f389ef
Credit Lunar's maintainers in the Nested Set license
binaryfire Oct 7, 2026
62b69e5
Read nested set node data through scopes and joins and skip create's …
binaryfire Oct 7, 2026
0d69ccf
Route scalar descendant lookups through the query's connection and re…
binaryfire Oct 7, 2026
5eea7f6
Keep loaded outside parents when linking nested set collections
binaryfire Oct 7, 2026
d953b18
Scale nested set eager relation constraints and matching
binaryfire Oct 7, 2026
5a1293f
Restore the default-migrations environment value after each case
binaryfire Oct 8, 2026
7a2d7b1
Delete nested set subtrees children first, after deleting observers a…
binaryfire Oct 8, 2026
d25ea19
Repair nested set trees from plain rows, and check trees on the writer
binaryfire Oct 8, 2026
da70a0f
Cover scoped nested set rebuilds and new scope roots
binaryfire Oct 8, 2026
3b2bcd6
Assign rebuilt nodes' tree scope directly and read lookup depths as p…
binaryfire Oct 8, 2026
6caa08e
Type nested set builders, relations and collections
binaryfire Oct 8, 2026
925b092
Record Nested Set upstream tracking and sync mappings
binaryfire Oct 8, 2026
b2ef46c
Type builder macros added by soft deletes and Scout
binaryfire Oct 8, 2026
fc56017
Require PHPStan 2.3 for framework development
binaryfire Oct 8, 2026
bf39a7f
Reconcile the Nested Set guide with upstream documentation
binaryfire Oct 8, 2026
1dc3bad
Record the Nested Set upstream checkpoint
binaryfire Oct 8, 2026
0628959
Merge 0.4 into the Nested Set branch
binaryfire Oct 8, 2026
b3f58c5
Forget a loaded parent on parent_id assignment and qualify node data …
binaryfire Oct 8, 2026
ac916a3
Resolve the builder trait conflict for typed custom Nested Set builders
binaryfire Oct 8, 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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
"mockery/mockery": "^1.6.15",
"opis/json-schema": "^2.6.0",
"pda/pheanstalk": "^8.0.2",
"phpstan/phpstan": "^2.2.15",
"phpstan/phpstan": "^2.3",
"phpunit/phpunit": "13.3.*",
"pusher/pusher-php-server": "^7.2",
"resend/resend-php": "^1.0",
Expand Down
7 changes: 4 additions & 3 deletions docs/upstream-sync/sync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,10 @@ spatie/laravel-permission:

aimeos/laravel-nestedset:
branch: master
checked_through: null
last_reviewed_pr: null
sync_date: null
checked_through: 90ea384febeaaa97967f43b1af9661d4edf2354a
last_reviewed_pr: 25
sync_date: '2026-10-08'
notes: The only tracked Nested Set upstream; lazychaser/laravel-nestedset and lunarphp/nestedset are not tracked because Aimeos carries their applicable fixes. src/NodeTrait.php maps to src/nested-set/src/HasNode.php; QueryBuilder, Collection and the relations map to src/nested-set/src/Eloquent; NestedSet and NestedSetServiceProvider keep their names. Hypervel keeps the original package's grouped method order rather than Aimeos's sort by visibility and name, so find upstream changes by method name. Upstream tests keep their class names in tests/NestedSet; tests/models map to tests/NestedSet/Fixtures/Models, and tests/data to the bases' getMockCategories() and getMockMenuItems(). Add ported cases to the shared bases, which also run on MySQL, MariaDB and PostgreSQL through the subclasses in tests/Integration/NestedSet/Database. README.md maps to src/docs/nested-set.md.

PHP-Open-Source-Saver/jwt-auth:
branch: main
Expand Down
3 changes: 2 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
includes:
- src/database/extension.neon
- src/foundation/extension.neon
- src/scout/extension.neon

parameters:
tmpDir: .cache/phpstan
Expand Down Expand Up @@ -57,7 +58,7 @@ parameters:
- '#Access to an undefined property Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::\$#'
- '#Call to an undefined method Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::#'

# NodeTrait methods - mixed in at app level, not on base Model class
# HasNode methods - mixed in at app level, not on base Model class
- message: '#Call to an undefined method Hypervel\\Database\\Eloquent\\Model::new(ScopedQuery|NestedSetQuery)\(\)#'
path: src/nested-set/*

Expand Down
2 changes: 2 additions & 0 deletions phpstan.types.neon.dist
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
includes:
- src/database/extension.neon
- src/foundation/extension.neon
- src/scout/extension.neon

parameters:
level: max
tmpDir: .cache/phpstan-types
scanDirectories:
- src/database/src/PHPStan
- src/foundation/src/PHPStan
- src/scout/src/PHPStan
paths:
- types
2 changes: 1 addition & 1 deletion src/database/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.24",
"phpstan/phpstan": "^2.2.15"
"phpstan/phpstan": "^2.3"
},
"suggest": {
"fakerphp/faker": "Required to use Eloquent model factories (^1.24)."
Expand Down
12 changes: 12 additions & 0 deletions src/database/extension.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
parametersSchema:
eloquentBuilderMacros: arrayOf(string(), string())

parameters:
# Builder macros added by model traits' global scopes: trait => interface declaring the macros.
eloquentBuilderMacros:
Hypervel\Database\Eloquent\SoftDeletes: Hypervel\Database\PHPStan\SoftDeletingMacros

services:
-
class: Hypervel\Database\PHPStan\BuilderMacroResolver
arguments:
macros: %eloquentBuilderMacros%
-
class: Hypervel\Database\PHPStan\ModelScopeMethodResolver
-
Expand Down
15 changes: 10 additions & 5 deletions src/database/src/Eloquent/SoftDeletes.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,18 @@ public function forceDelete(): int|bool|null

$this->forceDeleting = true;

return tap($this->delete(), function ($deleted) {
// Reset the flag even when deletion throws, so a later delete() stays a soft delete.
try {
$deleted = $this->delete();
} finally {
$this->forceDeleting = false;
}

if ($deleted) {
$this->fireModelEvent('forceDeleted', false);
}
});
if ($deleted) {
$this->fireModelEvent('forceDeleted', false);
}

return $deleted;
}

/**
Expand Down
99 changes: 99 additions & 0 deletions src/database/src/PHPStan/BuilderMacroResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\PHPStan;

use PHPStan\Analyser\OutOfClassScope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StaticType;
use PHPStan\Type\Type;

/**
* Resolve the builder macros that model traits add through their global scopes.
*
* Packages map each trait to an interface declaring its macros through the
* eloquentBuilderMacros parameter. A generic interface receives the model type.
*/
class BuilderMacroResolver
{
private readonly OutOfClassScope $scope;

/**
* Create a builder macro resolver.
*
* @param array<class-string, class-string> $macros
*/
public function __construct(
private readonly ReflectionProvider $reflectionProvider,
private readonly array $macros,
) {
$this->scope = new OutOfClassScope;
}

/**
* Determine whether the model's traits add the named builder macro.
*/
public function hasMacro(ClassReflection $modelClass, string $methodName): bool
{
return $this->macroInterface($modelClass, $methodName) !== null;
}

/**
* Resolve a trait macro called on a builder for the given model type.
*/
public function resolve(ClassReflection $builderClass, Type $modelType, string $methodName): ?MethodReflection
{
$modelClasses = $modelType->getObjectClassReflections();

if (count($modelClasses) !== 1) {
return null;
}

$macroInterface = $this->macroInterface($modelClasses[0], $methodName);

if ($macroInterface === null) {
return null;
}

$interfaceType = $macroInterface->isGeneric()
? new GenericObjectType($macroInterface->getName(), [$modelType])
: new ObjectType($macroInterface->getName());
$method = $interfaceType->getMethod($methodName, $this->scope);

// Fluent macros return the builder they run on.
return $macroInterface->getNativeMethod($methodName)->getVariants()[0]->getReturnType() instanceof StaticType
? new ForwardedFluentMethodReflection($builderClass, $method)
: $method;
}

/**
* Return the interface declaring a macro added by one of the model's traits.
*/
private function macroInterface(ClassReflection $modelClass, string $methodName): ?ClassReflection
{
$traits = null;

foreach ($this->macros as $trait => $interface) {
$interfaceClass = $this->reflectionProvider->getClass($interface);

// Builder::hasMacro() matches local macro names exactly.
if (! $interfaceClass->hasNativeMethod($methodName)
|| $interfaceClass->getNativeMethod($methodName)->getName() !== $methodName) {
continue;
}

$traits ??= $modelClass->getTraits(true);

if (isset($traits[$trait])) {
return $interfaceClass;
}
}

return null;
}
}
18 changes: 12 additions & 6 deletions src/database/src/PHPStan/ForwardedBuilderMethodExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class ForwardedBuilderMethodExtension implements MethodsClassReflectionExtension
public function __construct(
private readonly ReflectionProvider $reflectionProvider,
private readonly ModelScopeMethodResolver $scopeMethods,
private readonly BuilderMacroResolver $builderMacros,
) {
$this->scope = new OutOfClassScope;
}
Expand Down Expand Up @@ -86,7 +87,8 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN
return null;
}

$cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName);
// Keep the exact spelling: builder macro names are case-sensitive.
$cacheKey = $classReflection->getCacheKey() . ':' . $methodName;

if (array_key_exists($cacheKey, $this->methods)) {
$cachedMethod = $this->methods[$cacheKey];
Expand Down Expand Up @@ -118,6 +120,13 @@ private function resolveEloquentBuilderMethod(
): ?MethodReflection {
$modelType = $this->templateType($classReflection, EloquentBuilder::class, 'TModel');

// Local macros run before named scopes in Builder::__call().
$macro = $this->builderMacros->resolve($classReflection, $modelType, $methodName);

if ($macro !== null) {
return $macro;
}

if ($this->hasNamedScope($modelType, $methodName)) {
return null;
}
Expand Down Expand Up @@ -174,10 +183,6 @@ private function resolveRelationMethod(ClassReflection $classReflection, string
return new ForwardedFluentMethodReflection($classReflection, $method);
}

if ($this->hasNamedScope($relatedType, $methodName)) {
return null;
}

$method = $this->resolveEloquentBuilderMethod($eloquentBuilder, $methodName);

if ($method !== null) {
Expand All @@ -186,7 +191,8 @@ private function resolveRelationMethod(ClassReflection $classReflection, string
: $method;
}

if (! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) {
if ($this->hasNamedScope($relatedType, $methodName)
|| ! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) {
return null;
}

Expand Down
9 changes: 7 additions & 2 deletions src/database/src/PHPStan/ForwardedModelMethodExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class ForwardedModelMethodExtension implements MethodsClassReflectionExtension
public function __construct(
private readonly ReflectionProvider $reflectionProvider,
private readonly ModelScopeMethodResolver $scopeMethods,
private readonly BuilderMacroResolver $builderMacros,
) {
$this->scope = new OutOfClassScope;
}
Expand Down Expand Up @@ -63,7 +64,8 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN
return null;
}

$cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName);
// Keep the exact spelling: builder macro names are case-sensitive.
$cacheKey = $classReflection->getCacheKey() . ':' . $methodName;

if (array_key_exists($cacheKey, $this->methods)) {
$cachedMethod = $this->methods[$cacheKey];
Expand All @@ -77,7 +79,10 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN
$builderType = $this->activeBuilderType($classReflection);
$scopeMethod = $this->scopeMethods->resolve($classReflection, $methodName);

if (($scopeMethod === null || $this->hasNativeMethod($builderType, $methodName))
// Native builder methods and local macros run before named scopes.
if (($scopeMethod === null
|| $this->hasNativeMethod($builderType, $methodName)
|| $this->builderMacros->hasMacro($classReflection, $methodName))
&& $builderType->hasMethod($methodName)->yes()) {
$method = new ForwardedModelMethodReflection(
$classReflection,
Expand Down
14 changes: 7 additions & 7 deletions src/database/src/PHPStan/NamedScopeMethodExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class NamedScopeMethodExtension implements MethodsClassReflectionExtension
public function __construct(
private readonly ReflectionProvider $reflectionProvider,
private readonly ModelScopeMethodResolver $scopeMethods,
private readonly BuilderMacroResolver $builderMacros,
) {
$this->scope = new OutOfClassScope;
}
Expand Down Expand Up @@ -72,7 +73,8 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN
return null;
}

$cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName);
// Keep the exact spelling: builder macro names are case-sensitive.
$cacheKey = $classReflection->getCacheKey() . ':' . $methodName;

if (array_key_exists($cacheKey, $this->methods)) {
$cachedMethod = $this->methods[$cacheKey];
Expand All @@ -90,12 +92,10 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN
$scopeMethod = $this->scopeMethods->resolve($modelClass, $methodName);
$method = null;

if ($scopeMethod !== null && ! $this->nativeMethodTakesPrecedence(
$classReflection,
$methodName,
$queryType,
$static,
)) {
// Local builder macros run before named scopes in Builder::__call().
if ($scopeMethod !== null
&& ! $this->builderMacros->hasMacro($modelClass, $methodName)
&& ! $this->nativeMethodTakesPrecedence($classReflection, $methodName, $queryType, $static)) {
$method = new NamedScopeMethodReflection(
$classReflection,
$modelClass,
Expand Down
55 changes: 55 additions & 0 deletions src/database/src/PHPStan/SoftDeletingMacros.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database\PHPStan;

use Hypervel\Database\Eloquent\Model;

/**
* Declare the builder macros that SoftDeletingScope adds, for static analysis.
*
* Keep these signatures in step with the scope's macros.
*
* @template TModel of Model
*/
interface SoftDeletingMacros
{
/**
* Include soft-deleted models in the results, or exclude them when false.
*/
public function withTrashed(bool $withTrashed = true): static;

/**
* Exclude soft-deleted models from the results.
*/
public function withoutTrashed(): static;

/**
* Only include soft-deleted models in the results.
*/
public function onlyTrashed(): static;

/**
* Restore the matching soft-deleted models.
*/
public function restore(): int;

/**
* Get the first matching model or create it, then restore it.
*
* @param array<string, mixed> $attributes
* @param array<string, mixed> $values
* @return TModel
*/
public function restoreOrCreate(array $attributes = [], array $values = []): Model;

/**
* Create the model or get the matching one, then restore it.
*
* @param array<string, mixed> $attributes
* @param array<string, mixed> $values
* @return TModel
*/
public function createOrRestore(array $attributes = [], array $values = []): Model;
}
2 changes: 1 addition & 1 deletion src/docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ Custom query builders may declare their binding-slot names through the third `Qu
<a name="static-analysis"></a>
### Static Analysis

The Hypervel database package includes a PHPStan extension that understands named scopes and query methods forwarded through Eloquent models, builders, and relationships. See the [static analysis setup instructions](/docs/{{version}}/installation#static-analysis) to enable it in your application.
The Hypervel database package includes a PHPStan extension that understands named scopes, soft deleting query methods such as `withTrashed` and `restore`, and query methods forwarded through Eloquent models, builders, and relationships. See the [static analysis setup instructions](/docs/{{version}}/installation#static-analysis) to enable it in your application.

A scope that declares no return type, or declares `void`, `null`, or the query builder, stays chainable. Declaring a broader type such as `mixed` or `object` tells the analyzer the scope may return something else, so that type is preserved. When a scope declares a union containing the query builder, such as `Builder|int`, the builder becomes the chainable receiver and the remaining types are kept.

Expand Down
Loading
Loading