feat: implement exception handling system with error context and policies

This commit is contained in:
2025-11-01 15:46:43 +01:00
parent f3440dff0d
commit a441da37f6
35 changed files with 920 additions and 88 deletions

33
AGENTS.md Normal file
View File

@@ -0,0 +1,33 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/` layers PHP code (`Application`, `Domain`, `Framework`, `Infrastructure`); create new classes under the matching namespace and keep adapters in Infrastructure.
- `resources/` stores Vite-managed frontend code (`resources/js`, `resources/css`), while build artifacts belong in `public/assets` under the `public/` web root.
- `tests/` mirrors the PHP namespaces and houses Playwright suites in `tests/e2e`; share fixtures and stubs through `tests/Support`.
- Operational docs and tooling sit in `docs/`, `docker/`, and `deployment/`; update those paths whenever runtime behaviour changes.
## Build, Test & Development Commands
- `make up` / `make down` manage the Docker stack; add `make logs` when you need container output.
- `npm run dev` starts Vite locally, and `npm run build` produces optimized bundles into `public/assets`.
- `make test` drives the full Pest suite; use `make test-unit`, `make test-domain`, or `make test-coverage` for focused runs.
- JavaScript and browser checks run via `npm run test`, `npm run test:e2e`, and `npm run test:coverage`.
- Quality gates: `make phpstan`, `make cs` (dry run), `make cs-fix` (auto-fix), plus `npm run lint:js` and `npm run format`.
## Coding Style & Naming Conventions
- Follow PSR-12 with strict types; prefer constructor injection and suffix integrations with their role (`MailerAdapter`, `RedisCache`).
- Keep PHP objects immutable unless Domain logic requires mutation; colocate interfaces with their consuming layer.
- Use 4-space indents in PHP and Prettier defaults for TypeScript; name frontend components in PascalCase and utilities in camelCase.
## Testing Guidelines
- Add Pest specs beside the feature namespace with expressive names (`it_handles_invalid_tokens`); keep builders in `tests/Support`.
- Mock external services through `tests/__mocks__`; rely on Pest datasets for edge cases.
- Sync Playwright specs with UX changes, reuse `tests/e2e/fixtures`, and review reports via `playwright show-report`.
## Commit & Pull Request Guidelines
- Use Conventional Commits (`fix:`, `feat:`, optional scope) to match history.
- PRs must outline the change, list executed checks, and link issues; attach screenshots for UI work or config diffs for ops updates.
- Confirm CI covers Pest, Jest, Playwright, PHPStan, and php-cs-fixer before requesting review.
## Security & Configuration Tips
- Never commit `.env` or secrets; follow `ENV_SETUP.md` and store deployment credentials in Vault.
- Run `make security-check` ahead of releases and reflect infrastructure changes in `docs/deployment/`.

View File

@@ -20,6 +20,8 @@ use App\Framework\DI\DefaultContainer;
use App\Framework\Encryption\EncryptionFactory; use App\Framework\Encryption\EncryptionFactory;
use App\Framework\ErrorHandling\CliErrorHandler; use App\Framework\ErrorHandling\CliErrorHandler;
use App\Framework\ErrorHandling\ErrorHandler; use App\Framework\ErrorHandling\ErrorHandler;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ExceptionHandling\ExceptionHandlerManager;
use App\Framework\Http\MiddlewareManager; use App\Framework\Http\MiddlewareManager;
use App\Framework\Http\MiddlewareManagerInterface; use App\Framework\Http\MiddlewareManagerInterface;
use App\Framework\Http\ResponseEmitter; use App\Framework\Http\ResponseEmitter;
@@ -71,7 +73,7 @@ final readonly class AppBootstrapper
//if ($envType->isDevelopment()) { //if ($envType->isDevelopment()) {
if($typedConfig->app->type->isDevelopment()) { /*if($typedConfig->app->type->isDevelopment()) {
// Fehleranzeige für die Entwicklung aktivieren // Fehleranzeige für die Entwicklung aktivieren
ini_set('display_errors', 1); ini_set('display_errors', 1);
ini_set('display_startup_errors', 1); ini_set('display_startup_errors', 1);
@@ -87,7 +89,7 @@ final readonly class AppBootstrapper
return false; return false;
}, true, true); }, true, true);
} }*/
} }
public function bootstrapWeb(): ApplicationInterface public function bootstrapWeb(): ApplicationInterface
@@ -157,7 +159,11 @@ final readonly class AppBootstrapper
private function registerWebErrorHandler(): void private function registerWebErrorHandler(): void
{ {
$this->container->get(ErrorHandler::class)->register(); new ExceptionHandlerManager();
#var_dump('registerWebErrorHandler');
#$eh = $this->container->get(ErrorHandlerManager::class);
#$eh->register();
//$this->container->get(ErrorHandler::class)->register();
} }
private function registerCliErrorHandler(): void private function registerCliErrorHandler(): void

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\DI; namespace App\Framework\DI;
use App\Framework\Core\ValueObjects\ClassName; use App\Framework\Core\ValueObjects\ClassName;
use Stringable;
interface Container interface Container
{ {
@@ -17,17 +18,26 @@ interface Container
* @param class-string<T>|ClassName $class * @param class-string<T>|ClassName $class
* @return T * @return T
*/ */
public function get(string|ClassName $class): object; public function get(ClassName|Stringable|string $class): object;
/** @param class-string $class */ /** @param class-string $class */
public function has(string $class): bool; public function has(ClassName|Stringable|string $class): bool;
public function bind(string $abstract, callable|string|object $concrete): void; public function bind(
ClassName|Stringable|string $abstract,
callable|string|object $concrete
): void;
public function singleton(string $abstract, callable|string|object $concrete): void; public function singleton(
ClassName|Stringable|string $abstract,
callable|string|object $concrete
): void;
public function instance(string $abstract, object $instance): void; public function instance(
ClassName|Stringable|string $abstract,
object $instance
): void;
/** @param class-string $class */ /** @param class-string $class */
public function forget(string $class): void; public function forget(ClassName|Stringable|string $class): void;
} }

View File

@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Framework\DI; namespace App\Framework\DI;
use App\Framework\Core\ValueObjects\ClassName; use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\Exceptions\ClassNotInstantiable;
use App\Framework\DI\Exceptions\ClassNotResolvableException;
use App\Framework\DI\Exceptions\ClassResolutionException;
use App\Framework\DI\Exceptions\ContainerException;
use App\Framework\DI\Exceptions\CyclicDependencyException; use App\Framework\DI\Exceptions\CyclicDependencyException;
use App\Framework\DI\Exceptions\LazyLoadingException; use App\Framework\DI\Exceptions\LazyLoadingException;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Metrics\FrameworkMetricsCollector; use App\Framework\Metrics\FrameworkMetricsCollector;
use App\Framework\Reflection\CachedReflectionProvider; use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\Reflection\ReflectionProvider; use App\Framework\Reflection\ReflectionProvider;
use Stringable;
use Throwable; use Throwable;
final class DefaultContainer implements Container final class DefaultContainer implements Container
@@ -54,8 +60,9 @@ final class DefaultContainer implements Container
$this->instance(ReflectionProvider::class, $this->reflectionProvider); $this->instance(ReflectionProvider::class, $this->reflectionProvider);
} }
public function bind(string $abstract, callable|string|object $concrete): void public function bind(ClassName|Stringable|string $abstract, callable|string|object $concrete): void
{ {
$abstract = (string) $abstract;
$this->bindings->bind($abstract, $concrete); $this->bindings->bind($abstract, $concrete);
// Only clear caches for valid class names, skip string keys like 'filesystem.storage.local' // Only clear caches for valid class names, skip string keys like 'filesystem.storage.local'
@@ -64,23 +71,26 @@ final class DefaultContainer implements Container
} }
} }
public function singleton(string $abstract, callable|string|object $concrete): void public function singleton(ClassName|Stringable|string $abstract, callable|string|object $concrete): void
{ {
$abstract = (string) $abstract;
$this->bind($abstract, $concrete); $this->bind($abstract, $concrete);
$this->instances->markAsSingleton($abstract); $this->instances->markAsSingleton($abstract);
} }
public function instance(string $abstract, object $instance): void public function instance(ClassName|Stringable|string $abstract, object $instance): void
{ {
$this->instances->setInstance($abstract, $instance); $this->instances->setInstance($abstract, $instance);
} }
/** /**
* @inheritDoc * @template T of object
* @param class-string<T>|ClassName|Stringable $class
* @return T
*/ */
public function get(string|ClassName $class): object public function get(ClassName|Stringable|string $class): object
{ {
$className = $class instanceof ClassName ? $class->toString() : $class; $className = (string) $class;
// Bereits instanziierte Objekte zurückgeben // Bereits instanziierte Objekte zurückgeben
if ($this->instances->hasSingleton($className)) { if ($this->instances->hasSingleton($className)) {
@@ -154,9 +164,9 @@ final class DefaultContainer implements Container
// For string keys without bindings, throw immediately // For string keys without bindings, throw immediately
if (!class_exists($class) && !interface_exists($class)) { if (!class_exists($class) && !interface_exists($class)) {
throw new \RuntimeException( throw new ClassNotResolvableException(
"Cannot resolve '{$class}': not a valid class and no binding exists. " . class: $class,
"Available bindings: " . implode(', ', array_keys($this->bindings->getAllBindings())) availableBindings: array_keys($this->bindings->getAllBindings())
); );
} }
@@ -174,27 +184,16 @@ final class DefaultContainer implements Container
$dependencies = $this->dependencyResolver->resolveDependencies($className); $dependencies = $this->dependencyResolver->resolveDependencies($className);
return $reflection->newInstance(...$dependencies->toArray()); return $reflection->newInstance(...$dependencies->toArray());
} catch (\RuntimeException $e) { } catch (ContainerException $e) {
// If it's already our detailed exception, just re-throw // If it's already a ContainerException, just re-throw
if (str_contains($e->getMessage(), 'Dependency resolution chain:')) {
throw $e; throw $e;
} } catch (\RuntimeException|\ReflectionException $e) {
// Wrap with binding information
// Otherwise, wrap with binding information throw ClassResolutionException::fromRuntimeException(
throw new \RuntimeException( class: $class,
"Cannot resolve class '{$class}': {$e->getMessage()}. " . exception: $e,
"Available bindings: " . implode(', ', array_keys($this->bindings->getAllBindings())) . availableBindings: array_keys($this->bindings->getAllBindings()),
". Dependency chain: " . implode(' -> ', $this->resolving), dependencyChain: $this->resolving
0,
$e
);
} catch (\ReflectionException $e) {
throw new \RuntimeException(
"Cannot resolve class '{$class}': {$e->getMessage()}. " .
"Available bindings: " . implode(', ', array_keys($this->bindings->getAllBindings())) .
". Dependency chain: " . implode(' -> ', $this->resolving),
0,
$e
); );
} }
} }
@@ -202,38 +201,67 @@ final class DefaultContainer implements Container
private function throwDetailedBindingException(string $class/*, $reflection*/): never private function throwDetailedBindingException(string $class/*, $reflection*/): never
{ {
$availableBindings = array_keys($this->bindings->getAllBindings()); $availableBindings = array_keys($this->bindings->getAllBindings());
$dependencyChain = implode(' -> ', $this->resolving);
// Look for similar interface bindings // Try to get DiscoveryRegistry from container and include discovered initializers
$similarBindings = array_filter($availableBindings, function ($binding) use ($class) { $discoveredInitializers = [];
return str_contains($binding, basename(str_replace('\\', '/', $class))); if ($this->has(DiscoveryRegistry::class)) {
}); try {
$discoveryRegistry = $this->get(DiscoveryRegistry::class);
$initializerResults = $discoveryRegistry->attributes->get(Initializer::class);
$message = "Cannot instantiate class '{$class}': class is not instantiable (interface, abstract class, or trait).\n" . if (! empty($initializerResults)) {
"Dependency resolution chain: {$dependencyChain}\n" . $discoveredInitializers = array_map(
"Total available bindings: " . count($availableBindings) . "\n"; fn($attr) => $attr->className->getFullyQualified(),
$initializerResults
if (! empty($similarBindings)) { );
$message .= "Similar bindings found: " . implode(', ', $similarBindings) . "\n"; }
} catch (\Throwable $e) {
// Silently ignore errors when trying to access DiscoveryRegistry to avoid masking the original error
}
} }
$message .= "All bindings: " . implode(', ', $availableBindings); throw ClassNotInstantiable::fromContainerContext(
class: $class,
throw new \RuntimeException($message); dependencyChain: $this->resolving,
availableBindings: $availableBindings,
discoveredInitializers: $discoveredInitializers
);
} }
private function resolveBinding(string $class, callable|string|object $concrete): object private function resolveBinding(string $class, callable|string|object $concrete): object
{ {
try {
return match (true) { return match (true) {
is_callable($concrete) => $concrete($this), is_callable($concrete) => $concrete($this),
is_string($concrete) => $this->get($concrete), is_string($concrete) => $this->get($concrete),
/* @var object $concrete */
default => $concrete default => $concrete
}; };
} catch (ContainerException $e) {
// Re-throw ContainerExceptions as-is (they already have proper context)
throw $e;
} catch (\Throwable $e) {
// Determine binding type for better error messages
$bindingType = match (true) {
is_callable($concrete) => 'callable',
is_string($concrete) => 'string',
default => 'object'
};
throw ClassResolutionException::fromBindingResolution(
class: $class,
previous: $e,
availableBindings: array_keys($this->bindings->getAllBindings()),
dependencyChain: $this->resolving,
bindingType: $bindingType
);
}
} }
/** @param class-string $class */ /** @param class-string $class */
public function has(string $class): bool public function has(ClassName|Stringable|string $class): bool
{ {
$class = (string) $class;
return $this->instances->hasSingleton($class) return $this->instances->hasSingleton($class)
|| $this->instances->hasInstance($class) || $this->instances->hasInstance($class)
|| $this->bindings->hasBinding($class) || $this->bindings->hasBinding($class)
@@ -241,8 +269,9 @@ final class DefaultContainer implements Container
} }
/** @param class-string $class */ /** @param class-string $class */
public function forget(string $class): void public function forget(ClassName|Stringable|string $class): void
{ {
$class = (string) $class;
$this->instances->forget($class); $this->instances->forget($class);
$this->bindings->forget($class); $this->bindings->forget($class);
$this->clearCaches(ClassName::create($class)); $this->clearCaches(ClassName::create($class));

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI\Exceptions;
use App\Framework\Exception\ExceptionContext;
/**
* Thrown when a class cannot be instantiated because it's an interface, abstract class, or trait
*/
final class ClassNotInstantiable extends ContainerException
{
/**
* Factory method to create exception from container context
* Automatically calculates similar bindings from available bindings
*
* @param class-string $class
* @param string[] $dependencyChain
* @param string[] $availableBindings
* @param string[] $discoveredInitializers
*/
public static function fromContainerContext(
string $class,
array $dependencyChain,
array $availableBindings,
array $discoveredInitializers = []
): self {
// Calculate similar bindings based on class name
$similarBindings = array_filter($availableBindings, function ($binding) use ($class) {
return str_contains($binding, basename(str_replace('\\', '/', $class)));
});
return new self(
class: $class,
dependencyChain: $dependencyChain,
availableBindings: $availableBindings,
similarBindings: $similarBindings,
discoveredInitializers: $discoveredInitializers
);
}
/**
* @param class-string $class
* @param string[] $dependencyChain
* @param string[] $availableBindings
* @param string[] $similarBindings
* @param string[] $discoveredInitializers
*/
public function __construct(
public readonly string $class,
public readonly array $dependencyChain,
public readonly array $availableBindings,
public readonly array $similarBindings = [],
public readonly array $discoveredInitializers = [],
)
{
$dependencyChainStr = implode(' -> ', $this->dependencyChain);
$message = "Cannot instantiate class '{$this->class}': class is not instantiable (interface, abstract class, or trait).\n" .
"Dependency resolution chain: {$dependencyChainStr}\n" .
'Total available bindings: ' . count($this->availableBindings) . "\n";
if (!empty($this->similarBindings)) {
$message .= 'Similar bindings found: ' . implode(', ', $this->similarBindings) . "\n";
}
if (!empty($this->discoveredInitializers)) {
$message .= "Discovered initializers (" . count($this->discoveredInitializers) . "): " .
implode(', ', $this->discoveredInitializers) . "\n";
} else {
$message .= "No initializers discovered in DiscoveryRegistry.\n";
}
$context = ExceptionContext::forOperation('class_instantiation', 'DI Container')
->withData([
'class' => $this->class,
'dependencyChain' => $this->dependencyChain,
'availableBindings' => $this->availableBindings,
'similarBindings' => $this->similarBindings,
'discoveredInitializers' => $this->discoveredInitializers,
'bindingCount' => count($this->availableBindings),
'initializerCount' => count($this->discoveredInitializers),
]);
parent::__construct(
message: $message,
context: $context
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI\Exceptions;
use App\Framework\Exception\ExceptionContext;
/**
* Thrown when a class cannot be resolved because it's not a valid class/interface and no binding exists
*/
final class ClassNotResolvableException extends ContainerException
{
/**
* @param string[] $availableBindings
*/
public function __construct(
string $class,
array $availableBindings,
int $code = 0,
?\Throwable $previous = null
) {
$message = "Cannot resolve '{$class}': not a valid class and no binding exists. " .
"Available bindings: " . implode(', ', $availableBindings);
$context = ExceptionContext::forOperation('class_resolution', 'DI Container')
->withData([
'class' => $class,
'availableBindings' => $availableBindings,
'bindingCount' => count($availableBindings),
]);
parent::__construct(
message: $message,
context: $context,
code: $code,
previous: $previous
);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI\Exceptions;
use App\Framework\Exception\ExceptionContext;
/**
* Thrown when a class cannot be resolved due to reflection or other errors
*/
final class ClassResolutionException extends ContainerException
{
/**
* Factory method to create exception from binding resolution failure
*
* @param class-string $class
* @param \Throwable $previous
* @param string[] $availableBindings
* @param string[] $dependencyChain
* @param string $bindingType One of 'callable', 'string', or 'object'
*/
public static function fromBindingResolution(
string $class,
\Throwable $previous,
array $availableBindings,
array $dependencyChain,
string $bindingType
): self {
$reason = "Binding resolution failed (binding type: {$bindingType}): " . $previous->getMessage();
return new self(
class: $class,
reason: $reason,
availableBindings: $availableBindings,
dependencyChain: $dependencyChain,
previous: $previous,
bindingType: $bindingType
);
}
/**
* Factory method to create exception from RuntimeException or ReflectionException
*
* @param class-string $class
* @param \RuntimeException|\ReflectionException $exception
* @param string[] $availableBindings
* @param string[] $dependencyChain
*/
public static function fromRuntimeException(
string $class,
\RuntimeException|\ReflectionException $exception,
array $availableBindings,
array $dependencyChain
): self {
return new self(
class: $class,
reason: $exception->getMessage(),
availableBindings: $availableBindings,
dependencyChain: $dependencyChain,
previous: $exception
);
}
/**
* @param string[] $dependencyChain
* @param string[] $availableBindings
*/
public function __construct(
string $class,
string $reason,
array $availableBindings,
array $dependencyChain,
int $code = 0,
?\Throwable $previous = null,
?string $bindingType = null
) {
$message = "Cannot resolve class '{$class}': {$reason}. " .
"Available bindings: " . implode(', ', $availableBindings) .
". Dependency chain: " . implode(' -> ', $dependencyChain);
$contextData = [
'class' => $class,
'reason' => $reason,
'availableBindings' => $availableBindings,
'dependencyChain' => $dependencyChain,
'bindingCount' => count($availableBindings),
];
if ($bindingType !== null) {
$contextData['bindingType'] = $bindingType;
}
$context = ExceptionContext::forOperation('class_resolution', 'DI Container')
->withData($contextData);
parent::__construct(
message: $message,
context: $context,
code: $code,
previous: $previous
);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI\Exceptions;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
class ContainerException extends FrameworkException
{
/**
* Simplified constructor for simple error messages
* Automatically creates an ExceptionContext for DI operations
*/
public function __construct(
string $message = '',
?ExceptionContext $context = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct(
message: $message,
context: $context ?? ExceptionContext::forOperation('dependency_injection', 'DI Container'),
code: $code,
previous: $previous
);
}
}

View File

@@ -5,13 +5,11 @@ declare(strict_types=1);
namespace App\Framework\DI\Exceptions; namespace App\Framework\DI\Exceptions;
use App\Framework\Exception\ExceptionContext; use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
use RuntimeException;
/** /**
* Exception thrown when DefaultImplementation attribute validation fails * Exception thrown when DefaultImplementation attribute validation fails
*/ */
final class DefaultImplementationException extends RuntimeException final class DefaultImplementationException extends ContainerException
{ {
/** /**
* Thrown when a class with DefaultImplementation attribute does not implement the specified interface * Thrown when a class with DefaultImplementation attribute does not implement the specified interface
@@ -21,13 +19,20 @@ final class DefaultImplementationException extends RuntimeException
*/ */
public static function doesNotImplementInterface(string $className, string $interface): self public static function doesNotImplementInterface(string $className, string $interface): self
{ {
return new self( $message = sprintf(
sprintf(
'Class "%s" has #[DefaultImplementation] for interface "%s" but does not implement that interface', 'Class "%s" has #[DefaultImplementation] for interface "%s" but does not implement that interface',
$className, $className,
$interface $interface
)
); );
$context = ExceptionContext::forOperation('default_implementation_validation', 'DI Container')
->withData([
'className' => $className,
'interface' => $interface,
'errorType' => 'does_not_implement_interface',
]);
return new self($message, $context);
} }
/** /**
@@ -37,13 +42,19 @@ final class DefaultImplementationException extends RuntimeException
*/ */
public static function noInterfacesImplemented(string $className): self public static function noInterfacesImplemented(string $className): self
{ {
return new self( $message = sprintf(
sprintf(
'Class "%s" has #[DefaultImplementation] without explicit interface but implements no interfaces. ' . 'Class "%s" has #[DefaultImplementation] without explicit interface but implements no interfaces. ' .
'Either specify an interface explicitly or ensure the class implements at least one interface.', 'Either specify an interface explicitly or ensure the class implements at least one interface.',
$className $className
)
); );
$context = ExceptionContext::forOperation('default_implementation_validation', 'DI Container')
->withData([
'className' => $className,
'errorType' => 'no_interfaces_implemented',
]);
return new self($message, $context);
} }
/** /**
@@ -54,12 +65,19 @@ final class DefaultImplementationException extends RuntimeException
*/ */
public static function interfaceDoesNotExist(string $className, string $interface): self public static function interfaceDoesNotExist(string $className, string $interface): self
{ {
return new self( $message = sprintf(
sprintf(
'Class "%s" has #[DefaultImplementation] for interface "%s" which does not exist', 'Class "%s" has #[DefaultImplementation] for interface "%s" which does not exist',
$className, $className,
$interface $interface
)
); );
$context = ExceptionContext::forOperation('default_implementation_validation', 'DI Container')
->withData([
'className' => $className,
'interface' => $interface,
'errorType' => 'interface_does_not_exist',
]);
return new self($message, $context);
} }
} }

View File

@@ -6,7 +6,7 @@ namespace App\Framework\DI;
use App\Framework\Context\ContextType; use App\Framework\Context\ContextType;
#[\Attribute(\Attribute::TARGET_METHOD)] #[\Attribute(\Attribute::TARGET_METHOD|\Attribute::IS_REPEATABLE)]
final readonly class Initializer final readonly class Initializer
{ {
/** @var ContextType[]|null */ /** @var ContextType[]|null */

View File

@@ -29,8 +29,7 @@ final readonly class InitializerProcessor
private Container $container, private Container $container,
private ReflectionProvider $reflectionProvider, private ReflectionProvider $reflectionProvider,
private ExecutionContext $executionContext, private ExecutionContext $executionContext,
) { ) {}
}
/** /**
* Intelligente Initializer-Verarbeitung mit Dependency-Graph: * Intelligente Initializer-Verarbeitung mit Dependency-Graph:
@@ -41,7 +40,7 @@ final readonly class InitializerProcessor
public function processInitializers(DiscoveryRegistry $results): void public function processInitializers(DiscoveryRegistry $results): void
{ {
// Safe Logger resolution - use if available, otherwise rely on error_log // Safe Logger resolution - use if available, otherwise rely on error_log
$logger = $this->container->has(Logger::class) ? $this->container->get(Logger::class) : null; $logger = $this->container->get(Logger::class);
$initializerResults = $results->attributes->get(Initializer::class); $initializerResults = $results->attributes->get(Initializer::class);
$logger?->debug("InitializerProcessor: Processing " . count($initializerResults) . " initializers"); $logger?->debug("InitializerProcessor: Processing " . count($initializerResults) . " initializers");

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\ErrorHandling; namespace App\Framework\ErrorHandling;
use App\Framework\Attributes\Initializer;
use App\Framework\DI\Container; use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\ErrorHandling\Handlers\DatabaseErrorHandler; use App\Framework\ErrorHandling\Handlers\DatabaseErrorHandler;
use App\Framework\ErrorHandling\Handlers\FallbackErrorHandler; use App\Framework\ErrorHandling\Handlers\FallbackErrorHandler;
use App\Framework\ErrorHandling\Handlers\HttpErrorHandler; use App\Framework\ErrorHandling\Handlers\HttpErrorHandler;

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\ErrorHandling\ValueObjects; namespace App\Framework\ErrorHandling\ValueObjects;
use App\Framework\ErrorHandling\ErrorSeverity; use App\Framework\Exception\Core\ErrorSeverity;
/** /**
* Value Object representing error classification metadata. * Value Object representing error classification metadata.

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use ErrorException;
final readonly class ErrorContext
{
public function __construct(
public int $severity,
public string $message,
public ?string $file = null,
public ?int $line = null,
public bool $isSuppressed = false,
) {}
public static function create(
int $severity,
string $message,
?string $file = null,
?int $line = null,
bool $isSuppressed = false,
): self {
return new self($severity, $message, $file, $line, $isSuppressed);
}
public function isDeprecation(): bool
{
return $this->severity === E_DEPRECATED || $this->severity === E_USER_DEPRECATED;
}
public function isFatal(): bool
{
return in_array($this->severity, [E_ERROR, E_RECOVERABLE_ERROR, E_USER_ERROR], true);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\ExceptionHandling;
enum ErrorDecision
{
/** Fehler selbst behandelt (kein PHP-Standard mehr) */
case HANDLED;
/** An PHP-Standard weitergeben */
case DEFER;
/** Eskalieren als Exception */
case THROW;
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Strategy\StrictErrorPolicy;
use ErrorException;
final readonly class ErrorHandler
{
public function __construct(
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy,
)
{}
/**
* @throws ErrorException
*/
public function handle(
int $severity,
string $message,
?string $file = null,
?int $line = null,
): bool {
$context = ErrorContext::create(
severity : $severity,
message : $message,
file : $file,
line : $line,
isSuppressed: $this->isSuppressed($severity)
);
$decision = $this->strategy->handle($context);
return match($decision) {
ErrorDecision::HANDLED => true,
ErrorDecision::DEFER => false,
ErrorDecision::THROW => throw new ErrorException($message, 0, $severity, $file, $line),
};
}
private function isSuppressed($severity): bool
{
return !(error_reporting() & $severity);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Framework\ExceptionHandling;
use ErrorException;
interface ErrorHandlerStrategy
{
public function handle(ErrorContext $context): ErrorDecision;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Reporter\LogReporter;
use Throwable;
final readonly class ErrorKernel
{
public function __construct(
private ErrorRendererFactory $rendererFactory = new ErrorRendererFactory,
) {}
public function handle(Throwable $e, array $context = []): mixed
{
$log = new LogReporter();
$log->report($e->getMessage());
$this->rendererFactory->getRenderer()->render();
return null;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling;
interface ErrorRenderer
{
public function render(): void;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Renderer\HtmlErrorRenderer;
final class ErrorRendererFactory
{
public function getRenderer():ErrorRenderer
{
return new HtmlErrorRenderer();
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\DI\Initializer;
use Fiber;
final class ErrorScope
{
private array $stack = [];
#[Initializer]
public static function initialize(): ErrorScope
{
return new self;
}
public function enter(ErrorScopeContext $context): int
{
$id = $this->fiberId();
$this->stack[$id] ??= [];
$this->stack[$id][] = $context;
return count($this->stack[$id]);
}
public function current(): ?ErrorScopeContext
{
$id = $this->fiberId();
$stack = $this->stack[$id] ?? [];
return end($stack) ?? null;
}
public function leave(int $token): void
{
$id = $this->fiberId();
if(!isset($this->stack[$id])) {
return;
}
while(!empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
array_pop($this->stack[$id]);
}
if(empty($this->stack[$id])) {
unset($this->stack[$id]);
}
}
private function fiberId(): int
{
$fiber = Fiber::getCurrent();
return $fiber ? spl_object_id($fiber) : 0;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
final class ErrorScopeContext
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Framework\ExceptionHandling;
use Throwable;
interface ExceptionHandler
{
public function handle(Throwable $throwable): void;
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Config\EnvironmentType;
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
final readonly class ExceptionHandlerManager
{
public function __construct()
{
$resolver = new ErrorPolicyResolver();
$this->registerErrorHandler(new ErrorHandler($resolver->resolve(EnvironmentType::DEV)));
$this->registerExceptionHandler(new GlobalExceptionHandler());
$this->registerShutdownHandler(new ShutdownHandler());
}
public function registerExceptionHandler(ExceptionHandler $handler): void
{
set_exception_handler($handler->handle(...));
}
private function registerErrorHandler(ErrorHandler $handler):void
{
set_error_handler($handler->handle(...), E_ALL);
}
public function registerShutdownHandler(ShutdownHandler $handler): void
{
register_shutdown_function($handler->handle(...));
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
final readonly class GlobalExceptionHandler implements ExceptionHandler
{
public function handle(\Throwable $throwable): void
{
$kernel = new ErrorKernel();
$kernel->handle($throwable);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Renderer;
use App\Framework\ExceptionHandling\ErrorRenderer;
final class HtmlErrorRenderer implements ErrorRenderer
{
public function render(): void
{
echo '<html lang="en"><body><h1>500 Internal Server Error</h1></body></html>';
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Reporter;
final class LogReporter implements Reporter
{
public function report(string $message): void
{
echo ("[log] " . $message);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling\Reporter;
interface Reporter
{
public function report(string $message): void;
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ExceptionHandling\Strategy\StrictErrorPolicy;
use Error;
final readonly class ShutdownHandler
{
private const array FATAL_TYPES = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
public function handle(): void
{
$last = error_get_last();
if (!$last || !$this->isFatalError($last['type'])) {
return;
}
$this->cleanOutputBuffer();
$file = (string)($last['file'] ?? 'unknown');
$line = (int)($last['line'] ?? 0);
$error = new Error($last['message'] ?? 'Fatal error',0);
try {
$ehm = new ErrorKernel();
$ehm->handle($error, ['file' => $file, 'line' => $line]);
} catch (\Throwable) {
}
exit(255);
}
private function cleanOutputBuffer(): void
{
try {
while (ob_get_level() > 0) {
@ob_end_clean();
}
} catch (\Throwable) {
// ignore
}
}
private function isFatalError(?int $type = null): bool
{
return in_array($type ?? 0, self::FATAL_TYPES, true);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\Config\EnvironmentType;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
final readonly class ErrorPolicyResolver
{
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
{
return match(true) {
$environmentType->isProduction() => new StrictErrorPolicy(),
$environmentType->isDevelopment() => new StrictErrorPolicy(),
default => new StrictErrorPolicy(),
};
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\ExceptionHandling\ErrorContext;
use App\Framework\ExceptionHandling\ErrorDecision;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use ErrorException;
final readonly class LenientPolicy implements ErrorHandlerStrategy
{
public function __construct(private Logger $logger)
{
}
public function handle(ErrorContext $context): ErrorDecision
{
if($context->isDeprecation()) {
$this->logger->notice("[Deprecation] {$context->message}",
LogContext::withData(
[
'file' => $context->file,
'line' => $context->line]
));
return ErrorDecision::HANDLED;
}
if($context->isFatal()) {
throw new ErrorException(
$context->message,
0,
$context->severity,
$context->file,
$context->line
);
}
return ErrorDecision::DEFER;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\ExceptionHandling\ErrorContext;
use App\Framework\ExceptionHandling\ErrorDecision;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
final class SilentErrorPolicy implements ErrorHandlerStrategy
{
public function handle(ErrorContext $context): ErrorDecision
{
return ErrorDecision::DEFER;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\ExceptionHandling\ErrorContext;
use App\Framework\ExceptionHandling\ErrorDecision;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
use ErrorException;
final readonly class StrictErrorPolicy implements ErrorHandlerStrategy
{
/**
* @throws ErrorException
*/
public function handle(ErrorContext $context): ErrorDecision
{
throw new ErrorException($context->message, 0, $context->severity, $context->file, $context->line);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Http;
use App\Framework\DI\Container; use App\Framework\DI\Container;
use App\Framework\Logging\Logger; use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
final readonly class HttpMiddlewareChain implements HttpMiddlewareChainInterface final readonly class HttpMiddlewareChain implements HttpMiddlewareChainInterface
{ {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Http\Middlewares; namespace App\Framework\Http\Middlewares;
use App\Framework\ErrorHandling\ErrorHandler; use App\Framework\ErrorHandling\ErrorHandler;
use App\Framework\ExceptionHandling\ErrorKernel;
use App\Framework\Http\HttpMiddleware; use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext; use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority; use App\Framework\Http\MiddlewarePriority;
@@ -18,7 +19,7 @@ final readonly class ExceptionHandlingMiddleware implements HttpMiddleware
{ {
public function __construct( public function __construct(
private Logger $logger, private Logger $logger,
private ErrorHandler $errorHandler, #private ErrorHandler $errorHandler,
) { ) {
} }
@@ -27,6 +28,10 @@ final readonly class ExceptionHandlingMiddleware implements HttpMiddleware
try { try {
return $next($context); return $next($context);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$error = new ErrorKernel();
$error->handle($e);
$response = $this->errorHandler->createHttpResponse($e, $context); $response = $this->errorHandler->createHttpResponse($e, $context);
return $context->withResponse($response); return $context->withResponse($response);