Repository: laravel/framework (php)

Issue:
Scoped route binding in apiResource routes no longer working
<!-- DO NOT THROW THIS AWAY -->
<!-- Fill out the FULL versions with patch versions -->

- Laravel Version: 9.14.1
- PHP Version: 8.1.2
- Database Driver & Version:

### Description:

I've recently updated Laravel from 9.13.0 to 9.14.1 and I have a series of nested apiResource routes defined like so:
`$router->apiResource('/product/{product}/image', ImageController::class)->scoped();`

I've found that when I request an image that doesn't belong to the product id specified in the URL, I get a 404 like I'd expect in v9.13.0, but in v9.14.1 the request falls through to the controller incorrectly... I may be doing something wrong but it has been working until today.

### Steps To Reproduce:

Define a scoped API resource like the one above.
Request a URL where the child model is not owned by the parent model.

Expected: The app throws a 404
Actual: The app calls the controller

I've made a new laravel project to demonstrate: https://github.com/joe-pritchard/scoped-resource-bug

There's a test in there at `Tests\Feature\Models\ChildTest` which shows the issue. The test fails, but it will pass if you downgrade the `laravel/framework` dependency to 9.13.0

<!-- If possible, please provide a GitHub repository to demonstrate your issue -->
<!-- laravel new bug-report --github="--public" -->


Pull request description:
[9.x] Map integer parameter to parameter name when resolving binding field
Fixes #42541

First of all, I'm really sorry about all the issues the initial PR has caused 😞

## Summary

This PR reverts most of the changes of the original PR (#42425) while still fixing the bug described in the PR. The only method that was changed was the `bindingFieldFor` method of the `Route` class. All changes to `ImplicitRouteBinding` and `RouteUri` were reverted.

To fix the bug described in the original PR, we now resolve the parameter index to the parameter name by looking it up in the route's `parameterNames` array. 

```php
public function bindingFieldFor($parameter)
{
    if (is_int($parameter))  {
        $parameter = $this->parameterNames()[$parameter];
    }

    return $this->bindingFields[$parameter] ?? null;
}
```

This means that we no longer have to change the structure of the `bindingFields` array itself like in the original PR. The bug described in the related issue (#42541) was due to the fact that the `ResourceRouteRegistrar` _also_ filled the route's binding fields with `null` values, just like my change in the original PR. The problem with that is that both of these implementations used `null` to signal the exact opposite of each other. My PR used `null` to mean "don't scope this parameter", whereas the `ResourceRouteRegistrar` used it to mean "scope this parameter but default to the model's route key name".

I left in all the additional tests I added since all the problems that got reported due to my original PR were not covered by any existing tests.

Patch:
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index bff45979a5..cc45c172e3 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -7,6 +7,7 @@
          convertErrorsToExceptions="true"
          convertNoticesToExceptions="true"
          convertWarningsToExceptions="true"
+         printerClass="Illuminate\Tests\IgnoreSkippedPrinter"
          processIsolation="false"
          stopOnError="false"
          stopOnFailure="false"
diff --git a/src/Illuminate/Auth/Middleware/Authorize.php b/src/Illuminate/Auth/Middleware/Authorize.php
index f173d9eb01..1af05beb2f 100644
--- a/src/Illuminate/Auth/Middleware/Authorize.php
+++ b/src/Illuminate/Auth/Middleware/Authorize.php
@@ -75,7 +75,7 @@ protected function getModel($request, $model)
         if ($this->isClassName($model)) {
             return trim($model);
         } else {
-            return $request->route($model, null) ?:
+            return $request->route($model, null) ??
                 ((preg_match("/^['\"](.*)['\"]$/", trim($model), $matches)) ? $matches[1] : null);
         }
     }
diff --git a/src/Illuminate/Routing/ImplicitRouteBinding.php b/src/Illuminate/Routing/ImplicitRouteBinding.php
index 8d597559dd..4d75630a0e 100644
--- a/src/Illuminate/Routing/ImplicitRouteBinding.php
+++ b/src/Illuminate/Routing/ImplicitRouteBinding.php
@@ -46,7 +46,7 @@ public static function resolveForRoute($container, $route)
                         ? 'resolveSoftDeletableRouteBinding'
                         : 'resolveRouteBinding';
 
-            if ($parent instanceof UrlRoutable && ($route->enforcesScopedBindings() || $route->bindingFieldFor($parameterName) !== null)) {
+            if ($parent instanceof UrlRoutable && ($route->enforcesScopedBindings() || array_key_exists($parameterName, $route->bindingFields()))) {
                 $childRouteBindingMethod = $route->allowsTrashedBindings() && in_array(SoftDeletes::class, class_uses_recursive($instance))
                             ? 'resolveSoftDeletableChildRouteBinding'
                             : 'resolveChildRouteBinding';
diff --git a/src/Illuminate/Routing/Route.php b/src/Illuminate/Routing/Route.php
index 25be726a1b..941a1ff0b5 100755
--- a/src/Illuminate/Routing/Route.php
+++ b/src/Illuminate/Routing/Route.php
@@ -535,9 +535,11 @@ public function signatureParameters($conditions = [])
      */
     public function bindingFieldFor($parameter)
     {
-        $fields = is_int($parameter) ? array_values($this->bindingFields) : $this->bindingFields;
+        if (is_int($parameter)) {
+            $parameter = $this->parameterNames()[$parameter];
+        }
 
-        return $fields[$parameter] ?? null;
+        return $this->bindingFields[$parameter] ?? null;
     }
 
     /**
diff --git a/src/Illuminate/Routing/RouteUri.php b/src/Illuminate/Routing/RouteUri.php
index 113612f932..323717741e 100644
--- a/src/Illuminate/Routing/RouteUri.php
+++ b/src/Illuminate/Routing/RouteUri.php
@@ -44,27 +44,19 @@ public static function parse($uri)
         $bindingFields = [];
 
         foreach ($matches[0] as $match) {
-            $parameter = trim($match, '{}?');
-
-            if (! str_contains($parameter, ':')) {
-                $bindingFields[$parameter] = null;
-
+            if (! str_contains($match, ':')) {
                 continue;
             }
 
-            $segments = explode(':', $parameter);
+            $segments = explode(':', trim($match, '{}?'));
 
             $bindingFields[$segments[0]] = $segments[1];
 
             $uri = str_contains($match, '?')
-                    ? str_replace($match, '{'.$segments[0].'?}', $uri)
-                    : str_replace($match, '{'.$segments[0].'}', $uri);
+                ? str_replace($match, '{'.$segments[0].'?}', $uri)
+                : str_replace($match, '{'.$segments[0].'}', $uri);
         }
 
-        $bindingFields = ! empty(array_filter($bindingFields))
-            ? $bindingFields
-            : [];
-
         return new static($uri, $bindingFields);
     }
 }
diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php
index 9a4ca19b54..5b59302112 100644
--- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php
+++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php
@@ -581,7 +581,7 @@ public function validateDigitsBetween($attribute, $value, $parameters)
 
         $length = strlen((string) $value);
 
-        return ! preg_match('/[^0-9.]/', $value)
+        return ! preg_match('/[^0-9]/', $value)
                     && $length >= $parameters[0] && $length <= $parameters[1];
     }
 


Test patch:
diff --git a/tests/Auth/AuthorizeMiddlewareTest.php b/tests/Auth/AuthorizeMiddlewareTest.php
index 83b9adcd85..8cdcf7aaaa 100644
--- a/tests/Auth/AuthorizeMiddlewareTest.php
+++ b/tests/Auth/AuthorizeMiddlewareTest.php
@@ -178,6 +178,24 @@ public function testSimpleAbilityWithStringParameterFromRouteParameter()
         $this->assertSame('success', $response->content());
     }
 
+    public function testSimpleAbilityWithStringParameter0FromRouteParameter()
+    {
+        $this->gate()->define('view-dashboard', function ($user, $param) {
+            return $param === '0';
+        });
+
+        $this->router->get('dashboard/{route_parameter}', [
+            'middleware' => Authorize::class.':view-dashboard,route_parameter',
+            'uses' => function () {
+                return 'success';
+            },
+        ]);
+
+        $response = $this->router->dispatch(Request::create('dashboard/0', 'GET'));
+
+        $this->assertSame('success', $response->content());
+    }
+
     public function testModelTypeUnauthorized()
     {
         $this->expectException(AuthorizationException::class);
diff --git a/tests/IgnoreSkippedPrinter.php b/tests/IgnoreSkippedPrinter.php
new file mode 100644
index 0000000000..521c3e6aa4
--- /dev/null
+++ b/tests/IgnoreSkippedPrinter.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Illuminate\Tests;
+
+use PHPUnit\Framework\TestResult;
+use PHPUnit\Runner\Version;
+use PHPUnit\TextUI\DefaultResultPrinter as PHPUnit9ResultPrinter;
+use PHPUnit\TextUI\ResultPrinter as PHPUnit8ResultPrinter;
+
+if (class_exists(Version::class) && (int) Version::series()[0] >= 9) {
+    class IgnoreSkippedPrinter extends PHPUnit9ResultPrinter
+    {
+        protected function printSkipped(TestResult $result): void
+        {
+            //
+        }
+    }
+} else {
+    class IgnoreSkippedPrinter extends PHPUnit8ResultPrinter
+    {
+        protected function printSkipped(TestResult $result): void
+        {
+            //
+        }
+    }
+}
diff --git a/tests/Routing/RouteUriTest.php b/tests/Routing/RouteUriTest.php
index 3b0f7def22..f5790258dd 100644
--- a/tests/Routing/RouteUriTest.php
+++ b/tests/Routing/RouteUriTest.php
@@ -51,27 +51,22 @@ public function uriProvider()
             [
                 '/foo/{bar}/baz/{qux:slug}',
                 '/foo/{bar}/baz/{qux}',
-                ['bar' => null, 'qux' => 'slug'],
+                ['qux' => 'slug'],
             ],
             [
                 '/foo/{bar}/baz/{qux:slug}',
                 '/foo/{bar}/baz/{qux}',
-                ['bar' => null, 'qux' => 'slug'],
-            ],
-            [
-                '/foo/{bar:slug}/baz/{qux}',
-                '/foo/{bar}/baz/{qux}',
-                ['bar' => 'slug', 'qux' => null],
+                ['qux' => 'slug'],
             ],
             [
                 '/foo/{bar}/baz/{qux:slug?}',
                 '/foo/{bar}/baz/{qux?}',
-                ['bar' => null, 'qux' => 'slug'],
+                ['qux' => 'slug'],
             ],
             [
                 '/foo/{bar}/baz/{qux:slug?}/{test:id?}',
                 '/foo/{bar}/baz/{qux?}/{test?}',
-                ['bar' => null, 'qux' => 'slug', 'test' => 'id'],
+                ['qux' => 'slug', 'test' => 'id'],
             ],
         ];
     }
diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php
index 00c42519f6..c11843d44e 100644
--- a/tests/Routing/RoutingRouteTest.php
+++ b/tests/Routing/RoutingRouteTest.php
@@ -1706,6 +1706,23 @@ public function testParentChildImplicitBindingsWhereOnlySomeParametersAreScoped(
         $this->assertSame('2|another-test-slug|3', $router->dispatch(Request::create('foo/2/another-test-slug/3', 'GET'))->getContent());
     }
 
+    public function testApiResourceScopingWhenChildDoesNotBelongToParent()
+    {
+        ResourceRegistrar::singularParameters();
+        $router = $this->getRouter();
+        $router->apiResource(
+            'teams.users',
+            RouteTestNestedResourceControllerWithMissingUser::class,
+            ['only' => ['show']],
+        )
+            ->middleware(SubstituteBindings::class)
+            ->scoped();
+
+        $this->expectException(ModelNotFoundException::class);
+
+        $router->dispatch(Request::create('teams/1/users/2', 'GET'));
+    }
+
     public function testParentChildImplicitBindingsProperlyCamelCased()
     {
         $router = $this->getRouter();
@@ -2109,6 +2126,13 @@ public function show(RoutingTestUserModel $fooBar)
     }
 }
 
+class RouteTestNestedResourceControllerWithMissingUser extends Controller
+{
+    public function show(RoutingTestTeamWithoutUserModel $team, RoutingTestUserModel $user)
+    {
+    }
+}
+
 class RouteTestClosureMiddlewareController extends Controller
 {
     public function __construct()
@@ -2366,6 +2390,14 @@ public function firstOrFail()
     }
 }
 
+class RoutingTestTeamWithoutUserModel extends RoutingTestTeamModel
+{
+    public function users()
+    {
+        throw new ModelNotFoundException();
+    }
+}
+
 class ActionStub
 {
     public function __invoke()
diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php
index 08e292d853..e2fddc7d4e 100755
--- a/tests/Validation/ValidationValidatorTest.php
+++ b/tests/Validation/ValidationValidatorTest.php
@@ -2337,12 +2337,6 @@ public function testValidateDigits()
 
         $v = new Validator($trans, ['foo' => '+12.3'], ['foo' => 'digits_between:1,6']);
         $this->assertFalse($v->passes());
-
-        $v = new Validator($trans, ['foo' => '1.2'], ['foo' => 'digits_between:1,10']);
-        $this->assertTrue($v->passes());
-
-        $v = new Validator($trans, ['foo' => '0.9876'], ['foo' => 'digits_between:1,5']);
-        $this->assertTrue($v->fails());
     }
 
     public function testValidateSize()