*/ 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}" }; } }