Skip to content
8 changes: 8 additions & 0 deletions src/Illuminate/Contracts/Routing/AttributeRouteController.php
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be an attribute as well, instead of an interface? It seems that things are moving away from using empty interfaces to mark functionality and towards using attributes instead.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello,
Thanks for the review.

If I understood correctly, you’re referring to the AttributeRouteController interface. In this pull request, I initially made the system explicit by requiring that interface.
If this pull request is accepted, we could also add an implicit mode so that all controllers are scanned without needing to implement the AttributeRouteController interface.

I’d also be happy to hear if you think there’s a better approach that could be implemented.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AmirHkrg I'm suggesting using an attribute instead of an interface since the interface is empty and appears to be used as just a marker.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to avoid using an interface for marking altogether, the best approach would be the implicit one — defining routes only with attributes, without any additional requirements.

Copy link
Contributor

@shaedrich shaedrich Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume, you talk past each other. Afaik, Will meant this:

https://www.php.net/manual/en/language.attributes.overview.php

<?php

namespace App\Http\Controllers;

use Illuminate\Routing\Attributes\{Get, Group, Post};
use Illuminate\Routing\Attributes\AttributeRouteController;

#[Group(prefix: 'users', name: 'users.', middleware: 'auth')]
#[AttributeRouteController]
class UserController extends Controller 
{
    #[Get('/', name: 'index')]
    public function index()
    {
        // Route: GET /users
        // Name: users.index
    }

    #[Post('/', name: 'store')]
    public function store()
    {
        // Route: POST /users
        // Name: users.store
    }
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @shaedrich

Yes, I understand their point, but I followed the current structure and conventions of Laravel. As you can see, Laravel uses interfaces for marking, such as the ShouldQueue and ShouldBeUnique interfaces in queues.
It is possible to use attributes for marking as well, but it would work a bit differently compared to other features.

Copy link
Contributor

@shaedrich shaedrich Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Amir, no offense, but this is short-sighted. Of course, Laravel has that structure. This is for historical reasons. When Laravel was created, there were no attributes. And if we would rectify everything with "we've always done it that way" and limit ourselves to only what had already been available at that time, there would be no progress.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly, if I look at it from your perspective, it’s completely valid, and it could be really good to be a pioneer in using new approaches.
I’ll replace the interface with attributes, though having both together might not be a bad idea either.
Of course, it’s also important that the Laravel team agrees with us, since this pull request itself might have a low chance of being merged.

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