feat(router): add php 8 attributes support for route definition#349
feat(router): add php 8 attributes support for route definition#349gessyken wants to merge 4 commits intobowphp:5.xfrom
Conversation
|
Why did you avoid environment loading errors. This is the best observability pattern to know if the .env.json or the .env define |
You're right. I added the try-catch to avoid duplicate errors in tests, Fixed - try-catch only checks if env is already loaded, but |
|
Ah!ok, you can reach out the env config into tests/Config for getting more information. |
| * @return Router | ||
| */ | ||
| public function register(string|array $controllers): Router | ||
| { |
There was a problem hiding this comment.
$controllers = (array) $controllers;
There was a problem hiding this comment.
Pull request overview
Adds first-class PHP 8 attribute-based routing to the Bow router, with unit/integration tests and a few small robustness fixes in unrelated areas (env/console/tests).
Changes:
- Introduces
Bow\Router\Attributes\*(e.g.,#[Controller],#[Get],#[Post], etc.) and anAttributeRouteRegistrarto discover/register routes via reflection. - Adds
Router::register()for registering one or many controller classes via the attribute registrar. - Improves a few edge cases (env loading guard, console raw-command lookup, test command stub directory creation) and adds docs/ignores.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Router/Attributes/Controller.php | Adds controller-level metadata attribute (prefix/middleware/name). |
| src/Router/Attributes/Route.php | Base route attribute (path/methods/middleware/where/name). |
| src/Router/Attributes/Get.php | GET route attribute wrapper. |
| src/Router/Attributes/Post.php | POST route attribute wrapper. |
| src/Router/Attributes/Put.php | PUT route attribute wrapper. |
| src/Router/Attributes/Patch.php | PATCH route attribute wrapper. |
| src/Router/Attributes/Delete.php | DELETE route attribute wrapper. |
| src/Router/Attributes/Options.php | OPTIONS route attribute wrapper. |
| src/Router/AttributeRouteRegistrar.php | Reflection-based controller scanning and route registration. |
| src/Router/Router.php | Adds register() entry point for attribute-based registration. |
| tests/Routing/Stubs/UserControllerStub.php | Attribute-decorated controller stub for registration tests. |
| tests/Routing/Stubs/SimpleControllerStub.php | Stub demonstrating routes without #[Controller]. |
| tests/Routing/AttributeRouteTest.php | Unit tests for attribute objects + reflection behavior. |
| tests/Routing/AttributeRouteIntegrationTest.php | Integration tests for registering attributes into the router. |
| src/Support/helpers.php | Makes app_env() tolerate env not being loaded (but return type needs correction). |
| src/Configuration/EnvConfiguration.php | Avoids re-loading env if already loaded; uses config base path for .env.json. |
| src/Console/Console.php | Guards array_key_exists() against null raw command. |
| tests/Console/Stubs/CustomCommand.php | Ensures test output directory exists before writing. |
| src/Database/Barry/Model.php | Normalizes double cast handling to (float). |
| ROADMAP.md | Adds project roadmap document. |
| .gitignore | Ignores IDE folders (.vscode, .idea). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Scan methods | ||
| foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { | ||
| if (str_starts_with($method->getName(), '__')) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
AttributeRouteRegistrar currently iterates over all public methods returned by ReflectionClass::getMethods(), which includes inherited public methods. This can unintentionally register routes for methods coming from a parent/base controller class. Consider filtering to methods declared on the controller itself (e.g., compare $method->getDeclaringClass()->getName()) and optionally skipping static methods.
| private function registerController(string $controllerClass): void | ||
| { | ||
| $reflection = new ReflectionClass($controllerClass); | ||
|
|
There was a problem hiding this comment.
registerController() constructs a ReflectionClass directly from the provided string. If a non-existent class is passed (or an invalid entry is present in the controllers array), this will throw a ReflectionException/TypeError that leaks through Router::register(). Consider validating class_exists() (and that each array item is a string) and throwing a RouterException with a clear message.
| // Get controller attribute | ||
| $controllerAttributes = $reflection->getAttributes(Controller::class); | ||
| $controllerAttribute = !empty($controllerAttributes) ? $controllerAttributes[0]->newInstance() : null; | ||
|
|
||
| $prefix = $controllerAttribute?->getPrefix() ?? ''; | ||
| $controllerMiddleware = $controllerAttribute?->getMiddleware() ?? []; | ||
|
|
There was a problem hiding this comment.
The Controller attribute exposes a "name" property (and tests assert it), but the registrar never uses it when registering routes. This makes the attribute parameter effectively dead. Either apply it (e.g., as a route-name prefix / group name) during registration or remove it from the attribute to avoid a misleading API.
|
|
||
| // Reset the router for each test | ||
| $this->router = Router::configure(); | ||
| } | ||
|
|
There was a problem hiding this comment.
This test resets the router by calling Router::configure(), but Router stores routes in a static property and configure() doesn't clear it. That means routes can leak between tests and make assertions order-dependent. Consider clearing Router's route collection in setUp() (via a dedicated Router reset/flush API, or via reflection in the test) before registering controllers.
| // Reset the router for each test | |
| $this->router = Router::configure(); | |
| } | |
| $this->resetRouterRoutes(); | |
| // Reset the router for each test | |
| $this->router = Router::configure(); | |
| } | |
| private function resetRouterRoutes(): void | |
| { | |
| $reflection = new \ReflectionClass(Router::class); | |
| if (!$reflection->hasProperty('routes')) { | |
| return; | |
| } | |
| $property = $reflection->getProperty('routes'); | |
| $property->setAccessible(true); | |
| $property->setValue(null, []); | |
| } |
| * @param string $key | ||
| * @param mixed $default | ||
| * @return ?string | ||
| */ | ||
| function app_env(string $key, mixed $default = null): ?string | ||
| { | ||
| $env = Env::getInstance(); | ||
| try { | ||
| $env = Env::getInstance(); | ||
|
|
||
| if ($env->isLoaded()) { | ||
| return $env->get($key, $default); | ||
| if ($env->isLoaded()) { | ||
| return $env->get($key, $default); | ||
| } | ||
| } catch (\Bow\Application\Exception\ApplicationException $e) { | ||
| // Environment not loaded, return default | ||
| } | ||
|
|
||
| return $default; |
There was a problem hiding this comment.
app_env() is declared to return ?string, but it can return Env::get() values and the provided $default (both are typed as mixed). Since Env::get() can return arrays/bools/etc, this signature can cause TypeErrors under strict_types and is misleading for callers. Consider changing the return type (and phpdoc) to mixed (or a union matching Env::get()).
Add support for defining routes using PHP 8 attributes on controller classes.
Controllers can use #[Controller] attribute with prefix and middleware, and methods
can use #[Get], #[Post], #[Put], #[Delete], #[Patch] attributes to define routes.
The Router::register() method automatically discovers and registers routes from
controller classes. Includes 22 tests.