Skip to content

Commit 6540965

Browse files
committed
feat: convert QueryException to ModelNotFoundException in implicit route binding
When implicit route model binding encounters a QueryException (e.g., from integer overflow or type mismatch), convert it to a ModelNotFoundException instead of letting the 500 propagate. This ensures invalid route parameters like '/users/99999999999999999999' return a 404 rather than a 500.
1 parent 031c384 commit 6540965

File tree

3 files changed

+95
-11
lines changed

3 files changed

+95
-11
lines changed

src/Illuminate/Database/Eloquent/Model.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,11 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
250250
*/
251251
protected static $isBroadcasting = true;
252252

253+
/**
254+
* Indicates if query exceptions during implicit route model binding should be reported.
255+
*/
256+
protected static bool $reportRouteModelBindingExceptions = true;
257+
253258
/**
254259
* The Eloquent query builder class to use for the model.
255260
*
@@ -633,6 +638,14 @@ public static function handleMissingAttributeViolationUsing(?callable $callback)
633638
static::$missingAttributeViolationCallback = $callback;
634639
}
635640

641+
/**
642+
* Report query exceptions during implicit route model binding.
643+
*/
644+
public static function reportRouteModelBindingExceptions(bool $value = true): void
645+
{
646+
static::$reportRouteModelBindingExceptions = $value;
647+
}
648+
636649
/**
637650
* Execute a callback without broadcasting any model events for all model types.
638651
*
@@ -2447,6 +2460,14 @@ public static function preventsAccessingMissingAttributes()
24472460
return static::$modelsShouldPreventAccessingMissingAttributes;
24482461
}
24492462

2463+
/**
2464+
* Determine if query exceptions during implicit route model binding should be reported.
2465+
*/
2466+
public static function reportsRouteModelBindingExceptions(): bool
2467+
{
2468+
return static::$reportRouteModelBindingExceptions;
2469+
}
2470+
24502471
/**
24512472
* Get the broadcast channel route definition that is associated with the given entity.
24522473
*

src/Illuminate/Routing/ImplicitRouteBinding.php

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Contracts\Routing\UrlRoutable;
66
use Illuminate\Database\Eloquent\ModelNotFoundException;
7+
use Illuminate\Database\QueryException;
78
use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException;
89
use Illuminate\Support\Reflector;
910
use Illuminate\Support\Str;
@@ -45,19 +46,27 @@ public static function resolveForRoute($container, $route)
4546
? 'resolveSoftDeletableRouteBinding'
4647
: 'resolveRouteBinding';
4748

48-
if ($parent instanceof UrlRoutable &&
49-
! $route->preventsScopedBindings() &&
50-
($route->enforcesScopedBindings() || array_key_exists($parameterName, $route->bindingFields()))) {
51-
$childRouteBindingMethod = $route->allowsTrashedBindings() && $instance::isSoftDeletable()
52-
? 'resolveSoftDeletableChildRouteBinding'
53-
: 'resolveChildRouteBinding';
54-
55-
if (! $model = $parent->{$childRouteBindingMethod}(
56-
$parameterName, $parameterValue, $route->bindingFieldFor($parameterName)
57-
)) {
49+
try {
50+
if ($parent instanceof UrlRoutable &&
51+
! $route->preventsScopedBindings() &&
52+
($route->enforcesScopedBindings() || array_key_exists($parameterName, $route->bindingFields()))) {
53+
$childRouteBindingMethod = $route->allowsTrashedBindings() && $instance::isSoftDeletable()
54+
? 'resolveSoftDeletableChildRouteBinding'
55+
: 'resolveChildRouteBinding';
56+
57+
if (! $model = $parent->{$childRouteBindingMethod}(
58+
$parameterName, $parameterValue, $route->bindingFieldFor($parameterName)
59+
)) {
60+
throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]);
61+
}
62+
} elseif (! $model = $instance->{$routeBindingMethod}($parameterValue, $route->bindingFieldFor($parameterName))) {
5863
throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]);
5964
}
60-
} elseif (! $model = $instance->{$routeBindingMethod}($parameterValue, $route->bindingFieldFor($parameterName))) {
65+
} catch (QueryException $e) {
66+
if ($instance::reportsRouteModelBindingExceptions()) {
67+
report($e);
68+
}
69+
6170
throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]);
6271
}
6372

tests/Routing/ImplicitRouteBindingTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,31 @@
22

33
namespace Illuminate\Tests\Routing;
44

5+
use Exception;
56
use Illuminate\Container\Container;
7+
use Illuminate\Contracts\Debug\ExceptionHandler;
68
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\ModelNotFoundException;
10+
use Illuminate\Database\QueryException;
711
use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException;
812
use Illuminate\Routing\ImplicitRouteBinding;
913
use Illuminate\Routing\Route;
14+
use Mockery as m;
15+
use PHPUnit\Framework\Attributes\DataProvider;
1016
use PHPUnit\Framework\TestCase;
1117

1218
include_once 'Enums.php';
1319

1420
class ImplicitRouteBindingTest extends TestCase
1521
{
22+
protected function tearDown(): void
23+
{
24+
Container::setInstance(null);
25+
Model::reportRouteModelBindingExceptions();
26+
27+
parent::tearDown();
28+
}
29+
1630
public function test_it_can_resolve_the_implicit_backed_enum_route_bindings_for_the_given_route()
1731
{
1832
$action = ['uses' => function (CategoryBackedEnum $category) {
@@ -126,9 +140,49 @@ public function test_it_can_resolve_the_implicit_model_route_bindings_for_the_gi
126140

127141
ImplicitRouteBinding::resolveForRoute($container, $route);
128142
}
143+
144+
#[DataProvider('shouldReportProvider')]
145+
public function testItThrowsModelNotFoundExceptionOnQueryException(bool $shouldReport): void
146+
{
147+
Model::reportRouteModelBindingExceptions($shouldReport);
148+
149+
$mock = m::mock(ExceptionHandler::class);
150+
151+
if ($shouldReport) {
152+
$mock->shouldReceive('report')->once()->with(m::type(QueryException::class));
153+
} else {
154+
$mock->shouldReceive('report')->never();
155+
}
156+
157+
$container = Container::getInstance();
158+
$container->instance(ExceptionHandler::class, $mock);
159+
160+
$action = ['uses' => fn (ImplicitRouteBindingUserWithQueryException $user) => $user];
161+
162+
$route = new Route('GET', '/test', $action);
163+
$route->parameters = ['user' => 'invalid-value'];
164+
$route->prepareForSerialization();
165+
$this->expectException(ModelNotFoundException::class);
166+
167+
ImplicitRouteBinding::resolveForRoute($container, $route);
168+
}
169+
170+
/** @return list<array{0: bool}> */
171+
public static function shouldReportProvider(): array
172+
{
173+
return [[true], [false]];
174+
}
129175
}
130176

131177
class ImplicitRouteBindingUser extends Model
132178
{
133179
//
134180
}
181+
182+
class ImplicitRouteBindingUserWithQueryException extends Model
183+
{
184+
public function resolveRouteBinding($value, $field = null): void
185+
{
186+
throw new QueryException('mysql', 'select * from users where id = ?', [$value], new Exception('Out of range value'));
187+
}
188+
}

0 commit comments

Comments
 (0)