Skip to content

feat(router): add php 8 attributes support for route definition#349

Open
gessyken wants to merge 4 commits intobowphp:5.xfrom
gessyken:5.x
Open

feat(router): add php 8 attributes support for route definition#349
gessyken wants to merge 4 commits intobowphp:5.xfrom
gessyken:5.x

Conversation

@gessyken
Copy link
Copy Markdown
Contributor

@gessyken gessyken commented Jan 2, 2026

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.

@papac
Copy link
Copy Markdown
Member

papac commented Jan 3, 2026

Why did you avoid environment loading errors. This is the best observability pattern to know if the .env.json or the .env define

@gessyken
Copy link
Copy Markdown
Contributor Author

gessyken commented Jan 3, 2026

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,
but observability is more important

Fixed - try-catch only checks if env is already loaded, but
Env::configure() exceptions will propagate if .env.json is missing

@papac
Copy link
Copy Markdown
Member

papac commented Jan 3, 2026

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
{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

$controllers = (array) $controllers;

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 an AttributeRouteRegistrar to 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.

Comment on lines +61 to +65
// Scan methods
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if (str_starts_with($method->getName(), '__')) {
continue;
}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +53
private function registerController(string $controllerClass): void
{
$reflection = new ReflectionClass($controllerClass);

Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +60
// Get controller attribute
$controllerAttributes = $reflection->getAttributes(Controller::class);
$controllerAttribute = !empty($controllerAttributes) ? $controllerAttributes[0]->newInstance() : null;

$prefix = $controllerAttribute?->getPrefix() ?? '';
$controllerMiddleware = $controllerAttribute?->getMiddleware() ?? [];

Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +45

// Reset the router for each test
$this->router = Router::configure();
}

Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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, []);
}

Copilot uses AI. Check for mistakes.
Comment on lines 1183 to 1199
* @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;
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

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()).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants