Skip to content
8 changes: 8 additions & 0 deletions src/Illuminate/Contracts/Routing/AttributeRouteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Illuminate\Contracts\Routing;

interface AttributeRouteController
{
//
}
73 changes: 72 additions & 1 deletion src/Illuminate/Foundation/Configuration/ApplicationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as AppEventServiceProvider;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as AppRouteServiceProvider;
use Illuminate\Routing\Router;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Event;
Expand Down Expand Up @@ -43,6 +45,13 @@ class ApplicationBuilder
*/
protected array $pageMiddleware = [];

/**
* The attribute routing configurations.
*
* @var array
*/
protected array $attributeRoutingConfigurations = [];

/**
* Create a new application builder instance.
*/
Expand Down Expand Up @@ -202,7 +211,9 @@ protected function buildRoutingCallback(array|string|null $web,
string $apiPrefix,
?callable $then)
{
return function () use ($web, $api, $pages, $health, $apiPrefix, $then) {
return function (Router $router) use ($web, $api, $pages, $health, $apiPrefix, $then) {
$this->registerAttributeRoutes($router);

if (is_string($api) || is_array($api)) {
if (is_array($api)) {
foreach ($api as $apiRoute) {
Expand Down Expand Up @@ -265,6 +276,66 @@ class_exists(Folio::class)) {
};
}

/**
* Configure attribute-based routing.
*
* @param array|string|null $web
* @param array|string|null $api
* @return $this
*/
public function withAttributeRouting(
array|string|null $web = null,
array|string|null $api = null
) {
$groups = [];

if (is_null($web) && is_null($api)) {
$groups['web'] = [app_path('Http/Controllers')];
} else {
if (! is_null($web)) {
$groups['web'] = Arr::wrap($web);
}
if (! is_null($api)) {
$groups['api'] = Arr::wrap($api);
}
}

$this->attributeRoutingConfigurations = $groups;

return $this;
}

/**
* Register all the configured attribute-based routes.
*
* @param \Illuminate\Routing\Router $router
* @return void
*/
protected function registerAttributeRoutes(Router $router): void
{
if (empty($this->attributeRoutingConfigurations)) {
return;
}

$registrar = $this->app->make(\Illuminate\Routing\AttributeRouteRegistrar::class);

foreach ($this->attributeRoutingConfigurations as $groupName => $paths) {
if (empty($paths)) {
continue;
}

$groupOptions = ['middleware' => $groupName];

if ($groupName === 'api') {
$groupOptions['prefix'] = 'api';
}

$router->group($groupOptions, function () use ($registrar, $paths) {
$registrar->register(...$paths);
});
}
}

/**
* Register the global middleware, middleware groups, and middleware aliases for the application.
*
Expand Down
185 changes: 185 additions & 0 deletions src/Illuminate/Routing/AttributeRouteRegistrar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<?php

namespace Illuminate\Routing;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Routing\AttributeRouteController;
use Illuminate\Routing\Attributes\Group;
use Illuminate\Routing\Attributes\RouteAttribute;
use Illuminate\Support\Str;
use ReflectionClass;
use Symfony\Component\Finder\Finder;

class AttributeRouteRegistrar
{
/**
* The PSR-4 autoloading map from Composer.
*
* @var array
*/
protected $psr4Paths;

/**
* Create a new AttributeRouteRegistrar instance.
*
* @param \Illuminate\Contracts\Foundation\Application $app
* @param \Illuminate\Routing\Router $router
* @return void
*/
public function __construct(protected Application $app, protected Router $router)
{
$this->psr4Paths = $this->getPsr4Paths();
}

/**
* Scan the given directories and register any found attribute-based routes.
*
* @param string ...$controllerDirectories
* @return void
*/
public function register(...$controllerDirectories)
{
if (empty($controllerDirectories)) {
return;
}

$finder = (new Finder)->files()->in($controllerDirectories)->name('*.php');

foreach ($finder as $file) {
$className = $this->getClassFromFile($file->getRealPath());

if ($className && class_exists($className) && is_a($className, AttributeRouteController::class, true)) {
$this->registerControllerRoutes($className);
}
}
}

/**
* Registers all routes for a given controller class.
*
* @param string $controllerClassName
* @return void
*/
public function registerControllerRoutes($controllerClassName)
{
$reflectionClass = new ReflectionClass($controllerClassName);

$groupAttributes = $this->getGroupAttributes($reflectionClass) ?? [];

$this->router->group($groupAttributes, function (Router $router) use ($reflectionClass) {
foreach ($reflectionClass->getMethods() as $method) {
$attributes = $method->getAttributes(RouteAttribute::class, \ReflectionAttribute::IS_INSTANCEOF);

foreach ($attributes as $attribute) {
try {
$instance = $attribute->newInstance();
$route = $router->addRoute(
$instance->methods,
$instance->path,
[$reflectionClass->getName(), $method->getName()]
);
$this->applyRouteOptions($route, $instance);
} catch (\Throwable $e) {
report($e);
}
}
}
});
}

/**
* Applies all options from a RouteAttribute instance to a route.
*
* @param \Illuminate\Routing\Route $route
* @param \Illuminate\Routing\Attributes\RouteAttribute $instance
* @return void
*/
protected function applyRouteOptions(Route $route, RouteAttribute $instance): void
{
if ($instance->name) {
$route->name($instance->name);
}
if ($instance->middleware) {
$route->middleware($instance->middleware);
}
if ($instance->where) {
$route->where($instance->where);
}

// Mark the route for the route:list command
$route->setAction(array_merge($route->getAction(), ['is_attribute_route' => true]));
}

/**
* Gets the properties from a single #[Group] attribute on a class.
*
* @param \ReflectionClass $reflectionClass
* @return array|null
*/
protected function getGroupAttributes(ReflectionClass $reflectionClass): ?array
{
$attributes = $reflectionClass->getAttributes(Group::class);

if (count($attributes) === 0) {
return null;
}

try {
/** @var Group $group */
$group = $attributes[0]->newInstance();

return array_filter([
'prefix' => $group->prefix,
'middleware' => $group->middleware,
'as' => $group->name,
'where' => $group->where,
]);
} catch (\Throwable $e) {
report($e);

return null;
}
}

/**
* Derive the fully qualified class name from a file path.
*
* This implementation uses the project's Composer PSR-4 map to determine
* the class name, making it compatible with any autoloaded directory.
*
* @param string $path
* @return string|null
*/
protected function getClassFromFile($path)
{
foreach ($this->psr4Paths as $namespace => $paths) {
foreach ((array) $paths as $psr4Path) {
if (Str::startsWith($path, $psr4Path)) {
$relativePath = Str::of($path)
->after($psr4Path)
->trim(DIRECTORY_SEPARATOR)
->replace(['/', '.php'], ['\\', ''])
->toString();

return $namespace.$relativePath;
}
}
}

return null;
}

/**
* Load the Composer PSR-4 autoloading map.
*
* This map is used to convert a file path into a fully qualified class name.
*
* @return array
*/
protected function getPsr4Paths()
{
$composerPath = $this->app->basePath('vendor/composer/autoload_psr4.php');

return file_exists($composerPath) ? require $composerPath : [];
}
}
24 changes: 24 additions & 0 deletions src/Illuminate/Routing/Attributes/Any.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Illuminate\Routing\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Any extends RouteAttribute
{
/**
* @param string $path
* @param string|null $name
* @param array|string $middleware
* @param array $where
*/
public function __construct(
$path,
$name = null,
$middleware = [],
$where = []
) {
parent::__construct($path, ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], $name, $middleware, $where);
}
}
24 changes: 24 additions & 0 deletions src/Illuminate/Routing/Attributes/Delete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Illuminate\Routing\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Delete extends RouteAttribute
{
/**
* @param string $path
* @param string|null $name
* @param array|string $middleware
* @param array $where
*/
public function __construct(
$path,
$name = null,
$middleware = [],
$where = []
) {
parent::__construct($path, ['DELETE'], $name, $middleware, $where);
}
}
24 changes: 24 additions & 0 deletions src/Illuminate/Routing/Attributes/Get.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Illuminate\Routing\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Get extends RouteAttribute
{
/**
* @param string $path
* @param string|null $name
* @param array|string $middleware
* @param array $where
*/
public function __construct(
$path,
$name = null,
$middleware = [],
$where = []
) {
parent::__construct($path, ['GET', 'HEAD'], $name, $middleware, $where);
}
}
23 changes: 23 additions & 0 deletions src/Illuminate/Routing/Attributes/Group.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Illuminate\Routing\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
class Group
{
/**
* @param string|null $prefix
* @param string|null $name
* @param array|string $middleware
* @param array $where
*/
public function __construct(
public ?string $prefix = null,
public ?string $name = null,
public array|string $middleware = [],
public array $where = []
) {
}
}
Loading
Loading