docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Framework\Quality\PHPStan\Rules\Naming;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Enforces Command Naming Convention:
* - CQRS Commands (Business Logic): NO "Command" suffix (SendEmail, CreateUser)
* - Console Commands (Infrastructure): WITH "Command" suffix (HealthCheckCommand)
*
* @implements Rule<InClassNode>
*/
final class CommandNamingRule implements Rule
{
public function getNodeType(): string
{
return InClassNode::class;
}
/**
* @param InClassNode $node
*/
public function processNode(Node $node, Scope $scope): array
{
$classReflection = $node->getClassReflection();
$className = $classReflection->getName();
$shortName = $classReflection->getNativeReflection()->getShortName();
// Check if it's a Console Command (implements ConsoleCommand interface)
$isConsoleCommand = $classReflection->implementsInterface('App\\Framework\\Console\\ConsoleCommand');
// Check if it's a CQRS Command (in /Commands/ namespace or has CommandHandler)
$isCqrsCommand = $this->isCqrsCommand($className, $classReflection);
if (!$isConsoleCommand && !$isCqrsCommand) {
return []; // Not a command
}
$errors = [];
if ($isConsoleCommand) {
// Console Commands MUST have "Command" suffix
if (!str_ends_with($shortName, 'Command')) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Console command "%s" must have "Command" suffix. Rename to "%sCommand".',
$shortName,
$shortName
))
->identifier('naming.command.consoleSuffix')
->build();
}
} elseif ($isCqrsCommand) {
// CQRS Commands MUST NOT have "Command" suffix
if (str_ends_with($shortName, 'Command')) {
$suggestedName = preg_replace('/Command$/', '', $shortName);
$errors[] = RuleErrorBuilder::message(sprintf(
'CQRS command "%s" should not have "Command" suffix. Consider renaming to "%s".',
$shortName,
$suggestedName
))
->identifier('naming.command.cqrsNoSuffix')
->tip('CQRS commands are more expressive without "Command" suffix: SendEmail, CreateUser')
->build();
}
// CQRS Commands should follow Verb-Noun pattern
if (!$this->followsVerbNounPattern($shortName)) {
$errors[] = RuleErrorBuilder::message(sprintf(
'CQRS command "%s" should follow Verb-Noun pattern (e.g., SendEmail, CreateUser, UpdateProfile).',
$shortName
))
->identifier('naming.command.verbNounPattern')
->build();
}
}
return $errors;
}
private function isCqrsCommand(string $className, \PHPStan\Reflection\ClassReflection $classReflection): bool
{
// Check if in /Commands/ namespace (CQRS convention)
if (str_contains($className, '\\Commands\\')) {
// Exclude Console commands namespace
if (!str_contains($className, '\\Console\\Commands\\')) {
return true;
}
}
// Check if there's a corresponding Handler
$possibleHandlerName = $className . 'Handler';
if (class_exists($possibleHandlerName)) {
return true;
}
// Check if class has methods that suggest CQRS (constructor with business data)
$constructor = $classReflection->getNativeReflection()->getConstructor();
if ($constructor && $constructor->getNumberOfParameters() > 0) {
// Has constructor with parameters - likely CQRS command
return true;
}
return false;
}
private function followsVerbNounPattern(string $name): bool
{
// Common CQRS verbs
$verbs = [
'Send', 'Create', 'Update', 'Delete', 'Store', 'Process',
'Execute', 'Handle', 'Generate', 'Validate', 'Register',
'Activate', 'Deactivate', 'Cancel', 'Approve', 'Reject'
];
foreach ($verbs as $verb) {
if (str_starts_with($name, $verb)) {
// Check if there's a noun after the verb
$noun = substr($name, strlen($verb));
return !empty($noun) && ctype_upper($noun[0]);
}
}
return false;
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Framework\Quality\PHPStan\Rules\Naming;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Enforces Controller Naming Convention:
* - View Controllers: Show{Feature} (only GET requests, ViewResult)
* - Action Controllers: {Verb}{Feature} (POST/PUT/DELETE, Business Logic)
* - NO "Controller" suffix
*
* @implements Rule<InClassNode>
*/
final class ControllerNamingRule implements Rule
{
public function getNodeType(): string
{
return InClassNode::class;
}
/**
* @param InClassNode $node
*/
public function processNode(Node $node, Scope $scope): array
{
$classReflection = $node->getClassReflection();
$className = $classReflection->getName();
$shortName = $classReflection->getNativeReflection()->getShortName();
// Skip if not in Application namespace (controllers are in Application)
if (!str_starts_with($className, 'App\\Application\\')) {
return [];
}
// Check if class has Route attributes (controller indicator)
$hasRouteAttribute = false;
foreach ($classReflection->getNativeReflection()->getMethods() as $method) {
foreach ($method->getAttributes() as $attribute) {
if (str_contains($attribute->getName(), 'Route')) {
$hasRouteAttribute = true;
break 2;
}
}
}
if (!$hasRouteAttribute) {
return []; // Not a controller
}
$errors = [];
// Rule 1: NO "Controller" suffix
if (str_ends_with($shortName, 'Controller')) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Controller "%s" should not have "Controller" suffix. Use Show{Feature} for views or {Verb}{Feature} for actions.',
$shortName
))
->identifier('naming.controller.noSuffix')
->build();
}
// Rule 2: Show* pattern validation
if (str_starts_with($shortName, 'Show')) {
$violations = $this->validateShowController($classReflection);
foreach ($violations as $violation) {
$errors[] = RuleErrorBuilder::message($violation)
->identifier('naming.controller.showPattern')
->build();
}
}
// Rule 3: Action controller pattern (Submit, Create, Update, Delete, etc.)
$actionVerbs = ['Submit', 'Create', 'Update', 'Delete', 'Handle', 'Process', 'Execute'];
$startsWithActionVerb = false;
foreach ($actionVerbs as $verb) {
if (str_starts_with($shortName, $verb)) {
$startsWithActionVerb = true;
break;
}
}
if (!str_starts_with($shortName, 'Show') && !$startsWithActionVerb) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Controller "%s" must start with "Show" (for views) or an action verb (Submit, Create, Update, Delete, Handle, Process, Execute).',
$shortName
))
->identifier('naming.controller.pattern')
->build();
}
return $errors;
}
/**
* @return string[]
*/
private function validateShowController(\PHPStan\Reflection\ClassReflection $classReflection): array
{
$violations = [];
$shortName = $classReflection->getNativeReflection()->getShortName();
foreach ($classReflection->getNativeReflection()->getMethods() as $method) {
if (!$method->isPublic() || $method->isStatic()) {
continue;
}
// Check for Route attribute
$routeAttributes = array_filter(
$method->getAttributes(),
fn($attr) => str_contains($attr->getName(), 'Route')
);
foreach ($routeAttributes as $routeAttr) {
$args = $routeAttr->getArguments();
// Check if method is not GET (Show* should only have GET routes)
if (isset($args['method'])) {
$methodName = is_object($args['method'])
? $args['method']->name
: $args['method'];
if ($methodName !== 'GET' && !str_contains((string)$methodName, 'GET')) {
$violations[] = sprintf(
'Show controller "%s" has non-GET route in method "%s()". Show controllers should only handle GET requests. Use %s for POST/PUT/DELETE.',
$shortName,
$method->getName(),
$this->suggestActionName($shortName, $methodName)
);
}
}
// Check return type (Show* should return ViewResult)
$returnType = $method->getReturnType();
if ($returnType && !str_contains($returnType->__toString(), 'ViewResult')) {
$violations[] = sprintf(
'Show controller "%s" method "%s()" should return ViewResult, not "%s".',
$shortName,
$method->getName(),
$returnType->__toString()
);
}
}
}
return $violations;
}
private function suggestActionName(string $showClassName, string $httpMethod): string
{
$feature = str_replace('Show', '', $showClassName);
return match(strtoupper($httpMethod)) {
'POST' => "Submit{$feature}",
'PUT', 'PATCH' => "Update{$feature}",
'DELETE' => "Delete{$feature}",
default => "{Verb}{$feature}"
};
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Framework\Quality\PHPStan\Rules\Naming;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Enforces Exception Naming Convention:
* - Domain Exceptions: NO "Exception" suffix (UserNotFound, PaymentFailed)
* - Technical Exceptions: WITH "Exception" suffix (DatabaseException, ValidationException)
*
* @implements Rule<InClassNode>
*/
final class ExceptionNamingRule implements Rule
{
private const TECHNICAL_NAMESPACES = [
'Framework',
'Infrastructure',
];
private const DOMAIN_NAMESPACES = [
'Domain',
'Application',
];
public function getNodeType(): string
{
return InClassNode::class;
}
/**
* @param InClassNode $node
*/
public function processNode(Node $node, Scope $scope): array
{
$classReflection = $node->getClassReflection();
$className = $classReflection->getName();
$shortName = $classReflection->getNativeReflection()->getShortName();
// Check if class extends Exception or FrameworkException
if (!$classReflection->isSubclassOf(\Exception::class) &&
!$classReflection->isSubclassOf('App\\Framework\\Exception\\FrameworkException')) {
return [];
}
// Skip base FrameworkException
if ($className === 'App\\Framework\\Exception\\FrameworkException') {
return [];
}
$errors = [];
$isTechnicalException = $this->isTechnicalException($className);
$isDomainException = $this->isDomainException($className);
if ($isTechnicalException) {
// Technical exceptions MUST have "Exception" suffix
if (!str_ends_with($shortName, 'Exception')) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Technical exception "%s" must have "Exception" suffix. Rename to "%sException".',
$shortName,
$shortName
))
->identifier('naming.exception.technicalSuffix')
->build();
}
} elseif ($isDomainException) {
// Domain exceptions MUST NOT have "Exception" suffix
if (str_ends_with($shortName, 'Exception')) {
$suggestedName = $this->suggestDomainExceptionName($shortName);
$errors[] = RuleErrorBuilder::message(sprintf(
'Domain exception "%s" should not have "Exception" suffix. Consider renaming to "%s".',
$shortName,
$suggestedName
))
->identifier('naming.exception.domainNoSuffix')
->tip('Domain exceptions are more expressive without "Exception" suffix: UserNotFound::byId($id)')
->build();
}
}
return $errors;
}
private function isTechnicalException(string $className): bool
{
foreach (self::TECHNICAL_NAMESPACES as $namespace) {
if (str_contains($className, "\\{$namespace}\\")) {
return true;
}
}
return false;
}
private function isDomainException(string $className): bool
{
foreach (self::DOMAIN_NAMESPACES as $namespace) {
if (str_contains($className, "\\{$namespace}\\")) {
return true;
}
}
return false;
}
private function suggestDomainExceptionName(string $currentName): string
{
// Remove "Exception" suffix
$baseName = preg_replace('/Exception$/', '', $currentName);
// Convert to domain-friendly name
// UserNotFoundException -> UserNotFound
// OrderCancelledException -> OrderCancelled
// PaymentFailedException -> PaymentFailed
return $baseName;
}
}

View File

@@ -0,0 +1,212 @@
# PHPStan Naming Convention Rules
Automated enforcement of framework naming conventions via PHPStan static analysis.
## Installed Rules
### 1. ControllerNamingRule
**Enforces:**
- ✅ View Controllers: `Show{Feature}` pattern
- ✅ Action Controllers: `{Verb}{Feature}` pattern
- ❌ NO "Controller" suffix
- ❌ Show* controllers must only have GET routes
- ❌ Show* controllers must return ViewResult
**Examples:**
```php
// ✅ CORRECT
class ShowContact { } // View Controller
class SubmitContact { } // Action Controller
class CreateUser { } // Action Controller
// ❌ WRONG
class ContactController { } // Has "Controller" suffix
class ShowContact {
#[Route('/contact', POST)] // Show* with POST route
public function submit() { }
}
```
**Error Identifiers:**
- `naming.controller.noSuffix`
- `naming.controller.showPattern`
- `naming.controller.pattern`
---
### 2. ExceptionNamingRule
**Enforces:**
- ✅ Domain Exceptions: NO "Exception" suffix
- ✅ Technical Exceptions: WITH "Exception" suffix
- ✅ Hybrid approach based on namespace
**Examples:**
```php
// ✅ CORRECT - Domain Exceptions
namespace App\Domain\User\Exceptions;
class UserNotFound extends FrameworkException { }
class PaymentFailed extends FrameworkException { }
// ✅ CORRECT - Technical Exceptions
namespace App\Framework\Database\Exception;
class DatabaseException extends FrameworkException { }
class ValidationException extends FrameworkException { }
// ❌ WRONG
namespace App\Domain\User\Exceptions;
class UserNotFoundException { } // Domain exception with suffix
namespace App\Framework\Database;
class DatabaseError { } // Technical exception without suffix
```
**Error Identifiers:**
- `naming.exception.technicalSuffix`
- `naming.exception.domainNoSuffix`
---
### 3. CommandNamingRule
**Enforces:**
- ✅ CQRS Commands: NO "Command" suffix
- ✅ Console Commands: WITH "Command" suffix
- ✅ CQRS Commands follow Verb-Noun pattern
**Examples:**
```php
// ✅ CORRECT - CQRS Commands
namespace App\Application\Contact;
class SendEmail { }
class CreateUser { }
class StoreContact { }
// ✅ CORRECT - Console Commands
namespace App\Framework\Console\Commands;
class HealthCheckCommand implements ConsoleCommand { }
class MakeMigrationCommand implements ConsoleCommand { }
// ❌ WRONG
namespace App\Application\Contact;
class SendEmailCommand { } // CQRS with suffix
namespace App\Framework\Console\Commands;
class HealthCheck { } // Console without suffix
```
**Error Identifiers:**
- `naming.command.consoleSuffix`
- `naming.command.cqrsNoSuffix`
- `naming.command.verbNounPattern`
---
### 4. ValueObjectNamingRule
**Enforces:**
- ✅ NO "VO" suffix
- ✅ NO "ValueObject" suffix
- ✅ Descriptive names
**Examples:**
```php
// ✅ CORRECT
final readonly class Email { }
final readonly class Price { }
final readonly class Duration { }
// ❌ WRONG
final readonly class EmailVO { }
final readonly class PriceValueObject { }
```
**Error Identifiers:**
- `naming.valueObject.noVoSuffix`
- `naming.valueObject.noValueObjectSuffix`
---
## Running PHPStan
### Full Analysis
```bash
make phpstan
# or
./vendor/bin/phpstan analyse
```
### Specific File
```bash
./vendor/bin/phpstan analyse src/Application/Contact/ShowContact.php
```
### Generate Baseline (for existing violations)
```bash
./vendor/bin/phpstan analyse --generate-baseline
```
## Ignoring Specific Violations
### Per-file ignore in phpstan.neon
```neon
parameters:
ignoreErrors:
-
message: '#Controller ".*" should not have "Controller" suffix#'
path: src/Application/Legacy/*
```
### Inline ignore
```php
/** @phpstan-ignore-next-line naming.controller.noSuffix */
class LegacyUserController { }
```
## Configuration
Rules are registered in `phpstan-rules.neon`:
```neon
rules:
- App\Framework\Quality\PHPStan\Rules\Naming\ControllerNamingRule
- App\Framework\Quality\PHPStan\Rules\Naming\ExceptionNamingRule
- App\Framework\Quality\PHPStan\Rules\Naming\CommandNamingRule
- App\Framework\Quality\PHPStan\Rules\Naming\ValueObjectNamingRule
```
## Gradual Migration
**Approach 1: Baseline**
1. Run `./vendor/bin/phpstan analyse --generate-baseline`
2. Fix violations incrementally
3. Remove from baseline as you fix
**Approach 2: Exclude Paths**
```neon
parameters:
excludePaths:
- src/Application/Legacy/*
```
**Approach 3: Per-Rule Ignores**
```neon
parameters:
ignoreErrors:
- '#naming.controller.noSuffix#' # Temporarily ignore
```
## Benefits
**Automated Enforcement** - Naming conventions checked on every analysis
**Clear Error Messages** - Actionable feedback with suggestions
**IDE Integration** - Real-time feedback in PhpStorm/VSCode
**CI/CD Integration** - Fail builds on violations
**Gradual Migration** - Baseline support for existing code
## Related Documentation
- [Naming Conventions](../../../../../docs/claude/naming-conventions.md) - Full naming guidelines
- [PHPStan Documentation](https://phpstan.org/user-guide/getting-started) - PHPStan basics
- [Custom Rules](https://phpstan.org/developing-extensions/rules) - Creating custom rules

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Framework\Quality\PHPStan\Rules\Naming;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Enforces Value Object Naming Convention:
* - NO "VO" or "ValueObject" suffix
* - Descriptive names (Email, Price, Duration, not EmailVO or EmailValueObject)
*
* @implements Rule<InClassNode>
*/
final class ValueObjectNamingRule implements Rule
{
public function getNodeType(): string
{
return InClassNode::class;
}
/**
* @param InClassNode $node
*/
public function processNode(Node $node, Scope $scope): array
{
$classReflection = $node->getClassReflection();
$className = $classReflection->getName();
$shortName = $classReflection->getNativeReflection()->getShortName();
// Check if it's a Value Object
if (!$this->isValueObject($classReflection)) {
return [];
}
$errors = [];
// Rule 1: NO "VO" suffix
if (str_ends_with($shortName, 'VO')) {
$suggestedName = preg_replace('/VO$/', '', $shortName);
$errors[] = RuleErrorBuilder::message(sprintf(
'Value Object "%s" should not have "VO" suffix. Rename to "%s".',
$shortName,
$suggestedName
))
->identifier('naming.valueObject.noVoSuffix')
->build();
}
// Rule 2: NO "ValueObject" suffix
if (str_ends_with($shortName, 'ValueObject')) {
$suggestedName = preg_replace('/ValueObject$/', '', $shortName);
$errors[] = RuleErrorBuilder::message(sprintf(
'Value Object "%s" should not have "ValueObject" suffix. Rename to "%s".',
$shortName,
$suggestedName
))
->identifier('naming.valueObject.noValueObjectSuffix')
->build();
}
return $errors;
}
private function isValueObject(\PHPStan\Reflection\ClassReflection $classReflection): bool
{
$nativeReflection = $classReflection->getNativeReflection();
// Value Objects are typically:
// 1. Readonly classes
if (!$nativeReflection->isReadOnly()) {
return false;
}
// 2. Final classes
if (!$nativeReflection->isFinal()) {
return false;
}
// 3. In specific namespaces (ValueObjects, Core, Domain)
$className = $classReflection->getName();
$valueObjectNamespaces = [
'\\ValueObjects\\',
'\\Core\\ValueObjects\\',
'\\Domain\\',
];
foreach ($valueObjectNamespaces as $namespace) {
if (str_contains($className, $namespace)) {
return true;
}
}
// 4. Has only readonly public properties and no methods with business logic
$publicMethods = array_filter(
$nativeReflection->getMethods(),
fn($m) => $m->isPublic() && !$m->isConstructor() && !$m->isStatic()
);
// Value Objects typically have few public methods (mostly transformations/getters)
if (count($publicMethods) <= 5) {
// Check for common Value Object methods
$valueObjectMethods = ['equals', 'toString', 'toArray', 'toJson', 'toDecimal', 'format'];
foreach ($publicMethods as $method) {
if (in_array($method->getName(), $valueObjectMethods, true)) {
return true;
}
}
}
return false;
}
}