From 780d999c4b1c0ac75b9fc50722b57893935e9aa3 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Wed, 1 Jul 2026 16:27:08 +0330 Subject: [PATCH 1/3] fix(model): wrap scope result back into ModelBuilder in __callStatic Model::__callStatic() applied a local scope directly on the raw Builder from newQuery() and returned it as-is. Chaining anything after that first scope (another scope, with(), etc.) hit a plain Query\Builder that has no idea what a scope is. --- src/Eloquent/Model.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index bfa2a13..5539f36 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -863,8 +863,18 @@ public static function __callStatic(string $name, array $parameters): mixed $scope = 'scope' . ucfirst($name); if (method_exists($instance, $scope)) { - $query = $instance->newQuery(); - return $instance->$scope($query, ...$parameters); + $query = $instance->newQuery(); + $result = $instance->$scope($query, ...$parameters); + + // Keep the chain model-aware (wrap back into ModelBuilder) so + // further calls — another scope, with(), etc. — can still be + // chained after this one. Without this, a second scope call + // like Model::active()->adult() fails with "Call to undefined + // method Query\Builder::adult()", because the raw Builder + // returned here has no idea what a "scope" is. + return $result instanceof Builder + ? new ModelBuilder($result, static::class) + : $result; } // Forward to ModelBuilder (select, where, orderBy, limit, etc.) so From 4fac5b0ca4205b0e3afd1795c45e03684c6bcf00 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Wed, 1 Jul 2026 16:27:24 +0330 Subject: [PATCH 2/3] fix(model-builder): resolve local scopes in ModelBuilder::__call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit __call() forwarded every unknown method straight to the raw Builder, including scope names. A second scope in a chain — e.g. Model::active()->adult() — failed with "Call to undefined method Query\Builder::adult()". Now it checks for a matching scopeXxx() on the model first. --- src/Eloquent/ModelBuilder.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Eloquent/ModelBuilder.php b/src/Eloquent/ModelBuilder.php index d8979c4..ecfd06a 100644 --- a/src/Eloquent/ModelBuilder.php +++ b/src/Eloquent/ModelBuilder.php @@ -229,12 +229,28 @@ public function with(string|array ...$relations): EagerBuilder * (the ModelBuilder) so the chain stays intact and the IDE continues * to see the correct type. * + * Local scopes (scopeXxx on the model) are resolved here too, so a + * scope stays chainable after another scope — e.g. + * Model::active()->adult()->get() — and not just after plain Builder + * methods. Without this, $name is forwarded straight to the raw + * Builder, which knows nothing about scopes and throws "Call to + * undefined method". + * * @param string $name * @param array $arguments * @return static|mixed */ public function __call(string $name, array $arguments): mixed { + $modelInstance = new $this->modelClass(); + $scope = 'scope' . ucfirst($name); + + if (method_exists($modelInstance, $scope)) { + $result = $modelInstance->$scope($this->builder, ...$arguments); + + return $result === $this->builder ? $this : $result; + } + $result = $this->builder->$name(...$arguments); // If Builder returned itself, keep the chain on ModelBuilder From d0a71d8b34e14d762e8d88c57da8010899ba48d8 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Wed, 1 Jul 2026 16:27:56 +0330 Subject: [PATCH 3/3] test(model): cover chained local scopes and scope+builder-method chains Adds scopeAdult() to the TestUser fixture and two regression tests: scope->scope->get() and scope->where()->orderBy()->get(). Both reproduce the original bug before the fix and pass after it. --- tests/Integration/ModelCrudTest.php | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Integration/ModelCrudTest.php b/tests/Integration/ModelCrudTest.php index af1dd88..e71b22b 100644 --- a/tests/Integration/ModelCrudTest.php +++ b/tests/Integration/ModelCrudTest.php @@ -407,6 +407,41 @@ public function test_local_scope(): void $this->assertSame('Alice', $active->first()->name); } + /** + * Regression: chaining a second local scope after the first one used + * to fail with "Call to undefined method Foxdb\Query\Builder::adult()" + * because the first scope call returned a raw Query\Builder instead of + * a model-aware ModelBuilder, so nothing could resolve scopeAdult() + * as a scope anymore. + */ + public function test_chained_local_scopes(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25, 'is_active' => 1]); // active adult + TestUser::create(['name' => 'Bob', 'email' => 'b@test.com', 'age' => 15, 'is_active' => 1]); // active minor + TestUser::create(['name' => 'Carol', 'email' => 'c@test.com', 'age' => 30, 'is_active' => 0]); // inactive adult + + $result = TestUser::active()->adult()->get(); + + $this->assertCount(1, $result); + $this->assertSame('Alice', $result->first()->name); + } + + /** + * Regression: a scope must also stay chainable with ordinary Builder + * methods that come after it (where, orderBy, ...), not just other + * scopes. + */ + public function test_local_scope_chained_with_builder_methods(): void + { + TestUser::create(['name' => 'Alice', 'email' => 'a@test.com', 'age' => 25, 'is_active' => 1]); + TestUser::create(['name' => 'Bob', 'email' => 'b@test.com', 'age' => 30, 'is_active' => 1]); + + $result = TestUser::active()->where('age', 30)->orderByDesc('age')->get(); + + $this->assertCount(1, $result); + $this->assertSame('Bob', $result->first()->name); + } + // ----------------------------------------------------------------------- // Pagination // ----------------------------------------------------------------------- @@ -488,4 +523,9 @@ public function scopeActive(Builder $q): Builder { return $q->where('is_active', 1); } + + public function scopeAdult(Builder $q): Builder + { + return $q->where('age', '>=', 18); + } } \ No newline at end of file