From eab73e2b156078aef8c833f929de33ad9a8fddb6 Mon Sep 17 00:00:00 2001 From: rtcoder Date: Wed, 17 Jun 2026 18:41:50 +0200 Subject: [PATCH] Add command metadata and request diagnostics --- README.md | 30 ++- REFACTORING.md | 6 +- .../modules/Health/Commands/Health.php | 1 + docs/index.html | 27 ++- src/App.php | 11 +- src/Console/Attributes/Command.php | 19 ++ src/Console/CommandDefinition.php | 23 +++ src/Console/CommandRegistry.php | 72 ++++++- src/Console/Commands/About.php | 1 + src/Console/Commands/CacheClear.php | 1 + src/Console/Commands/CacheModules.php | 1 + src/Console/Commands/CacheStatus.php | 1 + src/Console/Commands/CreateCommand.php | 1 + src/Console/Commands/CreateController.php | 1 + src/Console/Commands/CreateDto.php | 1 + src/Console/Commands/CreateMiddleware.php | 1 + src/Console/Commands/CreateMigration.php | 1 + src/Console/Commands/CreateModel.php | 1 + src/Console/Commands/CreateModule.php | 1 + src/Console/Commands/CreateService.php | 1 + src/Console/Commands/DbCheck.php | 1 + src/Console/Commands/Doctor.php | 181 ++++++++++++++++++ src/Console/Commands/EnvCheck.php | 1 + src/Console/Commands/Help.php | 35 ++-- src/Console/Commands/Migrate.php | 1 + src/Console/Commands/MigrateRollback.php | 1 + src/Console/Commands/MigrateStatus.php | 1 + src/Console/Commands/ModuleList.php | 1 + src/Console/Commands/RouteList.php | 1 + src/Console/Commands/Serve.php | 1 + src/Console/Commands/Test.php | 1 + src/Console/Generator/stubs/command.stub | 2 + src/Logging/ExceptionLogger.php | 2 +- src/Request.php | 7 + tests/Feature/AppDispatchTest.php | 9 +- tests/Feature/CliHelpTest.php | 4 + tests/Feature/CliRegistryTest.php | 4 + tests/Feature/RequestResponseTest.php | 11 ++ 38 files changed, 441 insertions(+), 24 deletions(-) create mode 100644 src/Console/Attributes/Command.php create mode 100644 src/Console/CommandDefinition.php create mode 100644 src/Console/Commands/Doctor.php diff --git a/README.md b/README.md index 22a31b1..118a8ba 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,11 @@ $request->post('name'); $request->input('name'); $request->getJson(); $request->getHeader('Authorization'); +$request->getRequestId(); $request->routeParam('id'); ``` -Malformed JSON bodies are returned as `400 Bad Request`. +If the request does not include `X-Request-Id`, ShiftPHP generates one. The same id is emitted on every response as `X-Request-Id` and included in structured exception logs. Malformed JSON bodies are returned as `400 Bad Request`. ## Validation and DTOs @@ -405,13 +406,37 @@ http://127.0.0.1:8000/health ## CLI -The CLI discovers framework, application, and module commands through the command registry. Command names are normalized, so `migrate:status`, `migrate-status`, and `migrate_status` resolve to the same command. +The CLI discovers framework, application, and module commands through the command registry. Command names are declared with `#[Command]` attributes and can include aliases and groups. Command names are normalized, so `migrate:status`, `migrate-status`, and `migrate_status` resolve to the same command. + +```php +use Shift\Console\Attributes\Command; +use Shift\Console\CommandInterface; + +#[Command('billing:sync', aliases: ['sync-billing'], group: 'modules')] +final class SyncBilling implements CommandInterface +{ + public function execute(mixed ...$args): void + { + } + + public function getHelp(): string + { + return 'Usage: ./shift billing:sync'; + } + + public function getDescription(): string + { + return 'Sync billing data.'; + } +} +``` Show all commands or command-specific help: ```sh ./shift help ./shift help migrate +./shift help ms ``` List registered API routes: @@ -441,6 +466,7 @@ Inspect framework/runtime information: Check local environment and database configuration: ```sh +./shift doctor ./shift env:check ./shift db:check ``` diff --git a/REFACTORING.md b/REFACTORING.md index b262977..c9eb3f7 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -33,9 +33,12 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] CLI diagnostics for tests, runtime info, environment, database, and modules. - [x] CLI help listing and command-specific usage. - [x] Centralized CLI command registry shared by the dispatcher and help command. +- [x] CLI command metadata through attributes, aliases, and grouped help. +- [x] `shift doctor` project diagnostics. - [x] Database migrations with create, migrate, status, and rollback commands. - [x] Module discovery cache for production. - [x] Structured exception logging with JSON file logger and service container override. +- [x] Request id lifecycle with generated `X-Request-Id` response headers and log context. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: @@ -161,5 +164,4 @@ Internal errors return a generic `500` message unless `display_errors` is enable ## Next -- [ ] CLI command aliases and richer command metadata. -- [ ] Basic package-quality checks, for example static analysis and coding style. +- [ ] Static analysis and coding style checks. diff --git a/application/modules/Health/Commands/Health.php b/application/modules/Health/Commands/Health.php index 9c13580..5cf0dc7 100644 --- a/application/modules/Health/Commands/Health.php +++ b/application/modules/Health/Commands/Health.php @@ -6,6 +6,7 @@ use Shift\Console\CommandInterface; use Modules\Health\Services\HealthService; +#[\Shift\Console\Attributes\Command('health', group: 'modules')] class Health implements CommandInterface { public function execute(mixed ...$args): void diff --git a/docs/index.html b/docs/index.html index a107b84..91390c0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -275,9 +275,11 @@

Requests

$request->getHeader('Authorization'); $request->getUserAgent(); $request->getIpAddress(); +$request->getRequestId(); $request->getRouteParams(); $request->routeParam('id'); +

If a request does not include X-Request-Id, ShiftPHP generates one. The same id is emitted on every response as X-Request-Id and included in structured exception logs.

getJson() returns an empty array for an empty body. Malformed JSON throws an HTTP error and is returned as 400 Bad Request.

@@ -522,10 +524,33 @@

Migrations

CLI

The CLI entry point is shift. Built-in commands live under Console\Commands, and module commands are loaded from module command mappings.

-

Shift\Console\CommandRegistry discovers framework, application, and module commands. It normalizes command names, so migrate:status, migrate-status, and migrate_status resolve to the same command.

+

Shift\Console\CommandRegistry discovers framework, application, and module commands. Commands can declare their name, aliases, and help group with #[Command]. It normalizes command names, so migrate:status, migrate-status, and migrate_status resolve to the same command.

+ +
use Shift\Console\Attributes\Command;
+use Shift\Console\CommandInterface;
+
+#[Command('billing:sync', aliases: ['sync-billing'], group: 'modules')]
+final class SyncBilling implements CommandInterface
+{
+    public function execute(mixed ...$args): void
+    {
+    }
+
+    public function getHelp(): string
+    {
+        return 'Usage: ./shift billing:sync';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Sync billing data.';
+    }
+}
./shift help
 ./shift help migrate
+./shift help ms
+./shift doctor
 ./shift route:list
 ./shift test
 ./shift health
diff --git a/src/App.php b/src/App.php
index 73261b1..bb0fe2b 100755
--- a/src/App.php
+++ b/src/App.php
@@ -77,7 +77,7 @@ public function start(): void
             $response = JsonResponse::error('Internal Server Error', 500);
         }
 
-        $this->emitter->emit($response);
+        $this->emitter->emit($this->withRequestId($response));
     }
 
     public function middleware(MiddlewareInterface|callable|string $middleware): self
@@ -398,4 +398,13 @@ private function logException(Throwable $exception, int $statusCode): void
         } catch (Throwable) {
         }
     }
+
+    private function withRequestId(Response $response): Response
+    {
+        return new Response(
+            $response->getContent(),
+            $response->getStatusCode(),
+            ['X-Request-Id' => $this->request->getRequestId()] + $response->getHeaders()
+        );
+    }
 }
diff --git a/src/Console/Attributes/Command.php b/src/Console/Attributes/Command.php
new file mode 100644
index 0000000..9bfaf04
--- /dev/null
+++ b/src/Console/Attributes/Command.php
@@ -0,0 +1,19 @@
+ $aliases
+     */
+    public function __construct(
+        public readonly string $name,
+        public readonly array $aliases = [],
+        public readonly string $group = 'general'
+    ) {
+    }
+}
diff --git a/src/Console/CommandDefinition.php b/src/Console/CommandDefinition.php
new file mode 100644
index 0000000..5a5297e
--- /dev/null
+++ b/src/Console/CommandDefinition.php
@@ -0,0 +1,23 @@
+ $class
+     * @param list $aliases
+     */
+    public function __construct(
+        public readonly string $name,
+        public readonly string $class,
+        public readonly array $aliases = [],
+        public readonly string $group = 'general'
+    ) {
+    }
+
+    public function instantiate(): CommandInterface
+    {
+        return new $this->class();
+    }
+}
diff --git a/src/Console/CommandRegistry.php b/src/Console/CommandRegistry.php
index eecc14e..e53782c 100644
--- a/src/Console/CommandRegistry.php
+++ b/src/Console/CommandRegistry.php
@@ -2,6 +2,8 @@
 
 namespace Shift\Console;
 
+use ReflectionClass;
+use Shift\Console\Attributes\Command;
 use Shift\Modules\ModuleLoader;
 
 final class CommandRegistry
@@ -23,7 +25,18 @@ public static function default(): self
      */
     public function all(): array
     {
-        $commands = [];
+        return array_map(
+            static fn (CommandDefinition $definition): string => $definition->class,
+            $this->definitions()
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function definitions(): array
+    {
+        $definitions = [];
 
         foreach ($this->mappings() as $mapping) {
             if (!is_dir($mapping['dir'])) {
@@ -37,14 +50,15 @@ public function all(): array
                 $class = $mapping['namespace'] . $className;
 
                 if (class_exists($class) && is_subclass_of($class, CommandInterface::class)) {
-                    $commands[self::nameFromClass($className)] = $class;
+                    $definition = $this->definitionFor($class, $className);
+                    $definitions[$definition->name] = $definition;
                 }
             }
         }
 
-        ksort($commands);
+        ksort($definitions);
 
-        return $commands;
+        return $definitions;
     }
 
     /**
@@ -52,7 +66,20 @@ public function all(): array
      */
     public function find(string $command): ?string
     {
-        return $this->all()[$this->normalize($command)] ?? null;
+        return $this->findDefinition($command)?->class;
+    }
+
+    public function findDefinition(string $command): ?CommandDefinition
+    {
+        $normalized = $this->normalize($command);
+
+        foreach ($this->definitions() as $definition) {
+            if ($definition->name === $normalized || in_array($normalized, $definition->aliases, true)) {
+                return $definition;
+            }
+        }
+
+        return null;
     }
 
     public function normalize(string $command): string
@@ -83,6 +110,41 @@ public static function nameFromClass(string $class): string
         return implode(':', $commandParts);
     }
 
+    /**
+     * @param class-string $class
+     */
+    private function definitionFor(string $class, string $fallbackClassName): CommandDefinition
+    {
+        $reflection = new ReflectionClass($class);
+        $attributes = $reflection->getAttributes(Command::class);
+
+        if ($attributes !== []) {
+            /** @var Command $attribute */
+            $attribute = $attributes[0]->newInstance();
+
+            return new CommandDefinition(
+                $this->normalize($attribute->name),
+                $class,
+                $this->normalizeList($attribute->aliases),
+                $attribute->group
+            );
+        }
+
+        return new CommandDefinition(self::nameFromClass($fallbackClassName), $class);
+    }
+
+    /**
+     * @param list $values
+     * @return list
+     */
+    private function normalizeList(array $values): array
+    {
+        return array_values(array_filter(array_map(
+            fn (string $value): string => $this->normalize($value),
+            $values
+        )));
+    }
+
     /**
      * @return list
      */
diff --git a/src/Console/Commands/About.php b/src/Console/Commands/About.php
index 6f1d14c..8932a56 100644
--- a/src/Console/Commands/About.php
+++ b/src/Console/Commands/About.php
@@ -6,6 +6,7 @@
 use Shift\Console\Cli;
 use Shift\Console\CommandInterface;
 
+#[\Shift\Console\Attributes\Command('about', group: 'diagnostics')]
 class About implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CacheClear.php b/src/Console/Commands/CacheClear.php
index 9e34597..5c5e078 100644
--- a/src/Console/Commands/CacheClear.php
+++ b/src/Console/Commands/CacheClear.php
@@ -6,6 +6,7 @@
 use Shift\Console\CommandInterface;
 use Shift\Modules\ModuleLoader;
 
+#[\Shift\Console\Attributes\Command('cache:clear', aliases: ['cc'], group: 'cache')]
 class CacheClear implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CacheModules.php b/src/Console/Commands/CacheModules.php
index 964f72e..f6ca944 100644
--- a/src/Console/Commands/CacheModules.php
+++ b/src/Console/Commands/CacheModules.php
@@ -6,6 +6,7 @@
 use Shift\Console\CommandInterface;
 use Shift\Modules\ModuleLoader;
 
+#[\Shift\Console\Attributes\Command('cache:modules', aliases: ['cm'], group: 'cache')]
 class CacheModules implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CacheStatus.php b/src/Console/Commands/CacheStatus.php
index fc420ca..595a89e 100644
--- a/src/Console/Commands/CacheStatus.php
+++ b/src/Console/Commands/CacheStatus.php
@@ -6,6 +6,7 @@
 use Shift\Console\CommandInterface;
 use Shift\Modules\ModuleLoader;
 
+#[\Shift\Console\Attributes\Command('cache:status', aliases: ['cs'], group: 'cache')]
 class CacheStatus implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CreateCommand.php b/src/Console/Commands/CreateCommand.php
index f7fb4db..58f99ef 100644
--- a/src/Console/Commands/CreateCommand.php
+++ b/src/Console/Commands/CreateCommand.php
@@ -6,6 +6,7 @@
 use Shift\Console\Generator\GeneratesFiles;
 use Shift\Console\Generator\NameFormatter;
 
+#[\Shift\Console\Attributes\Command('create:command', group: 'make')]
 class CreateCommand implements CommandInterface
 {
     use GeneratesFiles;
diff --git a/src/Console/Commands/CreateController.php b/src/Console/Commands/CreateController.php
index 60729d8..d474e61 100644
--- a/src/Console/Commands/CreateController.php
+++ b/src/Console/Commands/CreateController.php
@@ -6,6 +6,7 @@
 use Shift\Console\Generator\GeneratesFiles;
 use Shift\Console\Generator\NameFormatter;
 
+#[\Shift\Console\Attributes\Command('create:controller', group: 'make')]
 class CreateController implements CommandInterface
 {
     use GeneratesFiles;
diff --git a/src/Console/Commands/CreateDto.php b/src/Console/Commands/CreateDto.php
index c0a85d5..236859d 100644
--- a/src/Console/Commands/CreateDto.php
+++ b/src/Console/Commands/CreateDto.php
@@ -6,6 +6,7 @@
 use Shift\Console\Generator\GeneratesFiles;
 use Shift\Console\Generator\NameFormatter;
 
+#[\Shift\Console\Attributes\Command('create:dto', group: 'make')]
 class CreateDto implements CommandInterface
 {
     use GeneratesFiles;
diff --git a/src/Console/Commands/CreateMiddleware.php b/src/Console/Commands/CreateMiddleware.php
index 8aaba41..d5f3b81 100644
--- a/src/Console/Commands/CreateMiddleware.php
+++ b/src/Console/Commands/CreateMiddleware.php
@@ -6,6 +6,7 @@
 use Shift\Console\Generator\GeneratesFiles;
 use Shift\Console\Generator\NameFormatter;
 
+#[\Shift\Console\Attributes\Command('create:middleware', group: 'make')]
 class CreateMiddleware implements CommandInterface
 {
     use GeneratesFiles;
diff --git a/src/Console/Commands/CreateMigration.php b/src/Console/Commands/CreateMigration.php
index b3a168c..a6e68dd 100644
--- a/src/Console/Commands/CreateMigration.php
+++ b/src/Console/Commands/CreateMigration.php
@@ -8,6 +8,7 @@
 use Shift\Console\Generator\NameFormatter;
 use Shift\Console\Generator\StubRenderer;
 
+#[\Shift\Console\Attributes\Command('create:migration', group: 'database')]
 class CreateMigration implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/CreateModel.php b/src/Console/Commands/CreateModel.php
index 8e2f683..2a23a17 100644
--- a/src/Console/Commands/CreateModel.php
+++ b/src/Console/Commands/CreateModel.php
@@ -6,6 +6,7 @@
 use Shift\Console\Generator\GeneratesFiles;
 use Shift\Console\Generator\NameFormatter;
 
+#[\Shift\Console\Attributes\Command('create:model', group: 'make')]
 class CreateModel implements CommandInterface
 {
     use GeneratesFiles;
diff --git a/src/Console/Commands/CreateModule.php b/src/Console/Commands/CreateModule.php
index 49e688a..b32d100 100644
--- a/src/Console/Commands/CreateModule.php
+++ b/src/Console/Commands/CreateModule.php
@@ -6,6 +6,7 @@
 use Shift\Console\Generator\GeneratesFiles;
 use Shift\Console\Generator\NameFormatter;
 
+#[\Shift\Console\Attributes\Command('create:module', group: 'make')]
 class CreateModule implements CommandInterface
 {
     use GeneratesFiles;
diff --git a/src/Console/Commands/CreateService.php b/src/Console/Commands/CreateService.php
index 23a4639..dbb7cab 100644
--- a/src/Console/Commands/CreateService.php
+++ b/src/Console/Commands/CreateService.php
@@ -6,6 +6,7 @@
 use Shift\Console\Generator\GeneratesFiles;
 use Shift\Console\Generator\NameFormatter;
 
+#[\Shift\Console\Attributes\Command('create:service', group: 'make')]
 class CreateService implements CommandInterface
 {
     use GeneratesFiles;
diff --git a/src/Console/Commands/DbCheck.php b/src/Console/Commands/DbCheck.php
index 593174b..fb5eaff 100644
--- a/src/Console/Commands/DbCheck.php
+++ b/src/Console/Commands/DbCheck.php
@@ -9,6 +9,7 @@
 use Shift\Database\DatabaseException;
 use Throwable;
 
+#[\Shift\Console\Attributes\Command('db:check', group: 'diagnostics')]
 class DbCheck implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/Doctor.php b/src/Console/Commands/Doctor.php
new file mode 100644
index 0000000..8a3d660
--- /dev/null
+++ b/src/Console/Commands/Doctor.php
@@ -0,0 +1,181 @@
+phpVersion(),
+            $this->extensions(),
+            $this->composerConfig(),
+            $this->phpLint(),
+            $this->testSuite(),
+            $this->environment(),
+            $this->databaseConfig(),
+            $this->moduleCache(),
+        ];
+
+        $failed = array_values(array_filter($checks, static fn (array $check): bool => $check[1] === 'fail'));
+        $cli->table(['Check', 'Status', 'Details'], $checks);
+
+        if ($failed === []) {
+            $cli->success('Doctor checks passed.');
+            return;
+        }
+
+        $cli->error('Doctor found ' . count($failed) . ' failing check(s).');
+        exit(1);
+    }
+
+    public function getHelp(): string
+    {
+        return 'Usage: ./shift doctor';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Run project diagnostics.';
+    }
+
+    private function phpVersion(): array
+    {
+        return [
+            'PHP version',
+            version_compare(PHP_VERSION, '8.3.0', '>=') ? 'ok' : 'fail',
+            PHP_VERSION,
+        ];
+    }
+
+    private function extensions(): array
+    {
+        $missing = array_values(array_filter(
+            ['json', 'pdo'],
+            static fn (string $extension): bool => !extension_loaded($extension)
+        ));
+
+        return [
+            'PHP extensions',
+            $missing === [] ? 'ok' : 'fail',
+            $missing === [] ? 'json, pdo' : 'Missing: ' . implode(', ', $missing),
+        ];
+    }
+
+    private function composerConfig(): array
+    {
+        $path = APP_ROOT . '/composer.json';
+
+        if (!is_file($path)) {
+            return ['Composer config', 'fail', 'composer.json not found'];
+        }
+
+        json_decode((string) file_get_contents($path), true);
+
+        return [
+            'Composer config',
+            json_last_error() === JSON_ERROR_NONE ? 'ok' : 'fail',
+            json_last_error() === JSON_ERROR_NONE ? 'composer.json valid JSON' : json_last_error_msg(),
+        ];
+    }
+
+    private function phpLint(): array
+    {
+        $files = array_merge(
+            $this->phpFiles(APP_ROOT . '/src'),
+            $this->phpFiles(APP_ROOT . '/application'),
+            $this->phpFiles(APP_ROOT . '/tests'),
+            [APP_ROOT . '/shift']
+        );
+
+        foreach ($files as $file) {
+            if (!is_file($file)) {
+                continue;
+            }
+
+            exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $exitCode);
+
+            if ($exitCode !== 0) {
+                return ['PHP lint', 'fail', basename($file) . ': ' . trim(implode(' ', $output))];
+            }
+        }
+
+        return ['PHP lint', 'ok', count($files) . ' file(s) checked'];
+    }
+
+    private function testSuite(): array
+    {
+        exec('composer test 2>&1', $output, $exitCode);
+
+        return [
+            'Test suite',
+            $exitCode === 0 ? 'ok' : 'fail',
+            $exitCode === 0 ? 'composer test passed' : trim(implode(' ', array_slice($output, -3))),
+        ];
+    }
+
+    private function environment(): array
+    {
+        return [
+            'Environment',
+            is_file(APP_ROOT . '/.env') ? 'ok' : 'warn',
+            is_file(APP_ROOT . '/.env') ? '.env present' : '.env not found',
+        ];
+    }
+
+    private function databaseConfig(): array
+    {
+        try {
+            $config = DatabaseConfig::fromEnv();
+        } catch (Throwable $exception) {
+            return ['Database config', 'fail', $exception->getMessage()];
+        }
+
+        return [
+            'Database config',
+            $config->driver !== '' && $config->database !== '' ? 'ok' : 'warn',
+            $config->driver . ':' . ($config->database !== '' ? $config->database : '(empty)'),
+        ];
+    }
+
+    private function moduleCache(): array
+    {
+        $loader = new ModuleLoader();
+
+        return [
+            'Module cache',
+            'ok',
+            $loader->isCached() ? 'cached' : 'empty',
+        ];
+    }
+
+    private function phpFiles(string $path): array
+    {
+        if (!is_dir($path)) {
+            return [];
+        }
+
+        $files = [];
+        $iterator = new \RecursiveIteratorIterator(
+            new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
+        );
+
+        foreach ($iterator as $file) {
+            if ($file->isFile() && $file->getExtension() === 'php') {
+                $files[] = $file->getPathname();
+            }
+        }
+
+        sort($files);
+
+        return $files;
+    }
+}
diff --git a/src/Console/Commands/EnvCheck.php b/src/Console/Commands/EnvCheck.php
index 2cf7f65..8ce4545 100644
--- a/src/Console/Commands/EnvCheck.php
+++ b/src/Console/Commands/EnvCheck.php
@@ -6,6 +6,7 @@
 use Shift\Console\Cli;
 use Shift\Console\CommandInterface;
 
+#[\Shift\Console\Attributes\Command('env:check', group: 'diagnostics')]
 class EnvCheck implements CommandInterface
 {
     /** @var list */
diff --git a/src/Console/Commands/Help.php b/src/Console/Commands/Help.php
index 809e492..5028e48 100644
--- a/src/Console/Commands/Help.php
+++ b/src/Console/Commands/Help.php
@@ -12,6 +12,7 @@
 use Shift\Console\CommandInterface;
 use Shift\Console\CommandRegistry;
 
+#[\Shift\Console\Attributes\Command('help', aliases: ['h'], group: 'core')]
 class Help implements CommandInterface
 {
     public function __construct(private readonly CommandRegistry $registry = new CommandRegistry())
@@ -33,15 +34,21 @@ public function execute(mixed ...$args): void
     private function displayHelpForCommand(string $command): void
     {
         $cli = new Cli();
-        $class = $this->registry->find($command);
+        $definition = $this->registry->findDefinition($command);
 
-        if ($class === null) {
+        if ($definition === null) {
             $cli->error('Command not found: ' . $command);
             return;
         }
 
-        $instance = new $class();
-        $cli->info(CommandRegistry::nameFromClass($class));
+        $instance = $definition->instantiate();
+        $cli->info($definition->name);
+        $cli->debug('Group: ' . $definition->group);
+
+        if ($definition->aliases !== []) {
+            $cli->debug('Aliases: ' . implode(', ', $definition->aliases));
+        }
+
         $cli->debug($instance->getDescription());
         $cli->debug($instance->getHelp());
     }
@@ -49,19 +56,25 @@ private function displayHelpForCommand(string $command): void
     private function displayFullHelp(): void
     {
         $cli = new Cli();
-        $rows = [];
+        $groups = [];
 
-        foreach ($this->registry->all() as $command => $class) {
-            $instance = new $class();
-            $rows[] = [
-                $command,
+        foreach ($this->registry->definitions() as $definition) {
+            $instance = $definition->instantiate();
+            $groups[$definition->group][] = [
+                $definition->name,
+                $definition->aliases === [] ? '' : implode(', ', $definition->aliases),
                 $instance->getDescription(),
             ];
         }
 
-        usort($rows, static fn (array $left, array $right): int => strcmp($left[0], $right[0]));
+        ksort($groups);
 
-        $cli->table(['Command', 'Description'], $rows);
+        foreach ($groups as $group => $rows) {
+            usort($rows, static fn (array $left, array $right): int => strcmp($left[0], $right[0]));
+
+            $cli->info(ucfirst($group));
+            $cli->table(['Command', 'Aliases', 'Description'], $rows);
+        }
     }
 
     public function getHelp(): string
diff --git a/src/Console/Commands/Migrate.php b/src/Console/Commands/Migrate.php
index 25e2c59..d8e39bd 100644
--- a/src/Console/Commands/Migrate.php
+++ b/src/Console/Commands/Migrate.php
@@ -8,6 +8,7 @@
 use Shift\Database\DatabaseConfig;
 use Shift\Database\Migrations\MigrationRunner;
 
+#[\Shift\Console\Attributes\Command('migrate', aliases: ['m'], group: 'database')]
 class Migrate implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/MigrateRollback.php b/src/Console/Commands/MigrateRollback.php
index 7f9461a..b5adb23 100644
--- a/src/Console/Commands/MigrateRollback.php
+++ b/src/Console/Commands/MigrateRollback.php
@@ -8,6 +8,7 @@
 use Shift\Database\DatabaseConfig;
 use Shift\Database\Migrations\MigrationRunner;
 
+#[\Shift\Console\Attributes\Command('migrate:rollback', aliases: ['rollback'], group: 'database')]
 class MigrateRollback implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/MigrateStatus.php b/src/Console/Commands/MigrateStatus.php
index 0d36070..511481e 100644
--- a/src/Console/Commands/MigrateStatus.php
+++ b/src/Console/Commands/MigrateStatus.php
@@ -8,6 +8,7 @@
 use Shift\Database\DatabaseConfig;
 use Shift\Database\Migrations\MigrationRunner;
 
+#[\Shift\Console\Attributes\Command('migrate:status', aliases: ['ms'], group: 'database')]
 class MigrateStatus implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/ModuleList.php b/src/Console/Commands/ModuleList.php
index 9642760..45f1e66 100644
--- a/src/Console/Commands/ModuleList.php
+++ b/src/Console/Commands/ModuleList.php
@@ -6,6 +6,7 @@
 use Shift\Console\CommandInterface;
 use Shift\Modules\ModuleLoader;
 
+#[\Shift\Console\Attributes\Command('module:list', aliases: ['modules'], group: 'modules')]
 class ModuleList implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/RouteList.php b/src/Console/Commands/RouteList.php
index d083a00..c587f73 100644
--- a/src/Console/Commands/RouteList.php
+++ b/src/Console/Commands/RouteList.php
@@ -7,6 +7,7 @@
 use Shift\Modules\ModuleLoader;
 use Shift\Routing\Router\Router;
 
+#[\Shift\Console\Attributes\Command('route:list', aliases: ['routes', 'rl'], group: 'routing')]
 class RouteList implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Commands/Serve.php b/src/Console/Commands/Serve.php
index 190dfec..726395d 100644
--- a/src/Console/Commands/Serve.php
+++ b/src/Console/Commands/Serve.php
@@ -11,6 +11,7 @@
 
 use Shift\Console\CommandInterface;
 
+#[\Shift\Console\Attributes\Command('serve', aliases: ['server'], group: 'server')]
 class Serve implements CommandInterface
 {
 
diff --git a/src/Console/Commands/Test.php b/src/Console/Commands/Test.php
index c836539..10f452e 100644
--- a/src/Console/Commands/Test.php
+++ b/src/Console/Commands/Test.php
@@ -5,6 +5,7 @@
 use Shift\Console\Cli;
 use Shift\Console\CommandInterface;
 
+#[\Shift\Console\Attributes\Command('test', aliases: ['t'], group: 'diagnostics')]
 class Test implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Console/Generator/stubs/command.stub b/src/Console/Generator/stubs/command.stub
index 739e4ed..b48e5ab 100644
--- a/src/Console/Generator/stubs/command.stub
+++ b/src/Console/Generator/stubs/command.stub
@@ -2,9 +2,11 @@
 
 namespace Modules\{{ module }}\Commands;
 
+use Shift\Console\Attributes\Command;
 use Shift\Console\Cli;
 use Shift\Console\CommandInterface;
 
+#[Command('{{ command }}', group: 'modules')]
 class {{ class }} implements CommandInterface
 {
     public function execute(mixed ...$args): void
diff --git a/src/Logging/ExceptionLogger.php b/src/Logging/ExceptionLogger.php
index eeaf683..b606c5e 100644
--- a/src/Logging/ExceptionLogger.php
+++ b/src/Logging/ExceptionLogger.php
@@ -36,7 +36,7 @@ public function log(Throwable $exception, ?Request $request = null, ?int $status
                 'path' => $request->getPath(),
                 'ip' => $request->getIpAddress(),
                 'user_agent' => $request->getUserAgent(),
-                'request_id' => $request->getHeader('X-Request-Id'),
+                'request_id' => $request->getRequestId(),
             ];
         }
 
diff --git a/src/Request.php b/src/Request.php
index 7d4fbb0..aae6dbc 100755
--- a/src/Request.php
+++ b/src/Request.php
@@ -18,6 +18,7 @@ class Request
     private array $attributes = [];
     private ?array $jsonData = null;
     private string $rawBody;
+    private string $requestId;
 
     public function __construct(?array $serverData = null, ?array $queryParams = null, ?array $postData = null, ?string $rawBody = null)
     {
@@ -26,6 +27,7 @@ public function __construct(?array $serverData = null, ?array $queryParams = nul
         $this->postData = $postData ?? $_POST;
         $this->rawBody = $rawBody ?? (string) file_get_contents('php://input');
         $this->parseRequest();
+        $this->requestId = $this->getHeader('X-Request-Id') ?? bin2hex(random_bytes(16));
     }
 
     private function parseRequest(): void
@@ -140,6 +142,11 @@ public function getIpAddress(): ?string
         return $this->serverData['REMOTE_ADDR'] ?? null;
     }
 
+    public function getRequestId(): string
+    {
+        return $this->requestId;
+    }
+
     public function setRouteParams(array $routeParams): void
     {
         $this->routeParams = $routeParams;
diff --git a/tests/Feature/AppDispatchTest.php b/tests/Feature/AppDispatchTest.php
index 93f4209..8fc3416 100644
--- a/tests/Feature/AppDispatchTest.php
+++ b/tests/Feature/AppDispatchTest.php
@@ -18,6 +18,7 @@
         $payload = json_decode($emitter->content, true);
 
         assertSameValue(200, $emitter->statusCode, 'App should emit successful status.');
+        assertArrayHasKeyValue('X-Request-Id', $app->getRequest()->getRequestId(), $emitter->headers, 'App should emit request id header.');
         assertSameValue('demo', $payload['data']['routeParams']['argument'] ?? null, 'App should pass route params to controller.');
     },
 
@@ -87,7 +88,11 @@ public function log(string $level, string $message, array $context = []): void
             }
         };
 
-        $app = new App(makeRequest('GET', '/errors/boom'), $router, $emitter);
+        $app = new App(new Shift\Request([
+            'REQUEST_METHOD' => 'GET',
+            'REQUEST_URI' => '/errors/boom',
+            'HTTP_X_REQUEST_ID' => 'request-500',
+        ]), $router, $emitter);
         $app->getContainer()->singleton(LoggerInterface::class, $logger);
         $app->start();
 
@@ -100,5 +105,7 @@ public function log(string $level, string $message, array $context = []): void
         assertSameValue('Controller exploded', $record['message'] ?? null, 'Log message should contain the exception message.');
         assertSameValue(RuntimeException::class, $record['context']['exception'] ?? null, 'Log context should include the exception class.');
         assertSameValue('/errors/boom', $record['context']['request']['path'] ?? null, 'Log context should include request path.');
+        assertSameValue('request-500', $record['context']['request']['request_id'] ?? null, 'Log context should include request id.');
+        assertArrayHasKeyValue('X-Request-Id', 'request-500', $emitter->headers, 'Error responses should include request id.');
     },
 ];
diff --git a/tests/Feature/CliHelpTest.php b/tests/Feature/CliHelpTest.php
index b68a103..8dec35d 100644
--- a/tests/Feature/CliHelpTest.php
+++ b/tests/Feature/CliHelpTest.php
@@ -9,6 +9,8 @@
         $output = ob_get_clean();
 
         assertStringContains('help', $output, 'Help list should include itself.');
+        assertStringContains('Database', $output, 'Help list should group commands.');
+        assertStringContains('Aliases', $output, 'Help list should include aliases column.');
         assertStringContains('migrate', $output, 'Help list should include migration commands.');
         assertStringContains('create:migration', $output, 'Help list should include migration generator.');
     },
@@ -18,6 +20,8 @@
         $output = ob_get_clean();
 
         assertStringContains('migrate:status', $output, 'Command help should include normalized command name.');
+        assertStringContains('Aliases: ms', $output, 'Command help should include aliases.');
+        assertStringContains('Group: database', $output, 'Command help should include group.');
         assertStringContains('Usage: ./shift migrate:status', $output, 'Command help should include command usage.');
     },
 ];
diff --git a/tests/Feature/CliRegistryTest.php b/tests/Feature/CliRegistryTest.php
index b7325ac..627cd56 100644
--- a/tests/Feature/CliRegistryTest.php
+++ b/tests/Feature/CliRegistryTest.php
@@ -8,9 +8,12 @@
     'command registry discovers built-in and module commands' => function (): void {
         $registry = CommandRegistry::default();
         $commands = $registry->all();
+        $definitions = $registry->definitions();
 
         assertSameValue(MigrateStatus::class, $commands['migrate:status'] ?? null, 'Registry should expose built-in commands by CLI name.');
         assertStringContains('Modules\\Health\\Commands\\Health', $commands['health'] ?? '', 'Registry should expose module commands.');
+        assertSameValue('database', $definitions['migrate:status']->group ?? null, 'Registry should expose command groups.');
+        assertSameValue(['ms'], $definitions['migrate:status']->aliases ?? null, 'Registry should expose command aliases.');
     },
     'command registry normalizes command names' => function (): void {
         $registry = CommandRegistry::default();
@@ -18,6 +21,7 @@
         assertSameValue(MigrateStatus::class, $registry->find('migrate-status'), 'Dash command names should resolve.');
         assertSameValue(MigrateStatus::class, $registry->find('migrate_status'), 'Underscore command names should resolve.');
         assertSameValue(MigrateStatus::class, $registry->find('MigrateStatus'), 'Class-like command names should resolve.');
+        assertSameValue(MigrateStatus::class, $registry->find('ms'), 'Aliases should resolve.');
     },
     'cli dispatcher runs commands through the registry' => function (): void {
         ob_start();
diff --git a/tests/Feature/RequestResponseTest.php b/tests/Feature/RequestResponseTest.php
index 9a7b16d..66d2539 100644
--- a/tests/Feature/RequestResponseTest.php
+++ b/tests/Feature/RequestResponseTest.php
@@ -10,6 +10,17 @@
         assertSameValue(['name' => 'Shift'], $request->getJson(), 'JSON body should parse.');
         assertSameValue('Shift', $request->input('name'), 'Input should read JSON body.');
         assertSameValue('Bearer token', $request->getHeader('Authorization'), 'Header should be available.');
+        assertSameValue(32, strlen($request->getRequestId()), 'Request should generate a request id.');
+    },
+
+    'request keeps incoming request id header' => function (): void {
+        $request = new Shift\Request([
+            'REQUEST_METHOD' => 'GET',
+            'REQUEST_URI' => '/health',
+            'HTTP_X_REQUEST_ID' => 'request-123',
+        ]);
+
+        assertSameValue('request-123', $request->getRequestId(), 'Incoming request id should be preserved.');
     },
 
     'request rejects malformed json' => function (): void {