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,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
/**
* Admin routes for administrative interface
*/
enum AdminRoutes: string implements RouteNameInterface
{
case DASHBOARD = 'admin.dashboard';
case MIGRATIONS = 'admin.migrations';
// Infrastructure
case INFRASTRUCTURE_REDIS = 'admin.infrastructure.redis';
case INFRASTRUCTURE_CACHE = 'admin.infrastructure.cache';
case INFRASTRUCTURE_SERVICES = 'admin.infrastructure.services';
// Analytics
case ANALYTICS_DASHBOARD = 'admin.analytics.dashboard';
// Content Management
case CONTENT_IMAGES = 'admin.content.images';
case CONTENT_IMAGE_SLOTS = 'admin.content.image-slots';
// Development Tools
case DEV_ROUTES = 'admin.dev.routes';
case DEV_DESIGN_SYSTEM = 'admin.dev.design-system';
case DEV_WAF_TEST = 'admin.dev.waf-test';
// System
case SYSTEM_PERFORMANCE = 'admin.system.performance';
case SYSTEM_HEALTH = 'admin.system.health';
case SYSTEM_PHPINFO = 'admin.system.phpinfo';
case SYSTEM_ENVIRONMENT = 'admin.system.environment';
public function getCategory(): RouteCategory
{
return RouteCategory::ADMIN;
}
public function isApiRoute(): bool
{
return false;
}
public function isAdminRoute(): bool
{
return true;
}
public function isWebRoute(): bool
{
return false;
}
public function isAuthRoute(): bool
{
return false;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
/**
* API routes for application interface
*/
enum ApiRoutes: string implements RouteNameInterface
{
case USERS_LIST = 'api_users_list';
case USERS_SHOW = 'api_users_show';
case USERS_CREATE = 'api_users_create';
case HEALTH = 'api_health';
case DOCS = 'api_docs';
case DOCS_SELFHOSTED = 'api_docs_selfhosted';
case DOCS_SIMPLE = 'api_docs_simple';
case DOCS_CONVERTER_TEST = 'api_docs_converter_test';
case DOCS_MARKDOWN_SIMPLE = 'api_docs_markdown_simple';
case DOCS_MARKDOWN_RENDERER = 'api_docs_markdown_renderer';
case DOCS_MARKDOWN_FULL = 'api_docs_markdown_full';
case DOCS_VIEWRESULT_DEBUG = 'api_docs_viewresult_debug';
case DOCS_MARKDOWN = 'api_docs_markdown';
case DOCS_MARKDOWN_THEMED = 'api_docs_markdown_themed';
case DOCS_TEST = 'api_docs_test';
case DOCS_THEMES = 'api_docs_themes';
case OPENAPI_SPEC = 'api_openapi_spec';
public function getCategory(): RouteCategory
{
return RouteCategory::API;
}
public function isApiRoute(): bool
{
return true;
}
public function isAdminRoute(): bool
{
return false;
}
public function isWebRoute(): bool
{
return false;
}
public function isAuthRoute(): bool
{
return false;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
/**
* Health check routes for system monitoring
*/
enum HealthRoutes: string implements RouteNameInterface
{
case HEALTH_CHECK = 'health_check';
case HEALTH_LIVENESS = 'health_liveness';
case HEALTH_READINESS = 'health_readiness';
public function getCategory(): RouteCategory
{
return RouteCategory::WEB;
}
public function isApiRoute(): bool
{
return false;
}
public function isAdminRoute(): bool
{
return false;
}
public function isWebRoute(): bool
{
return true;
}
public function isAuthRoute(): bool
{
return false;
}
}

View File

@@ -97,7 +97,41 @@ final readonly class HttpRouter implements Router
{
$pattern = $this->optimizedRoutes->getCompiledPattern($method, $subdomainKey);
if (! $pattern || ! preg_match($pattern->regex, $path, $matches)) {
if (! $pattern) {
return null;
}
// Try optimized batches first if available
// Test batches in reverse order (complex -> medium -> simple) for correct specificity
if ($pattern->hasOptimizedBatches()) {
foreach (array_reverse($pattern->getBatches(), true) as $batchIndex => $batch) {
if (preg_match($batch['regex'], $path, $matches)) {
// Search only within this batch's routes, not all routes
$routeIndex = $this->findMatchingRouteIndexInBatch($matches, $batch['routes']);
$routeData = $batch['routes'][$routeIndex];
$paramValues = $routeData->extractParameterValues($matches);
$originalRoute = $routeData->route;
return new DynamicRoute(
regex: $originalRoute->regex,
paramNames: $originalRoute->paramNames,
controller: $originalRoute->controller,
action: $originalRoute->action,
parameters: $originalRoute->parameters,
paramValues: $paramValues,
name: $originalRoute->name,
path: $originalRoute->path,
attributes: $originalRoute->attributes ?? [],
parameterCollection: $originalRoute->parameterCollection
);
}
}
return null;
}
// Fallback to primary regex for backward compatibility
if (! preg_match($pattern->regex, $path, $matches)) {
return null;
}
@@ -141,6 +175,22 @@ final readonly class HttpRouter implements Router
return 0;
}
/**
* Find matching route index within a specific batch
* @param array<int|string, string> $matches
* @param array<int, RouteData> $batchRoutes
*/
private function findMatchingRouteIndexInBatch(array $matches, array $batchRoutes): int
{
foreach ($batchRoutes as $index => $routeData) {
if ($routeData->shouldMatch($matches)) {
return $index;
}
}
return 0;
}
/**
* Extract subdomain from host
*/

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
/**
* Media routes for file serving
*/
enum MediaRoutes: string implements RouteNameInterface
{
case SHOW_IMAGE = 'show_image';
public function getCategory(): RouteCategory
{
return RouteCategory::MEDIA;
}
public function isApiRoute(): bool
{
return false;
}
public function isAdminRoute(): bool
{
return false;
}
public function isWebRoute(): bool
{
return false;
}
public function isAuthRoute(): bool
{
return false;
}
}

View File

@@ -14,6 +14,7 @@ use App\Framework\Router\Exceptions\ParameterResolutionException;
use App\Framework\Router\ValueObjects\MethodParameter;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\RouteParameters;
use App\Framework\TypeCaster\TypeCasterRegistry;
use App\Framework\Validation\Exceptions\ValidationException;
use ReflectionException;
@@ -23,7 +24,8 @@ final readonly class ParameterProcessor
private DefaultContainer $container,
private ControllerRequestFactory $controllerRequestFactory,
private DefaultLogger $logger,
private ReflectionProvider $reflectionProvider
private ReflectionProvider $reflectionProvider,
private TypeCasterRegistry $typeCasterRegistry
) {
}
@@ -116,7 +118,25 @@ final readonly class ParameterProcessor
// Route-Parameter aus queryParams extrahieren
if (array_key_exists($parameter->name, $queryParams)) {
return $queryParams[$parameter->name];
$rawValue = $queryParams[$parameter->name];
// Try to convert using TypeCaster if type is specified
if ($parameter->type !== null && !$parameter->isBuiltin) {
$caster = $this->typeCasterRegistry->getCasterForType($parameter->type);
if ($caster !== null && is_string($rawValue)) {
try {
return $caster->fromString($rawValue);
} catch (\Throwable $e) {
throw new ParameterResolutionException(
"Failed to convert route parameter '{$parameter->name}' to {$parameter->type}: " . $e->getMessage(),
0,
$e
);
}
}
}
return $rawValue;
}
// Builtin-Typen mit Default-Werten

View File

@@ -15,4 +15,9 @@ final class Redirect implements ActionResult
public readonly string $target,
) {
}
public static function to(string $target): self
{
return new self($target);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
/**
* Categories for route organization and type-safe routing
*/
enum RouteCategory: string
{
case WEB = 'web';
case API = 'api';
case ADMIN = 'admin';
case AUTH = 'auth';
case MEDIA = 'media';
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Attributes\Route;
use App\Framework\Context\ContextType;
use App\Framework\Core\RouteCompiler;
use App\Framework\DI\Initializer;
use App\Framework\Discovery\Results\DiscoveryRegistry;
/**
* Route Compiler Initializer - Kompiliert entdeckte Routen für Web-Context
*/
final readonly class RouteCompilerInitializer
{
public function __construct(
private DiscoveryRegistry $discoveryRegistry,
private RouteCompiler $routeCompiler
) {
}
#[Initializer(ContextType::WEB)]
public function __invoke(): CompiledRoutes
{
error_log("RouteCompilerInitializer: Starting route compilation");
try {
// Hole Route-Attribute aus Discovery Registry
error_log("RouteCompilerInitializer: About to access discoveryRegistry->attributes");
$routeAttributes = $this->discoveryRegistry->attributes->get(Route::class);
error_log("RouteCompilerInitializer: Successfully got " . count($routeAttributes) . " route attributes");
if (empty($routeAttributes)) {
error_log("RouteCompilerInitializer: No routes found, returning empty CompiledRoutes");
return new CompiledRoutes([], [], []);
}
// Kompiliere die Routen
error_log("RouteCompilerInitializer: About to compile routes");
$compiledData = $this->routeCompiler->compile(...$routeAttributes);
error_log("RouteCompilerInitializer: Successfully compiled " . count($compiledData) . " route groups");
// Extrahiere Arrays aus kompilierten Daten
$staticRoutes = [];
$dynamicRoutes = [];
$namedRoutes = [];
foreach ($compiledData as $subdomain => $methods) {
foreach ($methods as $method => $routes) {
$staticRoutes[$subdomain][$method] = $routes['static'] ?? [];
$dynamicRoutes[$subdomain][$method] = $routes['dynamic'] ?? [];
// Named routes extrahieren
foreach (array_merge($routes['static'] ?? [], $routes['dynamic'] ?? []) as $route) {
if (isset($route->name) && $route->name !== null) {
$namedRoutes[$route->name] = $route;
}
}
}
}
error_log("RouteCompilerInitializer: Created CompiledRoutes with " .
count($staticRoutes) . " static and " . count($dynamicRoutes) . " dynamic route groups");
$result = new CompiledRoutes($staticRoutes, $dynamicRoutes, $namedRoutes);
error_log("RouteCompilerInitializer: Successfully created CompiledRoutes object");
return $result;
} catch (\Throwable $e) {
error_log("RouteCompilerInitializer: ERROR - " . $e->getMessage());
error_log("RouteCompilerInitializer: ERROR - Stack trace: " . $e->getTraceAsString());
// Return empty routes on error
return new CompiledRoutes([], [], []);
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Attributes\Singleton;
/**
* Type-safe route helper for generating URLs with specific route enums
*/
#[Singleton]
final readonly class RouteHelper
{
public function __construct(
private UrlGenerator $urlGenerator
) {
}
/**
* Generate URL for web routes
*
* @param array<string, mixed> $params
*/
public function web(WebRoutes $route, array $params = [], bool $absolute = false): string
{
return $this->urlGenerator->route($route, $params, $absolute);
}
/**
* Generate URL for API routes
*
* @param array<string, mixed> $params
*/
public function api(ApiRoutes $route, array $params = [], bool $absolute = false): string
{
return $this->urlGenerator->route($route, $params, $absolute);
}
/**
* Generate URL for admin routes
*
* @param array<string, mixed> $params
*/
public function admin(AdminRoutes $route, array $params = [], bool $absolute = false): string
{
return $this->urlGenerator->route($route, $params, $absolute);
}
/**
* Generate URL for health routes
*
* @param array<string, mixed> $params
*/
public function health(HealthRoutes $route, array $params = [], bool $absolute = false): string
{
return $this->urlGenerator->route($route, $params, $absolute);
}
/**
* Generate URL for media routes
*
* @param array<string, mixed> $params
*/
public function media(MediaRoutes $route, array $params = [], bool $absolute = false): string
{
return $this->urlGenerator->route($route, $params, $absolute);
}
/**
* Generate URL for any route implementing RouteNameInterface
*
* @param array<string, mixed> $params
*/
public function any(RouteNameInterface $route, array $params = [], bool $absolute = false): string
{
return $this->urlGenerator->route($route, $params, $absolute);
}
/**
* Generate absolute URL for web routes
*
* @param array<string, mixed> $params
*/
public function absoluteWeb(WebRoutes $route, array $params = []): string
{
return $this->web($route, $params, true);
}
/**
* Generate absolute URL for API routes
*
* @param array<string, mixed> $params
*/
public function absoluteApi(ApiRoutes $route, array $params = []): string
{
return $this->api($route, $params, true);
}
/**
* Generate absolute URL for admin routes
*
* @param array<string, mixed> $params
*/
public function absoluteAdmin(AdminRoutes $route, array $params = []): string
{
return $this->admin($route, $params, true);
}
/**
* Check if current route matches given web route
*/
public function isCurrentWeb(WebRoutes $route): bool
{
return $this->urlGenerator->isCurrentRoute($route);
}
/**
* Check if current route matches given API route
*/
public function isCurrentApi(ApiRoutes $route): bool
{
return $this->urlGenerator->isCurrentRoute($route);
}
/**
* Check if current route matches given admin route
*/
public function isCurrentAdmin(AdminRoutes $route): bool
{
return $this->urlGenerator->isCurrentRoute($route);
}
/**
* Check if current route matches any route
*/
public function isCurrent(RouteNameInterface $route): bool
{
return $this->urlGenerator->isCurrentRoute($route);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
/**
* Interface for type-safe route name handling.
*
* Enables unified handling of different route enum categories while maintaining type safety.
*/
interface RouteNameInterface
{
/**
* Get the category this route belongs to
*/
public function getCategory(): RouteCategory;
/**
* Check if this is an API route
*/
public function isApiRoute(): bool;
/**
* Check if this is an admin route
*/
public function isAdminRoute(): bool;
/**
* Check if this is a web route
*/
public function isWebRoute(): bool;
/**
* Check if this is an auth route
*/
public function isAuthRoute(): bool;
}

View File

@@ -16,7 +16,12 @@ final readonly class RouteParametersInitializer
{
$request = $container->get(Request::class);
// Kombiniere alle Parameter-Quellen in Prioritäts-Reihenfolge:
// Return existing route parameters if already set (from middleware)
if (!$request->routeParameters->isEmpty()) {
return $request->routeParameters;
}
// Fallback: Kombiniere alle Parameter-Quellen in Prioritäts-Reihenfolge:
// 1. Query-Parameter
// 2. POST/Request-Body Parameter
$allParams = array_merge(

View File

@@ -88,7 +88,26 @@ final readonly class RouteResponder
private function renderTemplate(RenderContext $context): string
{
return $this->templateRenderer->render($context);
// Force log to file to ensure visibility
file_put_contents('/tmp/debug.log', "RouteResponder::renderTemplate STARTED for template: " . $context->template . "\n", FILE_APPEND);
file_put_contents('/tmp/debug.log', "Template data keys: " . implode(', ', array_keys($context->data)) . "\n", FILE_APPEND);
error_log("RouteResponder::renderTemplate STARTED for template: " . $context->template);
error_log("Template renderer class: " . get_class($this->templateRenderer));
// Log the class before calling render
error_log("RouteResponder: About to call render on class: " . get_class($this->templateRenderer));
file_put_contents('/tmp/debug.log', "About to call render on: " . get_class($this->templateRenderer) . "\n", FILE_APPEND);
$result = $this->templateRenderer->render($context);
file_put_contents('/tmp/debug.log', "RouteResponder::renderTemplate COMPLETED, result length: " . strlen($result) . "\n", FILE_APPEND);
file_put_contents('/tmp/debug.log', "Result contains {{: " . (str_contains($result, '{{') ? 'YES' : 'NO') . "\n", FILE_APPEND);
error_log("RouteResponder::renderTemplate COMPLETED, result length: " . strlen($result));
error_log("Result contains {{ model.title }}: " . (str_contains($result, '{{ model.title }}') ? 'YES' : 'NO'));
return $result;
}
private function isSpaRequest(): bool
@@ -105,7 +124,15 @@ final readonly class RouteResponder
$hasSpaRequest = ! empty($_SERVER['HTTP_X_SPA_REQUEST']);
}
return $xmlHttpRequest === 'XMLHttpRequest' && $hasSpaRequest;
// DEBUG: Log SPA detection
error_log("SPA Detection - xmlHttpRequest: '{$xmlHttpRequest}', hasSpaRequest: " . ($hasSpaRequest ? 'true' : 'false'));
error_log("SPA Detection - Headers: " . json_encode($this->request->headers->toArray()));
error_log("SPA Detection - SERVER vars: X-Requested-With='" . ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? 'null') . "', X-SPA-Request='" . ($_SERVER['HTTP_X_SPA_REQUEST'] ?? 'null') . "'");
$isSpa = $xmlHttpRequest === 'XMLHttpRequest' && $hasSpaRequest;
error_log("SPA Detection - Final result: " . ($isSpa ? 'TRUE (returning JSON)' : 'FALSE (returning HTML)'));
return $isSpa;
}
private function createSpaResponse(ViewResult $result): JsonResponse

View File

@@ -34,29 +34,18 @@ final readonly class RouterSetup
{
#$routeCache = new RouteCache($this->pathProvider->getCachePath('routes.cache.php'));
error_log("DEBUG RouterSetup: Starting route setup");
// Debug: Check what keys are available in DiscoveryRegistry
error_log("DEBUG RouterSetup: Checking for Route::class = " . Route::class);
$routeCount = $this->results->attributes->getCount(Route::class);
error_log("DEBUG RouterSetup: Found $routeCount routes in DiscoveryRegistry");
if ($routeCount > 0) {
$discoveredRoutes = $this->results->attributes->get(Route::class);
error_log("DEBUG RouterSetup: Found " . count($discoveredRoutes) . " routes in discovery results");
// Direct DiscoveredAttribute API
$optimizedRoutes = $this->routeCompiler->compileOptimized(...$discoveredRoutes);
#error_log("DEBUG RouterSetup: Routes compiled successfully - CompiledRoutes object created");
// Cache speichern
#$routeCache->save($optimizedRoutes);
// FULL COMMIT: Nur CompiledRoutes
} else {
error_log("DEBUG RouterSetup: No routes found in discovery registry");
$optimizedRoutes = $this->routeCompiler->compileOptimized();
}
@@ -66,8 +55,6 @@ final readonly class RouterSetup
$this->container->bind(HttpRouter::class, $router);
$this->container->bind(Router::class, $router); // Interface binding
error_log("DEBUG RouterSetup: Router setup completed successfully");
return $router;
}
}

View File

@@ -8,6 +8,7 @@ use App\Framework\Attributes\Singleton;
use App\Framework\Core\Route;
use App\Framework\Http\Request;
use App\Framework\Router\Exception\RouteNotFound;
use App\Framework\Router\RouteNameInterface;
/**
* Service zur Generierung von URLs basierend auf Named Routes
@@ -24,18 +25,25 @@ final readonly class UrlGenerator
/**
* Generiert eine URL für eine Named Route
*
* @param string $name Name der Route
* @param RouteNameInterface|string $name Name der Route
* @param array<string, mixed> $parameters Parameter für dynamische Routes
* @param bool $absolute Ob eine absolute URL generiert werden soll
* @return string Generierte URL
* @throws RouteNotFound Wenn die Route nicht gefunden wird
*/
public function route(string $name, array $parameters = [], bool $absolute = false): string
public function route(RouteNameInterface|string $name, array $parameters = [], bool $absolute = false): string
{
$route = $this->routes->getNamedRoute($name);
$routeName = $name instanceof RouteNameInterface ? $name->value : $name;
// Debug logging to identify missing route
error_log("UrlGenerator: Attempting to generate URL for route: '{$routeName}'");
$route = $this->routes->getNamedRoute($routeName);
if ($route === null) {
throw new RouteNotFound("Route with name '{$name}' not found.");
error_log("UrlGenerator: Route '{$routeName}' not found! Available routes: " . implode(', ', array_keys($this->routes->getAllNamedRoutes())));
throw new RouteNotFound("Route with name '{$routeName}' not found.");
}
$url = $this->buildUrl($route->path, $parameters);
@@ -50,11 +58,11 @@ final readonly class UrlGenerator
/**
* Generiert eine absolute URL für eine Named Route
*
* @param string $name Name der Route
* @param RouteNameInterface|string $name Name der Route
* @param array<string, mixed> $parameters Parameter für dynamische Routes
* @return string Absolute URL
*/
public function absoluteRoute(string $name, array $parameters = []): string
public function absoluteRoute(RouteNameInterface|string $name, array $parameters = []): string
{
return $this->route($name, $parameters, true);
}
@@ -89,10 +97,12 @@ final readonly class UrlGenerator
/**
* Prüft, ob die aktuelle Route mit dem gegebenen Namen übereinstimmt
*/
public function isCurrentRoute(string $name): bool
public function isCurrentRoute(RouteNameInterface|string $name): bool
{
$routeName = $name instanceof RouteNameInterface ? $name->value : $name;
try {
$route = $this->routes->getNamedRoute($name);
$route = $this->routes->getNamedRoute($routeName);
return $route !== null && $route->path === $this->request->path;
} catch (RouteNotFound) {

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use App\Framework\Http\Method;
use InvalidArgumentException;
final readonly class GroupRoute
{
/** @var array<string> */
private array $middleware;
public function __construct(
private RoutePath $path,
private Method $method,
private string $handler,
private ?string $name = null,
array $middleware = []
) {
if (empty($handler)) {
throw new InvalidArgumentException('Route handler cannot be empty');
}
$this->middleware = $middleware;
}
/**
* Create a new route
*/
public static function create(
RoutePath $path,
Method $method,
string $handler,
?string $name = null,
array $middleware = []
): self {
return new self($path, $method, $handler, $name, $middleware);
}
/**
* Get the route path
*/
public function getPath(): RoutePath
{
return $this->path;
}
/**
* Get the HTTP method
*/
public function getMethod(): Method
{
return $this->method;
}
/**
* Get the route handler
*/
public function getHandler(): string
{
return $this->handler;
}
/**
* Get the route name
*/
public function getName(): ?string
{
return $this->name;
}
/**
* Get middleware for this route
*
* @return array<string>
*/
public function getMiddleware(): array
{
return $this->middleware;
}
/**
* Create a new route with updated path
*/
public function withPath(RoutePath $path): self
{
return new self($path, $this->method, $this->handler, $this->name, $this->middleware);
}
/**
* Create a new route with additional middleware
*
* @param array<string> $middleware
*/
public function withMiddleware(array $middleware): self
{
return new self($this->path, $this->method, $this->handler, $this->name, $middleware);
}
/**
* Create a new route with updated name
*/
public function withName(string $name): self
{
return new self($this->path, $this->method, $this->handler, $name, $this->middleware);
}
/**
* Check if this route is dynamic (has parameters)
*/
public function isDynamic(): bool
{
return $this->path->isDynamic();
}
/**
* Get parameter names for dynamic routes
*
* @return array<string>
*/
public function getParameterNames(): array
{
return $this->path->getParameterNames();
}
/**
* Convert to regex pattern for route matching
*/
public function toRegex(): string
{
return $this->path->toRegex();
}
/**
* Convert to string representation
*/
public function toString(): string
{
return sprintf(
'%s %s -> %s%s',
$this->method->value,
$this->path->toString(),
$this->handler,
$this->name ? " [{$this->name}]" : ''
);
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
final readonly class Placeholder
{
private function __construct(
public string $name,
public ?string $pattern = null,
public ?string $type = null,
public bool $isWildcard = false
) {
if (empty($this->name)) {
throw new \InvalidArgumentException('Placeholder name cannot be empty');
}
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $this->name)) {
throw new \InvalidArgumentException("Invalid placeholder name: {$this->name}");
}
}
public static function fromString(string $name): self
{
return new self($name);
}
public static function typed(string $name, string $type, ?string $pattern = null): self
{
return new self(
name: $name,
pattern: $pattern ?? self::getDefaultPatternForType($type),
type: $type
);
}
public static function wildcard(string $name): self
{
return new self(
name: $name,
pattern: '(.+?)', // Non-greedy wildcard
isWildcard: true
);
}
/**
* Convert to string representation for path compilation
*/
public function toString(): string
{
if ($this->isWildcard) {
return "{{$this->name}*}";
}
return "{{$this->name}}";
}
/**
* Get regex pattern for this placeholder
*/
public function getPattern(): string
{
if ($this->pattern !== null) {
return $this->pattern;
}
return $this->isWildcard ? '(.+?)' : '([^/]+)';
}
/**
* Get placeholder name for parameter extraction
*/
public function getName(): string
{
return $this->name;
}
/**
* Check if this is a wildcard placeholder
*/
public function isWildcard(): bool
{
return $this->isWildcard;
}
/**
* Get type hint for this placeholder
*/
public function getType(): ?string
{
return $this->type;
}
/**
* Get default regex pattern for common types
*/
private static function getDefaultPatternForType(string $type): string
{
return match ($type) {
'int', 'integer' => '(\d+)',
'uuid' => '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
'slug' => '([a-z0-9\-]+)',
'alpha' => '([a-zA-Z]+)',
'alphanumeric' => '([a-zA-Z0-9]+)',
'filename' => '([a-zA-Z0-9._\-]+)',
default => '([^/]+)'
};
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use InvalidArgumentException;
final readonly class RouteGroup
{
/** @var array<GroupRoute> */
private array $routes;
/** @var array<string> */
private array $middleware;
public function __construct(
private RoutePath $prefix,
array $middleware,
GroupRoute ...$routes
) {
if (empty($routes)) {
throw new InvalidArgumentException('Route group cannot be empty');
}
$this->routes = array_values($routes);
$this->middleware = $middleware;
}
/**
* Create a new route group with prefix
*/
public static function prefix(RoutePath $prefix): RouteGroupBuilder
{
return new RouteGroupBuilder($prefix);
}
/**
* Create a new route group from routes
*/
public static function fromRoutes(RoutePath $prefix, GroupRoute ...$routes): self
{
return new self($prefix, [], ...$routes);
}
/**
* Get the prefix for this route group
*/
public function getPrefix(): RoutePath
{
return $this->prefix;
}
/**
* Get all routes in this group
*
* @return array<GroupRoute>
*/
public function getRoutes(): array
{
return $this->routes;
}
/**
* Get middleware applied to this group
*
* @return array<string>
*/
public function getMiddleware(): array
{
return $this->middleware;
}
/**
* Apply middleware to this group
*
* @param array<string> $middleware
*/
public function withMiddleware(array $middleware): self
{
return new self($this->prefix, $middleware, ...$this->routes);
}
/**
* Get all routes with prefix applied
*
* @return array<GroupRoute>
*/
public function getCompiledRoutes(): array
{
$compiledRoutes = [];
foreach ($this->routes as $route) {
$prefixedPath = $this->applyPrefix($route->getPath());
$compiledRoute = $route->withPath($prefixedPath);
if (!empty($this->middleware)) {
$combinedMiddleware = array_merge($this->middleware, $route->getMiddleware());
$compiledRoute = $compiledRoute->withMiddleware($combinedMiddleware);
}
$compiledRoutes[] = $compiledRoute;
}
return $compiledRoutes;
}
/**
* Apply prefix to a route path
*/
private function applyPrefix(RoutePath $routePath): RoutePath
{
$prefixElements = $this->prefix->getElements();
$routeElements = $routePath->getElements();
return RoutePath::fromElements(...$prefixElements, ...$routeElements);
}
/**
* Get the number of routes in this group
*/
public function getRouteCount(): int
{
return count($this->routes);
}
/**
* Check if group has any dynamic routes
*/
public function hasDynamicRoutes(): bool
{
foreach ($this->routes as $route) {
if ($route->getPath()->isDynamic()) {
return true;
}
}
return false;
}
/**
* Convert to string representation
*/
public function toString(): string
{
return sprintf(
'RouteGroup[prefix=%s, routes=%d, middleware=%s]',
$this->prefix->toString(),
count($this->routes),
implode(',', $this->middleware)
);
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use App\Framework\Http\Method;
final class RouteGroupBuilder
{
/** @var array<GroupRoute> */
private array $routes = [];
/** @var array<string> */
private array $middleware = [];
public function __construct(
private readonly RoutePath $prefix
) {}
/**
* Add middleware to this group
*
* @param string ...$middleware
*/
public function middleware(string ...$middleware): self
{
$this->middleware = array_merge($this->middleware, $middleware);
return $this;
}
/**
* Add a GET route to the group
*/
public function get(RoutePath $path, string $handler, ?string $name = null): self
{
return $this->addRoute(Method::GET, $path, $handler, $name);
}
/**
* Add a POST route to the group
*/
public function post(RoutePath $path, string $handler, ?string $name = null): self
{
return $this->addRoute(Method::POST, $path, $handler, $name);
}
/**
* Add a PUT route to the group
*/
public function put(RoutePath $path, string $handler, ?string $name = null): self
{
return $this->addRoute(Method::PUT, $path, $handler, $name);
}
/**
* Add a DELETE route to the group
*/
public function delete(RoutePath $path, string $handler, ?string $name = null): self
{
return $this->addRoute(Method::DELETE, $path, $handler, $name);
}
/**
* Add a PATCH route to the group
*/
public function patch(RoutePath $path, string $handler, ?string $name = null): self
{
return $this->addRoute(Method::PATCH, $path, $handler, $name);
}
/**
* Add multiple HTTP methods for the same path
*
* @param array<Method> $methods
*/
public function match(array $methods, RoutePath $path, string $handler, ?string $name = null): self
{
foreach ($methods as $method) {
$this->addRoute($method, $path, $handler, $name);
}
return $this;
}
/**
* Add a route that responds to all HTTP methods
*/
public function any(RoutePath $path, string $handler, ?string $name = null): self
{
return $this->match([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::PATCH,
Method::HEAD,
Method::OPTIONS
], $path, $handler, $name);
}
/**
* Add a nested route group
*/
public function group(RoutePath $nestedPrefix, callable $callback): self
{
$nestedBuilder = new self($nestedPrefix);
$callback($nestedBuilder);
$nestedGroup = $nestedBuilder->build();
foreach ($nestedGroup->getCompiledRoutes() as $route) {
$this->routes[] = $route;
}
return $this;
}
/**
* Add a resource route collection (RESTful routes)
*/
public function resource(string $name, string $controller): self
{
$resourcePath = RoutePath::fromString($name);
$idPlaceholder = Placeholder::fromString('id');
return $this
->get($resourcePath, $controller . '::index', $name . '.index')
->get($resourcePath->append('create'), $controller . '::create', $name . '.create')
->post($resourcePath, $controller . '::store', $name . '.store')
->get($resourcePath->append($idPlaceholder), $controller . '::show', $name . '.show')
->get($resourcePath->append($idPlaceholder)->append('edit'), $controller . '::edit', $name . '.edit')
->put($resourcePath->append($idPlaceholder), $controller . '::update', $name . '.update')
->patch($resourcePath->append($idPlaceholder), $controller . '::update', $name . '.update')
->delete($resourcePath->append($idPlaceholder), $controller . '::destroy', $name . '.destroy');
}
/**
* Add an API resource route collection (no create/edit forms)
*/
public function apiResource(string $name, string $controller): self
{
$resourcePath = RoutePath::fromString($name);
$idPlaceholder = Placeholder::fromString('id');
return $this
->get($resourcePath, $controller . '::index', $name . '.index')
->post($resourcePath, $controller . '::store', $name . '.store')
->get($resourcePath->append($idPlaceholder), $controller . '::show', $name . '.show')
->put($resourcePath->append($idPlaceholder), $controller . '::update', $name . '.update')
->patch($resourcePath->append($idPlaceholder), $controller . '::update', $name . '.update')
->delete($resourcePath->append($idPlaceholder), $controller . '::destroy', $name . '.destroy');
}
/**
* Build the route group
*/
public function build(): RouteGroup
{
return new RouteGroup($this->prefix, $this->middleware, ...$this->routes);
}
/**
* Add a route with the specified method
*/
private function addRoute(Method $method, RoutePath $path, string $handler, ?string $name): self
{
$route = GroupRoute::create(
path: $path,
method: $method,
handler: $handler,
name: $name,
middleware: []
);
$this->routes[] = $route;
return $this;
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use App\Framework\TypeCaster\TypeCasterRegistry;
use InvalidArgumentException;
final readonly class RoutePath
{
/** @var array<string|Placeholder|TypedPlaceholder> */
private array $elements;
private function __construct(string|Placeholder|TypedPlaceholder ...$elements)
{
if (empty($elements)) {
throw new InvalidArgumentException('Route path cannot be empty');
}
$this->elements = array_values($elements);
$this->validateElements();
}
/**
* Create route path from path elements
*
* @param string|Placeholder|TypedPlaceholder ...$elements
*/
public static function fromElements(string|Placeholder|TypedPlaceholder ...$elements): self
{
return new self(...$elements);
}
/**
* Create route path from string (for backward compatibility)
*/
public static function fromString(string $path): self
{
if (empty($path)) {
throw new InvalidArgumentException('Route path cannot be empty');
}
// Remove leading slash
$path = ltrim($path, '/');
// Split by slash and convert to elements
$segments = explode('/', $path);
$elements = [];
foreach ($segments as $segment) {
if (empty($segment)) {
continue;
}
// Check if it's a placeholder {param} or {param*}
if (preg_match('/^\{(\w+)(\*)?}$/', $segment, $matches)) {
$paramName = $matches[1];
$isWildcard = isset($matches[2]) && $matches[2] === '*';
$elements[] = $isWildcard
? Placeholder::wildcard($paramName)
: Placeholder::fromString($paramName);
} else {
$elements[] = $segment;
}
}
return new self(...$elements);
}
/**
* Fluent builder pattern
*/
public static function create(): RoutePathBuilder
{
return new RoutePathBuilder();
}
/**
* Convert to string representation for router compilation
*/
public function toString(): string
{
$segments = [];
foreach ($this->elements as $element) {
if ($element instanceof TypedPlaceholder) {
$segments[] = $element->toString();
} elseif ($element instanceof Placeholder) {
$segments[] = $element->toString();
} else {
$segments[] = $element;
}
}
return '/' . implode('/', $segments);
}
/**
* Get all elements of this route path
*
* @return array<string|Placeholder>
*/
public function getElements(): array
{
return $this->elements;
}
/**
* Get all placeholders in this route path
*
* @return array<Placeholder|TypedPlaceholder>
*/
public function getPlaceholders(): array
{
return array_filter(
$this->elements,
fn($element) => $element instanceof Placeholder || $element instanceof TypedPlaceholder
);
}
/**
* Check if this route has any dynamic parameters
*/
public function isDynamic(): bool
{
return !empty($this->getPlaceholders());
}
/**
* Check if this route is static (no parameters)
*/
public function isStatic(): bool
{
return !$this->isDynamic();
}
/**
* Get parameter names for dynamic routes
*
* @return array<string>
*/
public function getParameterNames(): array
{
$placeholders = $this->getPlaceholders();
return array_map(
fn(Placeholder|TypedPlaceholder $placeholder) => $placeholder->getName(),
$placeholders
);
}
/**
* Convert to regex pattern for route matching
*/
public function toRegex(): string
{
$segments = [];
foreach ($this->elements as $element) {
if ($element instanceof TypedPlaceholder) {
$segments[] = $element->getPattern();
} elseif ($element instanceof Placeholder) {
$segments[] = $element->getPattern();
} else {
$segments[] = preg_quote($element, '~');
}
}
return '~^/' . implode('/', $segments) . '$~';
}
/**
* Add another element to create a new RoutePath
*/
public function append(string|Placeholder $element): self
{
$newElements = [...$this->elements, $element];
return new self(...$newElements);
}
/**
* Prepend an element to create a new RoutePath
*/
public function prepend(string|Placeholder $element): self
{
$newElements = [$element, ...$this->elements];
return new self(...$newElements);
}
/**
* Get the number of segments in this route
*/
public function getSegmentCount(): int
{
return count($this->elements);
}
/**
* Validate route elements
*/
private function validateElements(): void
{
foreach ($this->elements as $element) {
if ($element instanceof TypedPlaceholder || $element instanceof Placeholder) {
continue;
}
if (!is_string($element)) {
throw new InvalidArgumentException(
'Route elements must be strings, Placeholder, or TypedPlaceholder objects'
);
}
if (empty($element)) {
throw new InvalidArgumentException('Route segments cannot be empty');
}
// Check for invalid characters in static segments
if (preg_match('/[{}]/', $element)) {
throw new InvalidArgumentException(
"Invalid characters in route segment: {$element}"
);
}
}
}
/**
* Convert to string when used as string
*/
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
final class RoutePathBuilder
{
/** @var array<string|Placeholder> */
private array $elements = [];
/**
* Add a static segment to the route
*/
public function segment(string $segment): self
{
if (empty($segment)) {
throw new \InvalidArgumentException('Segment cannot be empty');
}
$this->elements[] = $segment;
return $this;
}
/**
* Add a simple placeholder
*/
public function parameter(string $name): self
{
$this->elements[] = Placeholder::fromString($name);
return $this;
}
/**
* Add a typed placeholder with optional pattern
*/
public function typedParameter(string $name, string $type, ?string $pattern = null): self
{
$this->elements[] = Placeholder::typed($name, $type, $pattern);
return $this;
}
/**
* Add a wildcard placeholder
*/
public function wildcard(string $name): self
{
$this->elements[] = Placeholder::wildcard($name);
return $this;
}
/**
* Add multiple segments at once
*/
public function segments(string ...$segments): self
{
foreach ($segments as $segment) {
$this->segment($segment);
}
return $this;
}
/**
* Build the final RoutePath
*/
public function build(): RoutePath
{
if (empty($this->elements)) {
throw new \InvalidArgumentException('Cannot build empty route path');
}
return RoutePath::fromElements(...$this->elements);
}
/**
* Quick build for common patterns
*/
public function id(string $name = 'id'): RoutePath
{
return $this->typedParameter($name, 'int')->build();
}
public function uuid(string $name = 'id'): RoutePath
{
return $this->typedParameter($name, 'uuid')->build();
}
public function slug(string $name = 'slug'): RoutePath
{
return $this->typedParameter($name, 'slug')->build();
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use App\Framework\TypeCaster\TypeCasterRegistry;
use InvalidArgumentException;
/**
* Placeholder that automatically casts to Value Objects using TypeCasters
* Composition-based approach without inheritance
*/
final readonly class TypedPlaceholder
{
private function __construct(
private Placeholder $placeholder,
private string $valueObjectClass,
private TypeCasterRegistry $typeCasterRegistry
) {
// Validate that we have a caster for this type
if ($this->typeCasterRegistry->getCasterForType($this->valueObjectClass) === null) {
throw new InvalidArgumentException(
"No TypeCaster found for type: {$this->valueObjectClass}"
);
}
}
/**
* Create typed placeholder for automatic Value Object binding
*/
public static function for(
string $valueObjectClass,
TypeCasterRegistry $typeCasterRegistry,
?string $name = null
): self {
// Auto-generate name from class if not provided
if ($name === null) {
$name = self::generateNameFromClass($valueObjectClass);
}
// Get pattern from TypeCaster
$pattern = $typeCasterRegistry->getPattern($valueObjectClass);
$placeholder = Placeholder::typed($name, 'custom', $pattern);
return new self($placeholder, $valueObjectClass, $typeCasterRegistry);
}
/**
* Get the underlying placeholder
*/
public function getPlaceholder(): Placeholder
{
return $this->placeholder;
}
/**
* Get the Value Object class for conversion
*/
public function getValueObjectClass(): string
{
return $this->valueObjectClass;
}
/**
* Convert raw route value to Value Object instance
*/
public function convertValue(string $rawValue): mixed
{
return $this->typeCasterRegistry->fromString($rawValue, $this->valueObjectClass);
}
/**
* Get placeholder name
*/
public function getName(): string
{
return $this->placeholder->getName();
}
/**
* Get regex pattern
*/
public function getPattern(): string
{
return $this->placeholder->getPattern();
}
/**
* Convert to string representation for route compilation
*/
public function toString(): string
{
return $this->placeholder->toString();
}
/**
* Generate parameter name from class name
*/
private static function generateNameFromClass(string $className): string
{
$shortName = basename(str_replace('\\', '/', $className));
// Convert UserId -> userId, ProductSlug -> productSlug
return lcfirst($shortName);
}
/**
* Convert to string when used as string
*/
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
/**
* Web routes for public-facing pages
*/
enum WebRoutes: string implements RouteNameInterface
{
case HOME = 'home';
case CONTACT = 'contact';
case EPK = 'epk';
case IMPRESSUM = 'impressum';
case DATENSCHUTZ = 'datenschutz';
case SITEMAP = 'sitemap';
case SITEMAP_READABLE = 'sitemap_readable';
case RAPIDMAIL = 'rapidmail';
public function getCategory(): RouteCategory
{
return RouteCategory::WEB;
}
public function isApiRoute(): bool
{
return false;
}
public function isAdminRoute(): bool
{
return false;
}
public function isWebRoute(): bool
{
return true;
}
public function isAuthRoute(): bool
{
return false;
}
}