fix(Console): add void as valid return type for command methods
The MethodSignatureAnalyzer was rejecting command methods with void return type, causing the schedule:run command to fail validation.
This commit is contained in:
@@ -330,8 +330,9 @@ final readonly class MethodSignatureAnalyzer
|
||||
$returnType = $method->getReturnType();
|
||||
if ($returnType instanceof ReflectionNamedType) {
|
||||
$returnTypeName = $returnType->getName();
|
||||
// Accept: int, ExitCode, ActionResult, or array
|
||||
// Accept: void, int, ExitCode, ActionResult, or array
|
||||
$validReturnTypes = [
|
||||
'void',
|
||||
'int',
|
||||
ExitCode::class,
|
||||
'App\Framework\MagicLinks\Actions\ActionResult',
|
||||
|
||||
116
src/Framework/Core/ValueObjects/FrameworkModule.php
Normal file
116
src/Framework/Core/ValueObjects/FrameworkModule.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Core\ValueObjects;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
use InvalidArgumentException;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Immutable value object representing a Framework module
|
||||
*
|
||||
* A module is a top-level directory within src/Framework/ that groups
|
||||
* related functionality (e.g., Http, Database, Cache, Queue).
|
||||
*/
|
||||
final readonly class FrameworkModule implements Stringable
|
||||
{
|
||||
private const string FRAMEWORK_NAMESPACE_PREFIX = 'App\\Framework\\';
|
||||
|
||||
public PhpNamespace $namespace;
|
||||
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public FilePath $path
|
||||
) {
|
||||
if (empty($name)) {
|
||||
throw new InvalidArgumentException('Module name cannot be empty');
|
||||
}
|
||||
|
||||
if (! preg_match('/^[A-Z][a-zA-Z0-9]*$/', $name)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Invalid module name: {$name}. Must be PascalCase starting with uppercase letter."
|
||||
);
|
||||
}
|
||||
|
||||
$this->namespace = PhpNamespace::fromString(self::FRAMEWORK_NAMESPACE_PREFIX . $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create module from name and base path
|
||||
*/
|
||||
public static function create(string $name, FilePath $frameworkBasePath): self
|
||||
{
|
||||
$modulePath = $frameworkBasePath->join($name);
|
||||
|
||||
return new self($name, $modulePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a namespace belongs to this module
|
||||
*/
|
||||
public function containsNamespace(PhpNamespace $namespace): bool
|
||||
{
|
||||
return $namespace->startsWith($this->namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a class belongs to this module
|
||||
*/
|
||||
public function containsClass(ClassName $className): bool
|
||||
{
|
||||
return $this->containsNamespace($className->getNamespaceObject());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path belongs to this module
|
||||
*/
|
||||
public function containsFile(FilePath $filePath): bool
|
||||
{
|
||||
return $this->path->contains($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relative namespace within this module
|
||||
*
|
||||
* Example: For Http module and namespace App\Framework\Http\Middlewares\Auth
|
||||
* Returns: Middlewares\Auth
|
||||
*/
|
||||
public function getRelativeNamespace(PhpNamespace $namespace): ?PhpNamespace
|
||||
{
|
||||
if (! $this->containsNamespace($namespace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$moduleParts = $this->namespace->parts();
|
||||
$namespaceParts = $namespace->parts();
|
||||
|
||||
// Remove module prefix parts
|
||||
$relativeParts = array_slice($namespaceParts, count($moduleParts));
|
||||
|
||||
if (empty($relativeParts)) {
|
||||
return PhpNamespace::global();
|
||||
}
|
||||
|
||||
return PhpNamespace::fromParts($relativeParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare for equality
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->name === $other->name;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
208
src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php
Normal file
208
src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Core\ValueObjects;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Registry of all Framework modules with lookup capabilities
|
||||
*
|
||||
* Provides efficient lookup of which module a namespace, class, or file belongs to.
|
||||
* Modules are discovered lazily from the Framework directory structure.
|
||||
*/
|
||||
final readonly class FrameworkModuleRegistry
|
||||
{
|
||||
private const string FRAMEWORK_NAMESPACE_PREFIX = 'App\\Framework\\';
|
||||
|
||||
/** @var array<string, FrameworkModule> Module name => Module */
|
||||
private array $modules;
|
||||
|
||||
public function __construct(FrameworkModule ...$modules)
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($modules as $module) {
|
||||
$indexed[$module->name] = $module;
|
||||
}
|
||||
|
||||
$this->modules = $indexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create registry by scanning Framework directory
|
||||
*/
|
||||
public static function discover(FilePath $frameworkPath): self
|
||||
{
|
||||
if (! $frameworkPath->isDirectory()) {
|
||||
throw new InvalidArgumentException(
|
||||
"Framework path does not exist or is not a directory: {$frameworkPath}"
|
||||
);
|
||||
}
|
||||
|
||||
$modules = [];
|
||||
$entries = scandir($frameworkPath->toString());
|
||||
|
||||
if ($entries === false) {
|
||||
throw new InvalidArgumentException(
|
||||
"Cannot read Framework directory: {$frameworkPath}"
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
// Skip hidden files and special entries
|
||||
if ($entry === '.' || $entry === '..' || str_starts_with($entry, '.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entryPath = $frameworkPath->join($entry);
|
||||
|
||||
// Only consider directories as modules
|
||||
if (! $entryPath->isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Module names must be PascalCase
|
||||
if (! preg_match('/^[A-Z][a-zA-Z0-9]*$/', $entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$modules[] = FrameworkModule::create($entry, $frameworkPath);
|
||||
}
|
||||
|
||||
return new self(...$modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module for a PHP namespace
|
||||
*/
|
||||
public function getModuleForNamespace(PhpNamespace $namespace): ?FrameworkModule
|
||||
{
|
||||
$namespaceStr = $namespace->toString();
|
||||
|
||||
// Must be a Framework namespace
|
||||
if (! str_starts_with($namespaceStr, self::FRAMEWORK_NAMESPACE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract module name (first part after App\Framework\)
|
||||
$afterPrefix = substr($namespaceStr, strlen(self::FRAMEWORK_NAMESPACE_PREFIX));
|
||||
$parts = explode('\\', $afterPrefix);
|
||||
$moduleName = $parts[0] ?? null;
|
||||
|
||||
if ($moduleName === null || $moduleName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->modules[$moduleName] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module for a class name
|
||||
*/
|
||||
public function getModuleForClass(ClassName $className): ?FrameworkModule
|
||||
{
|
||||
return $this->getModuleForNamespace($className->getNamespaceObject());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module for a file path
|
||||
*/
|
||||
public function getModuleForFile(FilePath $filePath): ?FrameworkModule
|
||||
{
|
||||
foreach ($this->modules as $module) {
|
||||
if ($module->containsFile($filePath)) {
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two namespaces belong to the same module
|
||||
*/
|
||||
public function inSameModule(PhpNamespace $a, PhpNamespace $b): bool
|
||||
{
|
||||
$moduleA = $this->getModuleForNamespace($a);
|
||||
$moduleB = $this->getModuleForNamespace($b);
|
||||
|
||||
if ($moduleA === null || $moduleB === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $moduleA->equals($moduleB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two classes belong to the same module
|
||||
*/
|
||||
public function classesInSameModule(ClassName $a, ClassName $b): bool
|
||||
{
|
||||
return $this->inSameModule(
|
||||
$a->getNamespaceObject(),
|
||||
$b->getNamespaceObject()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two files belong to the same module
|
||||
*/
|
||||
public function filesInSameModule(FilePath $a, FilePath $b): bool
|
||||
{
|
||||
$moduleA = $this->getModuleForFile($a);
|
||||
$moduleB = $this->getModuleForFile($b);
|
||||
|
||||
if ($moduleA === null || $moduleB === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $moduleA->equals($moduleB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific module by name
|
||||
*/
|
||||
public function getModule(string $name): ?FrameworkModule
|
||||
{
|
||||
return $this->modules[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module exists
|
||||
*/
|
||||
public function hasModule(string $name): bool
|
||||
{
|
||||
return isset($this->modules[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all modules
|
||||
*
|
||||
* @return array<string, FrameworkModule>
|
||||
*/
|
||||
public function getAllModules(): array
|
||||
{
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all module names
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getModuleNames(): array
|
||||
{
|
||||
return array_keys($this->modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module count
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->modules);
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,3 @@ final readonly class DiscoveryWarning
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,4 +101,3 @@ final class DiscoveryWarningAggregator
|
||||
return $this->warningsByFile !== [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,3 @@ final readonly class DiscoveryWarningGroup
|
||||
return count($this->warnings);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Framework\ExceptionHandling\Audit;
|
||||
|
||||
use App\Framework\Audit\AuditLogger;
|
||||
use App\Framework\Audit\ValueObjects\AuditEntry;
|
||||
use App\Framework\Audit\ValueObjects\AuditableAction;
|
||||
use App\Framework\Audit\ValueObjects\AuditEntry;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
@@ -44,7 +44,7 @@ final readonly class ExceptionAuditLogger
|
||||
$context = $context ?? $this->getContext($exception);
|
||||
|
||||
// Skip if not auditable
|
||||
if (!$this->isAuditable($context)) {
|
||||
if (! $this->isAuditable($context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ final readonly class ExceptionAuditLogger
|
||||
// Remove common suffixes
|
||||
$component = $context->component;
|
||||
$component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component);
|
||||
|
||||
return strtolower($component);
|
||||
}
|
||||
|
||||
@@ -246,7 +247,7 @@ final readonly class ExceptionAuditLogger
|
||||
];
|
||||
|
||||
// Add context data
|
||||
if (!empty($context->data)) {
|
||||
if (! empty($context->data)) {
|
||||
$metadata['context_data'] = $context->data;
|
||||
}
|
||||
|
||||
@@ -267,7 +268,7 @@ final readonly class ExceptionAuditLogger
|
||||
}
|
||||
|
||||
// Add tags
|
||||
if (!empty($context->tags)) {
|
||||
if (! empty($context->tags)) {
|
||||
$metadata['tags'] = $context->tags;
|
||||
}
|
||||
|
||||
@@ -283,7 +284,7 @@ final readonly class ExceptionAuditLogger
|
||||
// Merge with existing metadata (but exclude internal fields)
|
||||
$excludeKeys = ['auditable', 'audit_action', 'entity_type'];
|
||||
foreach ($context->metadata as $key => $value) {
|
||||
if (!in_array($key, $excludeKeys, true)) {
|
||||
if (! in_array($key, $excludeKeys, true)) {
|
||||
$metadata[$key] = $value;
|
||||
}
|
||||
}
|
||||
@@ -306,4 +307,3 @@ final readonly class ExceptionAuditLogger
|
||||
return $this->contextProvider->get($exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -32,4 +33,3 @@ final readonly class BasicErrorHandler implements ErrorHandlerInterface
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -31,4 +32,3 @@ final readonly class BasicGlobalExceptionHandler implements ExceptionHandler
|
||||
file_put_contents('php://stderr', $errorOutput);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -16,7 +17,7 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
|
||||
{
|
||||
$last = error_get_last();
|
||||
|
||||
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
|
||||
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,4 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ final readonly class ExceptionContextBuilder
|
||||
if ($baseContext !== null) {
|
||||
return $this->mergeContexts($cached, $baseContext);
|
||||
}
|
||||
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
@@ -109,6 +110,7 @@ final readonly class ExceptionContextBuilder
|
||||
// Extract session ID
|
||||
if (property_exists($request, 'session') && $request->session !== null) {
|
||||
$sessionId = $request->session->id->toString();
|
||||
|
||||
try {
|
||||
$context = $context->withSessionId(SessionId::fromString($sessionId));
|
||||
} catch (\InvalidArgumentException) {
|
||||
@@ -162,13 +164,13 @@ final readonly class ExceptionContextBuilder
|
||||
if ($base->component !== null && $merged->component === null) {
|
||||
$merged = $merged->withOperation($merged->operation ?? '', $base->component);
|
||||
}
|
||||
if (!empty($base->data)) {
|
||||
if (! empty($base->data)) {
|
||||
$merged = $merged->addData($base->data);
|
||||
}
|
||||
if (!empty($base->debug)) {
|
||||
if (! empty($base->debug)) {
|
||||
$merged = $merged->addDebug($base->debug);
|
||||
}
|
||||
if (!empty($base->metadata)) {
|
||||
if (! empty($base->metadata)) {
|
||||
$merged = $merged->addMetadata($base->metadata);
|
||||
}
|
||||
if ($base->userId !== null) {
|
||||
@@ -186,7 +188,7 @@ final readonly class ExceptionContextBuilder
|
||||
if ($base->userAgent !== null) {
|
||||
$merged = $merged->withUserAgent($base->userAgent);
|
||||
}
|
||||
if (!empty($base->tags)) {
|
||||
if (! empty($base->tags)) {
|
||||
$merged = $merged->withTags(...array_merge($merged->tags, $base->tags));
|
||||
}
|
||||
|
||||
@@ -261,7 +263,7 @@ final readonly class ExceptionContextBuilder
|
||||
]);
|
||||
|
||||
// Add scope tags
|
||||
if (!empty($scopeContext->tags)) {
|
||||
if (! empty($scopeContext->tags)) {
|
||||
$context = $context->withTags(...$scopeContext->tags);
|
||||
}
|
||||
|
||||
@@ -299,4 +301,3 @@ final readonly class ExceptionContextBuilder
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ final readonly class ExceptionContextCache
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -210,4 +210,3 @@ final readonly class ExceptionContextCache
|
||||
$this->cache->set($cacheItem);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,4 +37,3 @@ final readonly class ExceptionCorrelation
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,13 @@ final readonly class ExceptionCorrelationEngine
|
||||
// Prefer Request-ID, then Session-ID, then User-ID
|
||||
if ($context->requestId !== null) {
|
||||
$requestId = is_string($context->requestId) ? $context->requestId : $context->requestId->toString();
|
||||
|
||||
return 'request:' . $requestId;
|
||||
}
|
||||
|
||||
if ($context->sessionId !== null) {
|
||||
$sessionId = is_string($context->sessionId) ? $context->sessionId : $context->sessionId->toString();
|
||||
|
||||
return 'session:' . $sessionId;
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ final readonly class ExceptionCorrelationEngine
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return new ExceptionCorrelation(correlationKey: '');
|
||||
}
|
||||
|
||||
@@ -128,4 +130,3 @@ final readonly class ExceptionCorrelationEngine
|
||||
$this->cache->set($cacheItem);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
use App\Framework\Core\ValueObjects\LineNumber;
|
||||
use ErrorException;
|
||||
|
||||
final readonly class ErrorContext
|
||||
{
|
||||
@@ -14,7 +14,8 @@ final readonly class ErrorContext
|
||||
public ?string $file = null,
|
||||
public ?LineNumber $line = null,
|
||||
public bool $isSuppressed = false,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(
|
||||
int $severity,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
enum ErrorDecision
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -9,9 +10,9 @@ use ErrorException;
|
||||
final readonly class ErrorHandler implements ErrorHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy,
|
||||
) {}
|
||||
|
||||
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy(),
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ErrorException
|
||||
@@ -42,6 +43,6 @@ final readonly class ErrorHandler implements ErrorHandlerInterface
|
||||
|
||||
private function isSuppressed($severity): bool
|
||||
{
|
||||
return !(error_reporting() & $severity);
|
||||
return ! (error_reporting() & $severity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
interface ErrorHandlerInterface
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
declare(strict_types=1);
|
||||
|
||||
use ErrorException;
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
interface ErrorHandlerStrategy
|
||||
{
|
||||
|
||||
@@ -19,7 +19,8 @@ final readonly class ErrorHandlingConfig
|
||||
public bool $debugMode = false,
|
||||
public bool $logErrors = true,
|
||||
public bool $displayErrors = false
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create config from EnvironmentType
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -10,9 +11,8 @@ use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
|
||||
use App\Framework\ExceptionHandling\Reporter\Reporter;
|
||||
use App\Framework\ExceptionHandling\Renderers\ConsoleErrorRenderer;
|
||||
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
|
||||
use App\Framework\ExceptionHandling\Reporter\Reporter;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use Throwable;
|
||||
|
||||
@@ -61,12 +61,12 @@ final readonly class ErrorKernel
|
||||
$shouldSkipAudit = $this->rateLimiter?->shouldSkipAudit($e, $exceptionContext) ?? false;
|
||||
|
||||
// Log exception to audit system if auditable and not rate limited
|
||||
if ($this->auditLogger !== null && !$shouldSkipAudit) {
|
||||
if ($this->auditLogger !== null && ! $shouldSkipAudit) {
|
||||
$this->auditLogger->logIfAuditable($e, $exceptionContext);
|
||||
}
|
||||
|
||||
// Log exception if not rate limited and reporter is available
|
||||
if (!$shouldSkipLogging && $this->reporter !== null) {
|
||||
if (! $shouldSkipLogging && $this->reporter !== null) {
|
||||
$this->reporter->report($e);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ final readonly class ErrorKernel
|
||||
// Handle based on context
|
||||
if ($executionContext->isCli()) {
|
||||
$this->handleCliException($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -190,7 +191,7 @@ final readonly class ErrorKernel
|
||||
);
|
||||
|
||||
// Ensure we return HttpResponse (type safety)
|
||||
if (!$result instanceof HttpResponse) {
|
||||
if (! $result instanceof HttpResponse) {
|
||||
throw new \RuntimeException('HTTP renderer must return HttpResponse');
|
||||
}
|
||||
|
||||
@@ -221,4 +222,3 @@ final readonly class ErrorKernel
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -35,6 +36,7 @@ final readonly class ErrorRendererFactory
|
||||
if ($this->executionContext->isCli()) {
|
||||
// ConsoleOutput should always be available in CLI context
|
||||
$output = $this->consoleOutput ?? new ConsoleOutput();
|
||||
|
||||
return new ConsoleErrorRenderer($output);
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ final readonly class ErrorRendererFactory
|
||||
public function createHttpRenderer(?bool $debugMode = null): ResponseErrorRenderer
|
||||
{
|
||||
$debugMode = $debugMode ?? $this->config->debugMode;
|
||||
|
||||
return new ResponseErrorRenderer($this->engine, $debugMode);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
final class ErrorScopeContext
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
@@ -85,4 +85,3 @@ enum ErrorSeverityType: int
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
use Throwable;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
@@ -17,7 +17,8 @@ final readonly class ExceptionHandlerManagerFactory
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and register ExceptionHandlerManager for current execution context
|
||||
@@ -62,4 +63,3 @@ final readonly class ExceptionHandlerManagerFactory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,15 @@ namespace App\Framework\ExceptionHandling;
|
||||
use App\Framework\Config\TypedConfiguration;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Context\ExecutionContext;
|
||||
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
|
||||
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
|
||||
use App\Framework\ExceptionHandling\Reporter\LogReporter;
|
||||
use App\Framework\ExceptionHandling\Reporter\Reporter;
|
||||
use App\Framework\ExceptionHandling\Reporter\ReporterRegistry;
|
||||
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
|
||||
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\View\Engine;
|
||||
@@ -99,7 +99,7 @@ final readonly class ExceptionHandlingInitializer
|
||||
$container->bind(ErrorHandler::class, new ErrorHandler(strategy: $strategy));
|
||||
|
||||
// ExceptionContextProvider - bind as singleton if not already bound
|
||||
if (!$container->has(ExceptionContextProvider::class)) {
|
||||
if (! $container->has(ExceptionContextProvider::class)) {
|
||||
$container->singleton(ExceptionContextProvider::class, new ExceptionContextProvider());
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ final readonly class ExceptionHandlingInitializer
|
||||
private function registerManager(Container $container, ExceptionHandlerManager $manager): void
|
||||
{
|
||||
// Store manager in container for potential later use
|
||||
if (!$container->has(ExceptionHandlerManager::class)) {
|
||||
if (! $container->has(ExceptionHandlerManager::class)) {
|
||||
$container->instance(ExceptionHandlerManager::class, $manager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ final readonly class ExceptionFactory
|
||||
public function __construct(
|
||||
private ExceptionContextProvider $contextProvider,
|
||||
private ErrorScope $errorScope
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception with context
|
||||
@@ -300,7 +301,7 @@ final readonly class ExceptionFactory
|
||||
}
|
||||
|
||||
// Add scope tags
|
||||
if (!empty($scopeContext->tags)) {
|
||||
if (! empty($scopeContext->tags)) {
|
||||
$enriched = $enriched->withTags(...$scopeContext->tags);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -16,7 +17,7 @@ final class FatalErrorTypes
|
||||
E_PARSE,
|
||||
E_CORE_ERROR,
|
||||
E_COMPILE_ERROR,
|
||||
E_USER_ERROR
|
||||
E_USER_ERROR,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -27,4 +28,3 @@ final class FatalErrorTypes
|
||||
return in_array($type, self::FATAL_TYPES, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -7,7 +8,8 @@ final readonly class GlobalExceptionHandler implements ExceptionHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorKernel $errorKernel
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(\Throwable $throwable): void
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Framework\Health\HealthCheckResult;
|
||||
final readonly class ExceptionHealthChecker implements HealthCheckInterface
|
||||
{
|
||||
public readonly string $name;
|
||||
|
||||
public readonly int $timeout;
|
||||
|
||||
/**
|
||||
@@ -105,4 +106,3 @@ final readonly class ExceptionHealthChecker implements HealthCheckInterface
|
||||
return \App\Framework\Health\HealthCheckCategory::APPLICATION;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,4 +81,3 @@ final readonly class ExceptionLocalizer
|
||||
return array_unique($chain);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,4 +40,3 @@ final readonly class ExceptionMetrics
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ final readonly class ExceptionMetricsCollector
|
||||
private function getMetric(string $metricName): int
|
||||
{
|
||||
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $metricName);
|
||||
|
||||
return $this->getMetricValue($cacheKey);
|
||||
}
|
||||
|
||||
@@ -111,11 +112,12 @@ final readonly class ExceptionMetricsCollector
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = $item->value;
|
||||
|
||||
return is_int($value) ? $value : 0;
|
||||
}
|
||||
|
||||
@@ -163,7 +165,7 @@ final readonly class ExceptionMetricsCollector
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit || !is_array($item->value)) {
|
||||
if (! $item->isHit || ! is_array($item->value)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@@ -175,4 +177,3 @@ final readonly class ExceptionMetricsCollector
|
||||
return array_sum($times) / count($times);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ final readonly class PrometheusExporter
|
||||
{
|
||||
// Replace invalid characters
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,4 +180,3 @@ final readonly class ErrorScopeMiddleware implements HttpMiddleware
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,11 +36,10 @@ final readonly class ExceptionPattern
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'description' => $this->description,
|
||||
'fix_suggestions' => array_map(
|
||||
fn(FixSuggestion $suggestion) => $suggestion->toArray(),
|
||||
fn (FixSuggestion $suggestion) => $suggestion->toArray(),
|
||||
$this->fixSuggestions
|
||||
),
|
||||
'occurrence_count' => $this->occurrenceCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ final readonly class ExceptionPatternDetector
|
||||
if (isset($this->knowledgeBase[$exceptionClass])) {
|
||||
$patternData = $this->knowledgeBase[$exceptionClass];
|
||||
$fixSuggestions = array_map(
|
||||
fn(array $fix) => new FixSuggestion(
|
||||
fn (array $fix) => new FixSuggestion(
|
||||
title: $fix['title'] ?? '',
|
||||
description: $fix['description'] ?? '',
|
||||
codeExample: $fix['code'] ?? null,
|
||||
@@ -66,4 +66,3 @@ final readonly class ExceptionPatternDetector
|
||||
return $patterns;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,4 +40,3 @@ final readonly class FixSuggestion
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,4 +51,3 @@ final readonly class ExceptionPerformanceMetrics
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,4 +108,3 @@ final readonly class ExceptionPerformanceTracker
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,4 +143,3 @@ final readonly class ExceptionFingerprint
|
||||
return $this->hash;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,4 +106,3 @@ final readonly class ExceptionRateLimitConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Framework\ExceptionHandling\RateLimit;
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheItem;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use Throwable;
|
||||
|
||||
@@ -39,7 +38,7 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldProcess(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled) {
|
||||
if (! $this->config->enabled) {
|
||||
return true; // Rate limiting disabled, always process
|
||||
}
|
||||
|
||||
@@ -70,11 +69,11 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldSkipLogging(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->skipLoggingOnLimit) {
|
||||
if (! $this->config->enabled || ! $this->config->skipLoggingOnLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->shouldProcess($exception, $context);
|
||||
return ! $this->shouldProcess($exception, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,11 +81,11 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldSkipAudit(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->skipAuditOnLimit) {
|
||||
if (! $this->config->enabled || ! $this->config->skipAuditOnLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->shouldProcess($exception, $context);
|
||||
return ! $this->shouldProcess($exception, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +93,7 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldTrackMetrics(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->trackMetricsOnLimit) {
|
||||
if (! $this->config->enabled || ! $this->config->trackMetricsOnLimit) {
|
||||
return true; // Always track if not enabled or tracking not disabled
|
||||
}
|
||||
|
||||
@@ -116,6 +115,7 @@ final readonly class ExceptionRateLimiter
|
||||
: ExceptionFingerprint::fromException($exception);
|
||||
|
||||
$cacheKey = $this->buildCacheKey($fingerprint);
|
||||
|
||||
return $this->getCachedCount($cacheKey);
|
||||
}
|
||||
|
||||
@@ -135,11 +135,12 @@ final readonly class ExceptionRateLimiter
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = $item->value;
|
||||
|
||||
return is_int($value) ? $value : 0;
|
||||
}
|
||||
|
||||
@@ -179,4 +180,3 @@ final readonly class ExceptionRateLimiter
|
||||
$this->cache->forget($cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Framework\ExceptionHandling\Recovery;
|
||||
|
||||
use App\Framework\Exception\ExceptionMetadata;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -34,7 +33,7 @@ final readonly class ExceptionRecoveryManager
|
||||
}
|
||||
|
||||
// Check if exception is retryable (implements marker interface or is in whitelist)
|
||||
if (!$this->isRetryable($exception)) {
|
||||
if (! $this->isRetryable($exception)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -102,4 +101,3 @@ final readonly class ExceptionRecoveryManager
|
||||
interface RetryableException
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -31,4 +31,3 @@ enum RetryStrategy: string
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
||||
{
|
||||
public function __construct(
|
||||
private ConsoleOutput $output
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function canRender(\Throwable $exception): bool
|
||||
{
|
||||
@@ -107,13 +108,13 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
||||
*/
|
||||
private function formatFileLink(string $filePath, int $line): string
|
||||
{
|
||||
if (!$this->isPhpStorm()) {
|
||||
if (! $this->isPhpStorm()) {
|
||||
return $filePath . ':' . $line;
|
||||
}
|
||||
|
||||
$linkFormatter = $this->output->getLinkFormatter();
|
||||
$relativePath = PhpStormDetector::getRelativePath($filePath);
|
||||
|
||||
|
||||
return $linkFormatter->createFileLinkWithLine($filePath, $line, $relativePath . ':' . $line);
|
||||
}
|
||||
|
||||
@@ -124,9 +125,9 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
||||
{
|
||||
$indexLabel = str_pad((string) $index, $indexWidth, ' ', STR_PAD_LEFT);
|
||||
$baseFormat = sprintf('#%s %s', $indexLabel, $item->formatForDisplay());
|
||||
|
||||
|
||||
// If PhpStorm is detected, replace file:line with clickable link
|
||||
if (!$this->isPhpStorm()) {
|
||||
if (! $this->isPhpStorm()) {
|
||||
return $baseFormat;
|
||||
}
|
||||
|
||||
@@ -135,12 +136,12 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
||||
$linkFormatter = $this->output->getLinkFormatter();
|
||||
$relativePath = PhpStormDetector::getRelativePath($item->file);
|
||||
$fileLink = $linkFormatter->createFileLinkWithLine($item->file, $item->line, $relativePath . ':' . $item->line);
|
||||
|
||||
|
||||
// Replace the file:line part in the formatted string
|
||||
// Use the short file path that's actually in the formatted string
|
||||
$shortFile = $item->getShortFile();
|
||||
$fileLocation = $shortFile . ':' . $item->line;
|
||||
|
||||
|
||||
// Find and replace the file location in the base format
|
||||
$position = strpos($baseFormat, $fileLocation);
|
||||
if ($position !== false) {
|
||||
@@ -151,4 +152,3 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
||||
return $baseFormat;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
private Engine $engine,
|
||||
private bool $isDebugMode = false,
|
||||
private ?ExceptionMessageTranslator $messageTranslator = null
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this renderer can handle the exception
|
||||
@@ -73,7 +74,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
|
||||
// Get user-friendly message if translator is available
|
||||
$context = $contextProvider?->get($exception);
|
||||
$userMessage = $this->messageTranslator?->translate($exception, $context)
|
||||
$userMessage = $this->messageTranslator?->translate($exception, $context)
|
||||
?? new \App\Framework\ExceptionHandling\Translation\UserFriendlyMessage(
|
||||
message: $this->isDebugMode
|
||||
? $exception->getMessage()
|
||||
@@ -85,7 +86,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
'message' => $userMessage->message,
|
||||
'type' => $this->isDebugMode ? $this->getShortClassName(get_class($exception)) : 'ServerError',
|
||||
'code' => $exception->getCode(),
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
if ($userMessage->title !== null) {
|
||||
@@ -136,13 +137,14 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
?ExceptionContextProvider $contextProvider
|
||||
): HttpResponse {
|
||||
$statusCode = $this->getHttpStatusCode($exception);
|
||||
$templateError = null;
|
||||
|
||||
// Try to render using template system
|
||||
$html = $this->renderWithTemplate($exception, $contextProvider, $statusCode);
|
||||
$html = $this->renderWithTemplate($exception, $contextProvider, $statusCode, $templateError);
|
||||
|
||||
// Fallback to simple HTML if template rendering fails
|
||||
if ($html === null) {
|
||||
$html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode);
|
||||
$html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode, $templateError);
|
||||
}
|
||||
|
||||
return new HttpResponse(
|
||||
@@ -163,7 +165,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
private function renderWithTemplate(
|
||||
\Throwable $exception,
|
||||
?ExceptionContextProvider $contextProvider,
|
||||
int $statusCode
|
||||
int $statusCode,
|
||||
?string &$templateError = null
|
||||
): ?string {
|
||||
try {
|
||||
// Determine template name based on status code
|
||||
@@ -194,6 +197,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
));
|
||||
|
||||
// Return null to trigger fallback HTML generation
|
||||
$templateError = $e->getMessage();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -229,6 +233,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
: 'An error occurred while processing your request.',
|
||||
'exceptionClass' => $this->getShortClassName(get_class($exception)),
|
||||
'isDebugMode' => $this->isDebugMode,
|
||||
'requestMethod' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
'requestUri' => $_SERVER['REQUEST_URI'] ?? '/',
|
||||
];
|
||||
|
||||
// Add debug information if enabled
|
||||
@@ -240,8 +246,18 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
'trace' => $stackTrace->formatForHtml(),
|
||||
'trace_short' => $stackTrace->formatShort(),
|
||||
'trace_frames' => $stackTrace->toArray(),
|
||||
'trace_plain' => $stackTrace->formatForConsole(),
|
||||
];
|
||||
|
||||
// Add pre-rendered HTML for stack trace list (collapsible frames)
|
||||
$data['stackTraceHtml'] = $this->renderStackTraceList($stackTrace);
|
||||
|
||||
// Add syntax-highlighted code snippet for exception location
|
||||
$data['codeSnippet'] = $this->getCodeSnippet(
|
||||
$exception->getFile(),
|
||||
$exception->getLine()
|
||||
);
|
||||
|
||||
// Add context from WeakMap if available
|
||||
if ($contextProvider !== null) {
|
||||
$context = $contextProvider->get($exception);
|
||||
@@ -265,7 +281,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
private function generateFallbackHtml(
|
||||
\Throwable $exception,
|
||||
?ExceptionContextProvider $contextProvider,
|
||||
int $statusCode
|
||||
int $statusCode,
|
||||
?string $templateError = null
|
||||
): string {
|
||||
// HTML-encode all variables for security
|
||||
$title = htmlspecialchars($this->getErrorTitle($statusCode), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
@@ -276,6 +293,15 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
ENT_QUOTES | ENT_HTML5,
|
||||
'UTF-8'
|
||||
);
|
||||
$exceptionClass = htmlspecialchars($this->getShortClassName(get_class($exception)), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$requestMethod = htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? 'GET', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$templateNotice = $templateError !== null
|
||||
? '<div class="notice notice--warn">Template fallback: ' . htmlspecialchars($templateError, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</div>'
|
||||
: '';
|
||||
$debugBadge = $this->isDebugMode
|
||||
? '<span class="badge badge--debug">DEBUG</span>'
|
||||
: '<span class="badge badge--prod">PROD</span>';
|
||||
|
||||
$debugInfo = '';
|
||||
if ($this->isDebugMode) {
|
||||
@@ -294,20 +320,39 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.error-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
h1 {
|
||||
color: #d32f2f;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
.badge--debug {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
.badge--prod {
|
||||
background: #fff3cd;
|
||||
color: #8a6d3b;
|
||||
}
|
||||
.error-message {
|
||||
background: #fff3cd;
|
||||
@@ -315,6 +360,36 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.notice {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e1e3e6;
|
||||
background: #f9fafb;
|
||||
color: #555;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.notice--warn {
|
||||
border-color: #ffe599;
|
||||
background: #fffaf0;
|
||||
}
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.meta-card {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e1e3e6;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.debug-info {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
@@ -399,10 +474,25 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<h1>{$title}</h1>
|
||||
<h1>{$title} {$debugBadge}</h1>
|
||||
{$templateNotice}
|
||||
<div class="error-message">
|
||||
<p>{$message}</p>
|
||||
</div>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Status</span>
|
||||
<strong>{$statusCode}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Exception</span>
|
||||
<strong>{$exceptionClass}</strong>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Request</span>
|
||||
<strong>{$requestMethod} {$requestUri}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{$debugInfo}
|
||||
</div>
|
||||
</body>
|
||||
@@ -478,7 +568,7 @@ HTML;
|
||||
<div class="stack-trace__list">
|
||||
{$renderedFrames}
|
||||
</div>
|
||||
<pre id="full-trace-text" class="stack-trace__raw" aria-label="Stack trace">{$trace}</pre>
|
||||
<pre id="full-trace-text" class="stack-trace__raw" aria-label="Stack trace" style="display:none">{$trace}</pre>
|
||||
<pre id="full-trace-plain" style="display:none">{$tracePlain}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -582,6 +672,7 @@ HTML;
|
||||
private function getShortClassName(string $fullClassName): string
|
||||
{
|
||||
$parts = explode('\\', $fullClassName);
|
||||
|
||||
return end($parts);
|
||||
}
|
||||
|
||||
@@ -590,7 +681,7 @@ HTML;
|
||||
*/
|
||||
private function getCodeSnippet(string $file, int $line): string
|
||||
{
|
||||
if (!file_exists($file)) {
|
||||
if (! file_exists($file)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -617,17 +708,15 @@ HTML;
|
||||
$callEscaped = htmlspecialchars($call, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$locationEscaped = htmlspecialchars($item->getShortFile() . ':' . $item->line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$isVendor = $item->isVendorFrame();
|
||||
$expanded = !$isVendor && $index < 3;
|
||||
$codeSnippet = '';
|
||||
|
||||
if (!$isVendor && $index < 3) {
|
||||
if (! $isVendor && $index < 3) {
|
||||
$codeSnippet = $this->getCodeSnippet($item->file, $item->line);
|
||||
}
|
||||
|
||||
$frames[] = sprintf(
|
||||
'<details class="stack-frame%s"%s><summary><span class="stack-frame__index">#%d</span> <span class="stack-frame__call">%s</span> <span class="stack-frame__location">%s</span></summary>%s</details>',
|
||||
'<details class="stack-frame%s"><summary><span class="stack-frame__index">#%d</span> <span class="stack-frame__call">%s</span> <span class="stack-frame__location">%s</span></summary>%s</details>',
|
||||
$isVendor ? ' stack-frame--vendor' : '',
|
||||
$expanded ? ' open' : '',
|
||||
$index,
|
||||
$callEscaped,
|
||||
$locationEscaped,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Reporter;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Reporter;
|
||||
|
||||
interface Reporter
|
||||
|
||||
@@ -17,6 +17,7 @@ final readonly class ReporterRegistry implements Reporter
|
||||
* @param Reporter[] $reporters Variadic list of reporter instances
|
||||
*/
|
||||
private array $reporters;
|
||||
|
||||
public function __construct(
|
||||
Reporter ...$reporters
|
||||
) {
|
||||
@@ -46,4 +47,3 @@ final readonly class ReporterRegistry implements Reporter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ final class ErrorScope
|
||||
{
|
||||
$id = $this->fiberId();
|
||||
|
||||
if (!isset($this->stack[$id])) {
|
||||
if (! isset($this->stack[$id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ final class ErrorScope
|
||||
array_pop($this->stack[$id]);
|
||||
} else {
|
||||
// Exit all scopes until token depth
|
||||
while (!empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
|
||||
while (! empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
|
||||
array_pop($this->stack[$id]);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ final class ErrorScope
|
||||
$stack = $this->stack[$id] ?? [];
|
||||
|
||||
$current = end($stack);
|
||||
|
||||
return $current !== false ? $current : null;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,8 @@ final class ErrorScope
|
||||
public function hasScope(): bool
|
||||
{
|
||||
$id = $this->fiberId();
|
||||
return !empty($this->stack[$id]);
|
||||
|
||||
return ! empty($this->stack[$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +101,7 @@ final class ErrorScope
|
||||
public function depth(): int
|
||||
{
|
||||
$id = $this->fiberId();
|
||||
|
||||
return count($this->stack[$id] ?? []);
|
||||
}
|
||||
|
||||
@@ -110,6 +113,7 @@ final class ErrorScope
|
||||
private function fiberId(): int
|
||||
{
|
||||
$fiber = Fiber::getCurrent();
|
||||
|
||||
return $fiber ? spl_object_id($fiber) : 0;
|
||||
}
|
||||
|
||||
@@ -129,7 +133,7 @@ final class ErrorScope
|
||||
return [
|
||||
'active_fibers' => count($this->stack),
|
||||
'total_scopes' => array_sum(array_map('count', $this->stack)),
|
||||
'max_depth' => !empty($this->stack) ? max(array_map('count', $this->stack)) : 0,
|
||||
'max_depth' => ! empty($this->stack) ? max(array_map('count', $this->stack)) : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ final readonly class ErrorScopeContext
|
||||
public ?string $jobId = null,
|
||||
public ?string $commandName = null,
|
||||
public array $tags = [],
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTTP scope from request
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Framework\ExceptionHandling\Serialization;
|
||||
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\Logging\ValueObjects\ExceptionContext as LoggingExceptionContext;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -36,6 +35,7 @@ final readonly class ExceptionSerializer
|
||||
public function toJson(Throwable $exception, array $options = []): string
|
||||
{
|
||||
$data = $this->toArray($exception, $options);
|
||||
|
||||
return json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
@@ -109,12 +109,12 @@ final readonly class ExceptionSerializer
|
||||
]);
|
||||
|
||||
// Add domain data
|
||||
if (!empty($context->data)) {
|
||||
if (! empty($context->data)) {
|
||||
$logData['domain_data'] = $context->data;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
if (!empty($context->metadata)) {
|
||||
if (! empty($context->metadata)) {
|
||||
$logData['metadata'] = $context->metadata;
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,7 @@ final readonly class ExceptionSerializer
|
||||
$serialized[] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
return $serialized;
|
||||
}
|
||||
|
||||
@@ -332,4 +333,3 @@ final readonly class ExceptionSerializer
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -9,13 +10,14 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorKernel $errorKernel
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$last = error_get_last();
|
||||
|
||||
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
|
||||
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +26,7 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
||||
$file = (string)($last['file'] ?? 'unknown');
|
||||
$line = (int)($last['line'] ?? 0);
|
||||
|
||||
$error = new Error($last['message'] ?? 'Fatal error',0);
|
||||
$error = new Error($last['message'] ?? 'Fatal error', 0);
|
||||
|
||||
|
||||
|
||||
@@ -49,5 +51,4 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
interface ShutdownHandlerInterface
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
@@ -11,13 +12,14 @@ final readonly class ErrorPolicyResolver
|
||||
{
|
||||
public function __construct(
|
||||
private ?Logger $logger = null
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
|
||||
{
|
||||
return match(true) {
|
||||
$environmentType->isProduction() => new StrictErrorPolicy(),
|
||||
$environmentType->isDevelopment() => $this->logger !== null
|
||||
$environmentType->isDevelopment() => $this->logger !== null
|
||||
? new LenientPolicy($this->logger)
|
||||
: new StrictErrorPolicy(),
|
||||
default => new SilentErrorPolicy(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
@@ -19,19 +20,21 @@ final readonly class LenientPolicy implements ErrorHandlerStrategy
|
||||
|
||||
public function handle(ErrorContext $context): ErrorDecision
|
||||
{
|
||||
if($context->isDeprecation()) {
|
||||
$this->logger->notice("[Deprecation] {$context->message}",
|
||||
if ($context->isDeprecation()) {
|
||||
$this->logger->notice(
|
||||
"[Deprecation] {$context->message}",
|
||||
LogContext::withData(
|
||||
[
|
||||
'file' => $context->file,
|
||||
'line' => $context->line?->toInt()
|
||||
'line' => $context->line?->toInt(),
|
||||
]
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
return ErrorDecision::HANDLED;
|
||||
}
|
||||
|
||||
if($context->isFatal()) {
|
||||
if ($context->isFatal()) {
|
||||
throw new ErrorException(
|
||||
$context->message,
|
||||
0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
|
||||
418
src/Framework/ExceptionHandling/Templates/errors/error.view.php
Normal file
418
src/Framework/ExceptionHandling/Templates/errors/error.view.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $title }}</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-bg: #f5f5f5;
|
||||
--color-surface: #ffffff;
|
||||
--color-error: #d32f2f;
|
||||
--color-text: #333333;
|
||||
--color-text-muted: #666666;
|
||||
--color-border: #e1e3e6;
|
||||
--color-warning-bg: #fff3cd;
|
||||
--color-warning-border: #ffc107;
|
||||
--color-debug-bg: #e8f5e9;
|
||||
--color-debug-text: #1b5e20;
|
||||
--color-prod-bg: #fff3cd;
|
||||
--color-prod-text: #8a6d3b;
|
||||
--color-code-bg: #f8f9fa;
|
||||
--color-vendor-bg: #fafbfc;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--font-mono: 'Courier New', monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.badge--debug {
|
||||
background: var(--color-debug-bg);
|
||||
color: var(--color-debug-text);
|
||||
}
|
||||
|
||||
.badge--prod {
|
||||
background: var(--color-prod-bg);
|
||||
color: var(--color-prod-text);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--color-warning-bg);
|
||||
border-left: 4px solid var(--color-warning-border);
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 1.25rem 0;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: #f9fafb;
|
||||
color: #555;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notice--warn {
|
||||
border-color: #ffe599;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Debug Section Styles */
|
||||
.debug-section {
|
||||
background: var(--color-code-bg);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.debug-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.context-item {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.context-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.context-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Code Snippet Styles */
|
||||
.code-context {
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.code-context h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Stack Trace Styles */
|
||||
.stack-trace {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.stack-trace__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.stack-trace__header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.stack-trace__list {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stack-frame {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stack-frame:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stack-frame summary {
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stack-frame summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack-frame summary::before {
|
||||
content: '\25B6';
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.stack-frame[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.stack-frame__index {
|
||||
min-width: 28px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stack-frame__call {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stack-frame__location {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stack-frame__code {
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.stack-frame--vendor {
|
||||
background: var(--color-vendor-bg);
|
||||
}
|
||||
|
||||
.stack-frame--vendor summary {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.stack-trace__raw {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border: 1px solid #d0d4da;
|
||||
background: var(--color-surface);
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.btn--copied {
|
||||
background: var(--color-debug-bg);
|
||||
border-color: var(--color-debug-text);
|
||||
color: var(--color-debug-text);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.error-footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-header">
|
||||
<h1 class="error-title">{{ $title }}</h1>
|
||||
<span class="badge badge--{{ $isDebugMode ? 'debug' : 'prod' }}">{{ $isDebugMode ? 'DEBUG' : 'PROD' }}</span>
|
||||
</div>
|
||||
|
||||
<div if="{{ $templateError ?? false }}" class="notice notice--warn">
|
||||
Template fallback: {{ $templateError }}
|
||||
</div>
|
||||
|
||||
<div class="error-message">
|
||||
<p>{{ $message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value">{{ $statusCode }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Exception</span>
|
||||
<span class="meta-value">{{ $exceptionClass }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Request</span>
|
||||
<span class="meta-value">{{ $requestMethod }} {{ $requestUri }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div if="{{ $isDebugMode }}" class="debug-section">
|
||||
<h3>Debug Information</h3>
|
||||
|
||||
<div class="context-item">
|
||||
<span class="context-label">Exception:</span>
|
||||
<span class="context-value">{{ $exceptionClass }}</span>
|
||||
</div>
|
||||
<div class="context-item">
|
||||
<span class="context-label">File:</span>
|
||||
<span class="context-value">{{ $debug['file'] ?? '' }}:{{ $debug['line'] ?? '' }}</span>
|
||||
</div>
|
||||
<div class="context-item">
|
||||
<span class="context-label">Short trace:</span>
|
||||
<span class="context-value">{{ $debug['trace_short'] ?? '' }}</span>
|
||||
</div>
|
||||
|
||||
<div if="{{ isset($context) }}" class="context-details">
|
||||
<div if="{{ $context['operation'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Operation:</span>
|
||||
<span class="context-value">{{ $context['operation'] }}</span>
|
||||
</div>
|
||||
<div if="{{ $context['component'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Component:</span>
|
||||
<span class="context-value">{{ $context['component'] }}</span>
|
||||
</div>
|
||||
<div if="{{ $context['request_id'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Request ID:</span>
|
||||
<span class="context-value">{{ $context['request_id'] }}</span>
|
||||
</div>
|
||||
<div if="{{ $context['occurred_at'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Occurred At:</span>
|
||||
<span class="context-value">{{ $context['occurred_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div if="{{ $codeSnippet ?? '' }}" class="code-context">
|
||||
{!! $codeSnippet !!}
|
||||
</div>
|
||||
|
||||
<div if="{{ isset($debug['trace_frames']) }}" class="stack-trace">
|
||||
<div class="stack-trace__header">
|
||||
<h4>Stack Trace</h4>
|
||||
<button type="button" class="btn" id="btn-copy-trace">Copy stack</button>
|
||||
</div>
|
||||
<div class="stack-trace__list">
|
||||
{!! $stackTraceHtml !!}
|
||||
</div>
|
||||
<pre id="full-trace-text" class="stack-trace__raw">{{ $debug['trace'] ?? '' }}</pre>
|
||||
<pre id="full-trace-plain" class="stack-trace__raw">{{ $debug['trace_plain'] ?? '' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-footer">
|
||||
Custom PHP Framework
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script if="{{ $isDebugMode }}">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const btn = document.getElementById('btn-copy-trace');
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
const raw = document.getElementById('full-trace-plain');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw.textContent || '');
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('btn--copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copy stack';
|
||||
btn.classList.remove('btn--copied');
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
btn.textContent = 'Copy failed';
|
||||
setTimeout(() => btn.textContent = 'Copy stack', 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -52,6 +52,7 @@ final readonly class ExceptionMessageTranslator
|
||||
// Process template
|
||||
if (is_string($template)) {
|
||||
$message = $this->processTemplate($template, $exception, $context);
|
||||
|
||||
return UserFriendlyMessage::simple($message);
|
||||
}
|
||||
|
||||
@@ -97,4 +98,3 @@ final readonly class ExceptionMessageTranslator
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,3 @@ final readonly class UserFriendlyMessage
|
||||
return new self($message, helpText: $helpText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ final readonly class StackItem
|
||||
private static function sanitizeArgs(array $args): array
|
||||
{
|
||||
return array_map(
|
||||
fn($arg) => self::sanitizeValue($arg),
|
||||
fn ($arg) => self::sanitizeValue($arg),
|
||||
$args
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ final readonly class StackItem
|
||||
$reflection = new \ReflectionFunction($value);
|
||||
$file = $reflection->getFileName();
|
||||
$line = $reflection->getStartLine();
|
||||
|
||||
return sprintf('Closure(%s:%d)', basename($file), $line);
|
||||
} catch (\Throwable) {
|
||||
return 'Closure';
|
||||
@@ -102,6 +103,7 @@ final readonly class StackItem
|
||||
if ($parentClass !== false) {
|
||||
return sprintf('Anonymous(%s)', $parentClass);
|
||||
}
|
||||
|
||||
return 'Anonymous';
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ final readonly class StackItem
|
||||
// Arrays rekursiv bereinigen
|
||||
if (is_array($value)) {
|
||||
return array_map(
|
||||
fn($item) => self::sanitizeValue($item),
|
||||
fn ($item) => self::sanitizeValue($item),
|
||||
$value
|
||||
);
|
||||
}
|
||||
@@ -145,15 +147,17 @@ final readonly class StackItem
|
||||
// Entferne Namespace: Spalte am Backslash und nimm den letzten Teil
|
||||
$parts = explode('\\', $interfaceName);
|
||||
$shortName = end($parts);
|
||||
|
||||
return $shortName . ' (anonymous)';
|
||||
}
|
||||
|
||||
return 'Anonymous';
|
||||
}
|
||||
|
||||
// Spalte am Backslash und nimm den letzten Teil (Klassenname ohne Namespace)
|
||||
$parts = explode('\\', $normalizedClass);
|
||||
$shortName = end($parts);
|
||||
|
||||
|
||||
// Sicherstellen, dass wir wirklich nur den letzten Teil zurückgeben
|
||||
// (falls explode() nicht funktioniert hat, z.B. bei Forward-Slashes)
|
||||
if ($shortName === $normalizedClass && str_contains($normalizedClass, '/')) {
|
||||
@@ -161,7 +165,7 @@ final readonly class StackItem
|
||||
$parts = explode('/', $normalizedClass);
|
||||
$shortName = end($parts);
|
||||
}
|
||||
|
||||
|
||||
return $shortName;
|
||||
}
|
||||
|
||||
@@ -226,6 +230,7 @@ final readonly class StackItem
|
||||
foreach ($this->args as $index => $arg) {
|
||||
if ($index >= $maxArgs) {
|
||||
$formatted[] = '…';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -235,6 +240,7 @@ final readonly class StackItem
|
||||
|
||||
if ($length > $maxTotalLength) {
|
||||
$formatted[] = '…';
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -271,6 +277,7 @@ final readonly class StackItem
|
||||
if (class_exists($normalizedValue) || interface_exists($normalizedValue) || enum_exists($normalizedValue)) {
|
||||
$parts = explode('\\', $normalizedValue);
|
||||
$shortName = end($parts);
|
||||
|
||||
return sprintf("'%s'", $shortName);
|
||||
}
|
||||
|
||||
@@ -282,6 +289,7 @@ final readonly class StackItem
|
||||
// Nur wenn es mehrere Teile gibt (Namespace vorhanden)
|
||||
if (count($parts) > 1) {
|
||||
$shortName = end($parts);
|
||||
|
||||
return sprintf("'%s'", $shortName);
|
||||
}
|
||||
}
|
||||
@@ -290,6 +298,7 @@ final readonly class StackItem
|
||||
if (preg_match('/^Closure\(([^:]+):(\d+)\)$/', $value, $matches)) {
|
||||
$file = basename($matches[1]);
|
||||
$line = $matches[2];
|
||||
|
||||
return sprintf("Closure(%s:%s)", $file, $line);
|
||||
}
|
||||
|
||||
@@ -319,8 +328,10 @@ final readonly class StackItem
|
||||
$interfaceName = $matches[1];
|
||||
$interfaceParts = explode('\\', $interfaceName);
|
||||
$shortInterface = end($interfaceParts);
|
||||
|
||||
return $shortInterface . ' (anonymous)';
|
||||
}
|
||||
|
||||
return 'Anonymous';
|
||||
}
|
||||
|
||||
@@ -341,25 +352,26 @@ final readonly class StackItem
|
||||
$closureInfo = $matches[1];
|
||||
// Normalisiere Forward-Slashes zu Backslashes
|
||||
$closureInfo = str_replace('/', '\\', $closureInfo);
|
||||
|
||||
|
||||
// Parse: App\Framework\Router\RouteDispatcher::executeController():77
|
||||
if (preg_match('/^([^:]+)::([^(]+)\(\):(\d+)$/', $closureInfo, $closureMatches)) {
|
||||
$fullClass = $closureMatches[1];
|
||||
$method = $closureMatches[2];
|
||||
$line = $closureMatches[3];
|
||||
|
||||
|
||||
// Entferne Namespace
|
||||
$classParts = explode('\\', $fullClass);
|
||||
$shortClass = end($classParts);
|
||||
|
||||
|
||||
return sprintf('{closure:%s::%s():%s}', $shortClass, $method, $line);
|
||||
}
|
||||
// Fallback: einfach Namespaces entfernen
|
||||
$closureInfo = preg_replace_callback(
|
||||
'/([A-Z][a-zA-Z0-9_\\\\]*)/',
|
||||
fn($m) => $this->removeNamespaceFromClass($m[0]),
|
||||
fn ($m) => $this->removeNamespaceFromClass($m[0]),
|
||||
$closureInfo
|
||||
);
|
||||
|
||||
return sprintf('{closure:%s}', $closureInfo);
|
||||
}
|
||||
|
||||
@@ -374,6 +386,7 @@ final readonly class StackItem
|
||||
// Normalisiere Forward-Slashes zu Backslashes
|
||||
$normalized = str_replace('/', '\\', $classString);
|
||||
$parts = explode('\\', $normalized);
|
||||
|
||||
return end($parts);
|
||||
}
|
||||
|
||||
@@ -403,6 +416,7 @@ final readonly class StackItem
|
||||
$methodName = $this->formatFunctionName($this->function);
|
||||
$separator = $this->type === '::' ? '::' : '->';
|
||||
$paramsStr = $params !== '' ? $params : '';
|
||||
|
||||
return sprintf('%s%s%s(%s) in %s', $className, $separator, $methodName, $paramsStr, $location);
|
||||
}
|
||||
|
||||
@@ -410,6 +424,7 @@ final readonly class StackItem
|
||||
if ($this->function !== null) {
|
||||
$methodName = $this->formatFunctionName($this->function);
|
||||
$paramsStr = $params !== '' ? $params : '';
|
||||
|
||||
return sprintf('%s(%s) in %s', $methodName, $paramsStr, $location);
|
||||
}
|
||||
|
||||
@@ -443,7 +458,7 @@ final readonly class StackItem
|
||||
$data['type'] = $this->type;
|
||||
}
|
||||
|
||||
if (!empty($this->args)) {
|
||||
if (! empty($this->args)) {
|
||||
$data['args'] = $this->serializeArgs();
|
||||
}
|
||||
|
||||
@@ -458,7 +473,7 @@ final readonly class StackItem
|
||||
private function serializeArgs(): array
|
||||
{
|
||||
return array_map(
|
||||
fn($arg) => $this->formatValueForOutput($arg),
|
||||
fn ($arg) => $this->formatValueForOutput($arg),
|
||||
$this->args
|
||||
);
|
||||
}
|
||||
@@ -484,6 +499,7 @@ final readonly class StackItem
|
||||
}
|
||||
|
||||
$projectRoot = dirname(__DIR__, 4);
|
||||
|
||||
return $projectRoot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\ValueObjects;
|
||||
|
||||
use IteratorAggregate;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -48,7 +48,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable
|
||||
array_values(
|
||||
array_filter(
|
||||
$this->items,
|
||||
fn(StackItem $item) => !$item->isVendorFrame()
|
||||
fn (StackItem $item) => ! $item->isVendorFrame()
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -104,7 +104,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable
|
||||
{
|
||||
$frames = array_slice($this->items, 0, $maxFrames);
|
||||
$formatted = array_map(
|
||||
fn(StackItem $item) => $item->formatShort(),
|
||||
fn (StackItem $item) => $item->formatShort(),
|
||||
$frames
|
||||
);
|
||||
|
||||
@@ -123,7 +123,7 @@ final readonly class StackTrace implements IteratorAggregate, Countable
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_map(
|
||||
fn(StackItem $item) => $item->toArray(),
|
||||
fn (StackItem $item) => $item->toArray(),
|
||||
$this->items
|
||||
);
|
||||
}
|
||||
@@ -148,4 +148,3 @@ final readonly class StackTrace implements IteratorAggregate, Countable
|
||||
return count($this->items);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user