Skip to content

Commit 281ed58

Browse files
[9.x] Adds implicit Enum route binding (#40281)
* Adds implicit Backed Enum route binding * Fixes tests on PHP 8.0 * Only accepts backed enums with `string` backing type * Refactors name of `isParameterBackedEnum` * Rewords docs * formatting * fix variable Co-authored-by: Taylor Otwell <[email protected]>
1 parent 14155b6 commit 281ed58

File tree

12 files changed

+254
-12
lines changed

12 files changed

+254
-12
lines changed

src/Illuminate/Foundation/Exceptions/Handler.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Illuminate\Http\JsonResponse;
1818
use Illuminate\Http\RedirectResponse;
1919
use Illuminate\Http\Response;
20+
use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException;
2021
use Illuminate\Routing\Router;
2122
use Illuminate\Session\TokenMismatchException;
2223
use Illuminate\Support\Arr;
@@ -85,6 +86,7 @@ class Handler implements ExceptionHandlerContract
8586
protected $internalDontReport = [
8687
AuthenticationException::class,
8788
AuthorizationException::class,
89+
BackedEnumCaseNotFoundException::class,
8890
HttpException::class,
8991
HttpResponseException::class,
9092
ModelNotFoundException::class,
@@ -352,6 +354,7 @@ public function render($request, Throwable $e)
352354
protected function prepareException(Throwable $e)
353355
{
354356
return match (true) {
357+
$e instanceof BackedEnumCaseNotFoundException => new NotFoundHttpException($e->getMessage(), $e),
355358
$e instanceof ModelNotFoundException => new NotFoundHttpException($e->getMessage(), $e),
356359
$e instanceof AuthorizationException => new AccessDeniedHttpException($e->getMessage(), $e),
357360
$e instanceof TokenMismatchException => new HttpException(419, $e->getMessage(), $e),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Illuminate\Routing\Exceptions;
4+
5+
use RuntimeException;
6+
7+
class BackedEnumCaseNotFoundException extends RuntimeException
8+
{
9+
/**
10+
* Create a new exception instance.
11+
*
12+
* @param string $backedEnumClass
13+
* @param string $case
14+
* @return void
15+
*/
16+
public function __construct($backedEnumClass, $case)
17+
{
18+
parent::__construct("Case [{$case}] not found on Backed Enum [{$backedEnumClass}].");
19+
}
20+
}

src/Illuminate/Routing/ImplicitRouteBinding.php

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Illuminate\Contracts\Routing\UrlRoutable;
66
use Illuminate\Database\Eloquent\ModelNotFoundException;
77
use Illuminate\Database\Eloquent\SoftDeletes;
8+
use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException;
89
use Illuminate\Support\Reflector;
910
use Illuminate\Support\Str;
1011

@@ -18,12 +19,15 @@ class ImplicitRouteBinding
1819
* @return void
1920
*
2021
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
22+
* @throws \Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException
2123
*/
2224
public static function resolveForRoute($container, $route)
2325
{
2426
$parameters = $route->parameters();
2527

26-
foreach ($route->signatureParameters(UrlRoutable::class) as $parameter) {
28+
$route = static::resolveBackedEnumsForRoute($route, $parameters);
29+
30+
foreach ($route->signatureParameters(['subClass' => UrlRoutable::class]) as $parameter) {
2731
if (! $parameterName = static::getParameterName($parameter->getName(), $parameters)) {
2832
continue;
2933
}
@@ -60,6 +64,38 @@ public static function resolveForRoute($container, $route)
6064
}
6165
}
6266

67+
/**
68+
* Resolve the Backed Enums route bindings for the route.
69+
*
70+
* @param \Illuminate\Routing\Route $route
71+
* @param array $parameters
72+
* @return \Illuminate\Routing\Route
73+
*
74+
* @throws \Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException
75+
*/
76+
protected static function resolveBackedEnumsForRoute($route, $parameters)
77+
{
78+
foreach ($route->signatureParameters(['backedEnum' => true]) as $parameter) {
79+
if (! $parameterName = static::getParameterName($parameter->getName(), $parameters)) {
80+
continue;
81+
}
82+
83+
$parameterValue = $parameters[$parameterName];
84+
85+
$backedEnumClass = (string) $parameter->getType();
86+
87+
$backedEnum = $backedEnumClass::tryFrom((string) $parameterValue);
88+
89+
if (is_null($backedEnum)) {
90+
throw new BackedEnumCaseNotFoundException($backedEnumClass, $parameterValue);
91+
}
92+
93+
$route->setParameter($parameterName, $backedEnum);
94+
}
95+
96+
return $route;
97+
}
98+
6399
/**
64100
* Return the parameter name if it exists in the given parameters.
65101
*

src/Illuminate/Routing/Route.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -505,12 +505,16 @@ protected function compileParameterNames()
505505
/**
506506
* Get the parameters that are listed in the route / controller signature.
507507
*
508-
* @param string|null $subClass
508+
* @param array $conditions
509509
* @return array
510510
*/
511-
public function signatureParameters($subClass = null)
511+
public function signatureParameters($conditions = [])
512512
{
513-
return RouteSignatureParameters::fromAction($this->action, $subClass);
513+
if (is_string($conditions)) {
514+
$conditions = ['subClass' => $conditions];
515+
}
516+
517+
return RouteSignatureParameters::fromAction($this->action, $conditions);
514518
}
515519

516520
/**

src/Illuminate/Routing/RouteSignatureParameters.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ class RouteSignatureParameters
1313
* Extract the route action's signature parameters.
1414
*
1515
* @param array $action
16-
* @param string|null $subClass
16+
* @param array $conditions
1717
* @return array
1818
*/
19-
public static function fromAction(array $action, $subClass = null)
19+
public static function fromAction(array $action, $conditions = [])
2020
{
2121
$callback = RouteAction::containsSerializedClosure($action)
2222
? unserialize($action['uses'])->getClosure()
@@ -26,9 +26,11 @@ public static function fromAction(array $action, $subClass = null)
2626
? static::fromClassMethodString($callback)
2727
: (new ReflectionFunction($callback))->getParameters();
2828

29-
return is_null($subClass) ? $parameters : array_filter($parameters, function ($p) use ($subClass) {
30-
return Reflector::isParameterSubclassOf($p, $subClass);
31-
});
29+
return match (true) {
30+
! empty($conditions['subClass']) => array_filter($parameters, fn ($p) => Reflector::isParameterSubclassOf($p, $conditions['subClass'])),
31+
! empty($conditions['backedEnum']) => array_filter($parameters, fn ($p) => Reflector::isParameterBackedEnumWithStringBackingType($p)),
32+
default => $parameters,
33+
};
3234
}
3335

3436
/**

src/Illuminate/Routing/Router.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,7 @@ public static function toResponse($request, $response)
818818
* @return \Illuminate\Routing\Route
819819
*
820820
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
821+
* @throws \Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException
821822
*/
822823
public function substituteBindings($route)
823824
{
@@ -831,12 +832,13 @@ public function substituteBindings($route)
831832
}
832833

833834
/**
834-
* Substitute the implicit Eloquent model bindings for the route.
835+
* Substitute the implicit route bindings for the given route.
835836
*
836837
* @param \Illuminate\Routing\Route $route
837838
* @return void
838839
*
839840
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
841+
* @throws \Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException
840842
*/
841843
public function substituteImplicitBindings($route)
842844
{

src/Illuminate/Support/Reflector.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Illuminate\Support;
44

55
use ReflectionClass;
6+
use ReflectionEnum;
67
use ReflectionMethod;
78
use ReflectionNamedType;
89
use ReflectionUnionType;
@@ -139,4 +140,24 @@ public static function isParameterSubclassOf($parameter, $className)
139140
&& class_exists($paramClassName)
140141
&& (new ReflectionClass($paramClassName))->isSubclassOf($className);
141142
}
143+
144+
/**
145+
* Determine if the parameter's type is a Backed Enum with a string backing type.
146+
*
147+
* @param \ReflectionParameter $parameter
148+
* @return bool
149+
*/
150+
public static function isParameterBackedEnumWithStringBackingType($parameter)
151+
{
152+
$backedEnumClass = (string) $parameter->getType();
153+
154+
if (function_exists('enum_exists') && enum_exists($backedEnumClass)) {
155+
$reflectionBackedEnum = new ReflectionEnum($backedEnumClass);
156+
157+
return $reflectionBackedEnum->isBacked()
158+
&& $reflectionBackedEnum->getBackingType()->getName() == 'string';
159+
}
160+
161+
return false;
162+
}
142163
}

tests/Integration/Routing/Enums.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Integration\Routing;
4+
5+
enum CategoryBackedEnum: string
6+
{
7+
case People = 'people';
8+
case Fruits = 'fruits';
9+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Integration\Routing;
4+
5+
use Illuminate\Support\Facades\Route;
6+
use Orchestra\Testbench\TestCase;
7+
8+
if (PHP_VERSION_ID >= 80100) {
9+
include 'Enums.php';
10+
}
11+
12+
/**
13+
* @requires PHP 8.1
14+
*/
15+
class ImplicitBackedEnumRouteBindingTest extends TestCase
16+
{
17+
public function testWithRouteCachingEnabled()
18+
{
19+
$this->defineCacheRoutes(<<<PHP
20+
<?php
21+
22+
use Illuminate\Tests\Integration\Routing\CategoryBackedEnum;
23+
24+
Route::get('/categories/{category}', function (CategoryBackedEnum \$category) {
25+
return \$category->value;
26+
})->middleware('web');
27+
PHP);
28+
29+
$response = $this->get('/categories/fruits');
30+
$response->assertSee('fruits');
31+
32+
$response = $this->get('/categories/people');
33+
$response->assertSee('people');
34+
35+
$response = $this->get('/categories/cars');
36+
$response->assertNotFound(404);
37+
}
38+
39+
public function testWithoutRouteCachingEnabled()
40+
{
41+
config(['app.key' => str_repeat('a', 32)]);
42+
43+
Route::post('/categories/{category}', function (CategoryBackedEnum $category) {
44+
return $category->value;
45+
})->middleware(['web']);
46+
47+
$response = $this->post('/categories/fruits');
48+
$response->assertSee('fruits');
49+
50+
$response = $this->post('/categories/people');
51+
$response->assertSee('people');
52+
53+
$response = $this->post('/categories/cars');
54+
$response->assertNotFound(404);
55+
}
56+
}

tests/Integration/Routing/ImplicitRouteBindingTest.php renamed to tests/Integration/Routing/ImplicitModelRouteBindingTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use Orchestra\Testbench\Concerns\InteractsWithPublishedFiles;
1111
use Orchestra\Testbench\TestCase;
1212

13-
class ImplicitRouteBindingTest extends TestCase
13+
class ImplicitModelRouteBindingTest extends TestCase
1414
{
1515
use InteractsWithPublishedFiles;
1616

0 commit comments

Comments
 (0)