Compare commits

...

16 Commits

Author SHA1 Message Date
c93d3f07a2 fix(Console): add void as valid return type for command methods
All checks were successful
Test Runner / test-php (push) Successful in 31s
Deploy Application / deploy (push) Successful in 1m42s
Test Runner / test-basic (push) Successful in 7s
The MethodSignatureAnalyzer was rejecting command methods with void return
type, causing the schedule:run command to fail validation.
2025-11-26 06:16:09 +01:00
386baff65f fix(staging): correct command names for scheduler and queue-worker
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 8s
Deploy Application / deploy (push) Successful in 1m43s
- scheduler: scheduler:run → schedule:run (correct console command)
- queue-worker: console.php queue:work → worker.php (standalone script)
2025-11-25 23:29:31 +01:00
7f7029ae2a fix(staging): add APP_KEY_FILE environment variable to scheduler and queue-worker
All checks were successful
Test Runner / test-basic (push) Successful in 7s
Test Runner / test-php (push) Successful in 7s
Deploy Application / deploy (push) Successful in 1m46s
The scheduler and queue-worker containers were crashing with
RequiredEnvironmentVariableException because the APP_KEY_FILE
environment variable was not set, even though the app_key secret
was mounted. The Framework's Environment class needs the *_FILE
pattern to read Docker Secrets.
2025-11-25 22:45:23 +01:00
22fd89b013 fix(ErrorBoundaries): use Environment class instead of direct $_ENV access
All checks were successful
Test Runner / test-basic (push) Successful in 9s
Test Runner / test-php (push) Successful in 8s
Deploy Application / deploy (push) Successful in 1m40s
Replace direct $_ENV/$_SERVER access with framework's Environment class
to follow proper framework patterns and enable Docker Secrets support.

Changes:
- Add Environment and EnvKey imports
- Use $container->get(Environment::class) for environment access
- Replace $_ENV['APP_ENV'] with $env->getString(EnvKey::APP_ENV, ...)
- Rename internal method to registerServices for clarity
- Add documentation explaining the pattern

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 19:44:02 +01:00
85e2360a90 fix(deploy): improve deployment robustness and reliability
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 7s
Deploy Application / deploy (push) Successful in 1m35s
- Add docker volume prune to deploy.sh to prevent stale code issues
- Add automatic migrations and cache warmup to staging entrypoint
- Fix nginx race condition by waiting for PHP-FPM before starting
- Improve PHP healthcheck to use php-fpm-healthcheck
- Add curl to production nginx Dockerfile for healthchecks
- Add ensureSeedsTable() to SeedRepository for automatic table creation
- Update SeedCommand to ensure seeds table exists before operations

This prevents 502 Bad Gateway errors during deployment and ensures
fresh code is deployed without volume cache issues.
2025-11-25 17:44:44 +01:00
7785e65d08 fix(security): prevent debug error pages on staging/production
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 7s
Deploy Application / deploy (push) Successful in 1m28s
Root cause: ExceptionHandlingInitializer attempted to autowire
EnvironmentType directly, but it was never registered in the DI
container. This caused the debug mode resolution to fail silently.

Changes:
- Use TypedConfiguration instead of EnvironmentType for proper DI
- Create ErrorHandlingConfig value object to centralize config
- Access debug mode via AppConfig.isDebugEnabled() which respects
  both APP_DEBUG env var AND EnvironmentType.isDebugEnabled()
- Register ErrorHandlingConfig as singleton in container
- Remove diagnostic logging from ResponseErrorRenderer

This ensures that staging/production environments (where
EnvironmentType != DEV) will not display stack traces, code context,
or file paths in error responses.
2025-11-25 15:01:40 +01:00
520d082393 chore: add diagnostic logging for debug mode verification
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 8s
Deploy Application / deploy (push) Successful in 1m33s
This temporary logging will help verify that:
- EnvironmentType is correctly detected as STAGING
- isDebugEnabled() returns false for STAGING
- ResponseErrorRenderer receives isDebugMode=false

Remove after verification is complete.
2025-11-25 14:44:05 +01:00
f9063aa151 fix: wire debug flag into error kernel
All checks were successful
Test Runner / test-php (push) Successful in 27s
Deploy Application / deploy (push) Successful in 59s
Test Runner / test-basic (push) Successful in 6s
2025-11-25 04:36:19 +01:00
4309ea7972 fix(staging): hardcode APP_DEBUG=false to prevent env override
All checks were successful
Test Runner / test-php (push) Successful in 30s
Deploy Application / deploy (push) Successful in 59s
Test Runner / test-basic (push) Successful in 6s
Changed APP_DEBUG from ${APP_DEBUG:-false} to hardcoded false value
in all 4 services (php, nginx, queue-worker, scheduler).

This prevents any server-side .env or environment variables from
accidentally enabling debug mode in staging, which was causing
detailed error pages to be displayed.
2025-11-25 04:33:46 +01:00
26f87060d5 fix(deploy): add build parameter to ensure Docker images are rebuilt
All checks were successful
Test Runner / test-php (push) Successful in 41s
Deploy Application / deploy (push) Successful in 2m58s
Test Runner / test-basic (push) Successful in 7s
The deployment was only pulling code via git but not rebuilding the
Docker images, causing containers to run with stale code from the
registry image. This fixes the debug error pages still showing on
staging despite APP_DEBUG=false.
2025-11-25 04:23:38 +01:00
dd7cfd97e6 feat: improve stack trace rendering 2025-11-25 04:13:25 +01:00
57eabe30a5 fix(security): change ErrorRendererFactory isDebugMode default to false
All checks were successful
Test Runner / test-basic (push) Successful in 7s
Test Runner / test-php (push) Successful in 8s
Deploy Application / deploy (push) Successful in 43s
Change the default value of $isDebugMode constructor parameter from
true to false, following the security-by-default principle. This ensures
that even if the factory is instantiated without explicit debug mode
configuration, it won't leak sensitive debugging information like
stack traces, file paths, and code context.
2025-11-25 04:09:41 +01:00
77505edabf refactor(csrf): replace error_log with debugLog for structured logging
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 8s
Deploy Application / deploy (push) Successful in 45s
Replace raw error_log() calls with framework's debugLog() method for:
- Consistent structured logging with context data
- Sensitive data hashing (tokens, session IDs)
- Debug-mode awareness (only logs when debug enabled)
2025-11-25 03:52:57 +01:00
68a59f460f fix(staging): set APP_DEBUG default to false for security
All checks were successful
Deploy Application / deploy (push) Successful in 24s
Test Runner / test-php (push) Successful in 28s
Test Runner / test-basic (push) Successful in 7s
Staging environment should not expose detailed error messages,
stack traces, or debug information to end users.

Changed default from 'true' to 'false' in all services:
- php
- nginx
- queue-worker
- scheduler
2025-11-25 03:47:29 +01:00
2d762eafdf fix(deploy): add warning messages for missing Docker secrets 2025-11-25 03:13:30 +01:00
760690549d fix(deploy): escape shell variables in docker-compose YAML
Shell variables like $SECRETS_DIR in docker-compose command blocks
must be escaped as $$SECRETS_DIR. Without escaping, docker-compose
interprets them as environment variable interpolation and expands
them to empty strings, causing:
- mkdir: cannot create directory ''
- Secrets copied to wrong path (/redis_password instead of /var/www/html/storage/secrets/redis_password)
- PHP TypeError: RedisConfig::__construct() argument #3 must be string, null given

The fix applies $$ escaping to all shell variables in the PHP
service entrypoint script.
2025-11-25 03:07:26 +01:00
87 changed files with 2246 additions and 247 deletions

View File

@@ -55,8 +55,8 @@ jobs:
git fetch origin ${{ github.ref_name }}
git reset --hard origin/${{ github.ref_name }}
# Run deployment script
./deployment/scripts/deploy.sh ${{ steps.env.outputs.environment }}
# Run deployment script with image build
./deployment/scripts/deploy.sh ${{ steps.env.outputs.environment }} build
EOF
rm -f /tmp/ssh_key

View File

@@ -1 +1 @@
{"php":"8.4.14","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":true,"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one","property":"one"}},"declare_strict_types":true},"hashes":{"src\/Framework\/UserAgent\/ValueObjects\/DeviceCategory.php":"ea8bf0dd6f03932e1622b5b2ed5751fe","src\/Framework\/UserAgent\/ParsedUserAgent.php":"65db6417a82fdc55a818ad96f0fb2ed5","src\/Framework\/UserAgent\/UserAgentParser.php":"0ae01d1b91d851c653087cae6f33bc62"}}
{"php":"8.5.0RC3","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":true,"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one","property":"one"}},"declare_strict_types":true},"hashes":{"src\/Framework\/Database\/Seed\/SeedCommand.php":"020de3bf1fad561be6bdbed799d19510","src\/Framework\/Database\/Seed\/SeedRepository.php":"523204a544558a7e11d8c792b2730729","src\/Framework\/Database\/Seed\/Migrations\/CreateSeedsTable.php":"df525e2ee87854f99e79184ba3ab3433","src\/Framework\/Database\/Seed\/SeedServicesInitializer.php":"a492c24e4b1d3c2996292905695f94b7","src\/Framework\/Database\/Seed\/SeedLoader.php":"5c867e0ba10f2fefd6680a948e2e58eb","src\/Framework\/Database\/Seed\/Seeder.php":"9fe694bf7fd34d83b6d3bc74c22e207b","src\/Framework\/Database\/Seed\/SeedRunner.php":"3285f01db3fec92a0493106dd86a7fdb"}}

View File

@@ -121,6 +121,13 @@ docker compose $COMPOSE_FILES pull || print_warning "Failed to pull some images,
print_info "Stopping existing containers..."
docker compose $COMPOSE_FILES down --remove-orphans || print_warning "No existing containers to stop"
# Staging: Remove named volume to ensure fresh code from image
# This prevents stale code persisting between deployments
if [ "$ENVIRONMENT" = "staging" ]; then
print_info "Removing staging code volume to ensure fresh deployment..."
docker volume rm staging-code 2>/dev/null || print_info "No stale staging volume to remove"
fi
# Remove any orphaned containers with conflicting names
for container in nginx php redis scheduler queue-worker; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then

View File

@@ -147,9 +147,10 @@ services:
- app-backend
- app-internal
healthcheck:
test: ["CMD", "php", "-v"]
interval: 30s
timeout: 10s
# Check if PHP-FPM is accepting connections
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 60s
deploy:

View File

@@ -20,7 +20,7 @@ services:
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=${APP_DEBUG:-true}
- APP_DEBUG=false
- APP_URL=https://staging.michaelschiemer.de
- APP_KEY=${APP_KEY:-}
# Git Repository - clones staging branch
@@ -72,39 +72,44 @@ services:
# Copy Docker Secrets to readable location for www-data
# Docker Secrets are only readable by root, but PHP (www-data) needs to read them.
# We copy them here as root to a location where www-data can read them.
# Note: Use $$ to escape shell variables in docker-compose YAML
echo "🔐 Setting up Docker Secrets for PHP access..."
SECRETS_DIR="/var/www/html/storage/secrets"
# Ensure we're in the right directory
cd /var/www/html || exit 1
# Create secrets directory if it doesn't exist
mkdir -p "$SECRETS_DIR"
chmod 750 "$SECRETS_DIR"
chown www-data:www-data "$SECRETS_DIR"
mkdir -p "$$SECRETS_DIR"
chmod 750 "$$SECRETS_DIR"
chown www-data:www-data "$$SECRETS_DIR"
if [ -f /run/secrets/redis_password ]; then
cp /run/secrets/redis_password "$SECRETS_DIR/redis_password" 2>/dev/null || true
chmod 640 "$SECRETS_DIR/redis_password"
chown www-data:www-data "$SECRETS_DIR/redis_password"
export REDIS_PASSWORD_FILE="$SECRETS_DIR/redis_password"
echo "✅ Copied redis_password to $SECRETS_DIR/redis_password"
cp /run/secrets/redis_password "$$SECRETS_DIR/redis_password" 2>/dev/null || true
chmod 640 "$$SECRETS_DIR/redis_password"
chown www-data:www-data "$$SECRETS_DIR/redis_password"
export REDIS_PASSWORD_FILE="$$SECRETS_DIR/redis_password"
echo "✅ Copied redis_password to $$SECRETS_DIR/redis_password"
else
echo "⚠️ Warning: /run/secrets/redis_password not found"
fi
if [ -f /run/secrets/db_user_password ]; then
cp /run/secrets/db_user_password "$SECRETS_DIR/db_user_password" 2>/dev/null || true
chmod 640 "$SECRETS_DIR/db_user_password"
chown www-data:www-data "$SECRETS_DIR/db_user_password"
export DB_PASSWORD_FILE="$SECRETS_DIR/db_user_password"
echo "✅ Copied db_user_password to $SECRETS_DIR/db_user_password"
cp /run/secrets/db_user_password "$$SECRETS_DIR/db_user_password" 2>/dev/null || true
chmod 640 "$$SECRETS_DIR/db_user_password"
chown www-data:www-data "$$SECRETS_DIR/db_user_password"
export DB_PASSWORD_FILE="$$SECRETS_DIR/db_user_password"
echo "✅ Copied db_user_password to $$SECRETS_DIR/db_user_password"
else
echo "⚠️ Warning: /run/secrets/db_user_password not found"
fi
if [ -f /run/secrets/app_key ]; then
cp /run/secrets/app_key "$SECRETS_DIR/app_key" 2>/dev/null || true
chmod 640 "$SECRETS_DIR/app_key"
chown www-data:www-data "$SECRETS_DIR/app_key"
export APP_KEY_FILE="$SECRETS_DIR/app_key"
echo "✅ Copied app_key to $SECRETS_DIR/app_key"
cp /run/secrets/app_key "$$SECRETS_DIR/app_key" 2>/dev/null || true
chmod 640 "$$SECRETS_DIR/app_key"
chown www-data:www-data "$$SECRETS_DIR/app_key"
export APP_KEY_FILE="$$SECRETS_DIR/app_key"
echo "✅ Copied app_key to $$SECRETS_DIR/app_key"
else
echo "⚠️ Warning: /run/secrets/app_key not found"
fi
@@ -165,11 +170,25 @@ services:
echo ""
echo " GIT_REPOSITORY_URL not set, using code from image"
fi
echo ""
echo "📊 Environment variables:"
env | grep -E "DB_|APP_" | grep -v "PASSWORD|KEY|SECRET" || true
# Run database migrations
if [ -f /var/www/html/console.php ]; then
echo ""
echo "🗄️ Running database migrations..."
cd /var/www/html
php console.php db:migrate --force || echo "⚠️ Migration warning (may be OK if already migrated)"
fi
# Warm up caches
echo ""
echo "🔥 Warming up caches..."
cd /var/www/html
php console.php cache:warm 2>/dev/null || echo " Cache warmup skipped (command may not exist)"
echo ""
echo "🛠️ Adjusting filesystem permissions..."
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
@@ -186,11 +205,13 @@ services:
echo "REDIS_PASSWORD_FILE: ${REDIS_PASSWORD_FILE:-NOT SET}"
exec php-fpm
healthcheck:
test: ["CMD-SHELL", "php-fpm-healthcheck || true"]
interval: 30s
timeout: 10s
# Use HTTP liveness check via php-fpm (not via nginx)
# This checks if the PHP application is actually responding
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 40s
start_period: 60s
depends_on:
redis:
condition: service_started
@@ -205,7 +226,7 @@ services:
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=${APP_DEBUG:-true}
- APP_DEBUG=false
# Git Repository - clones staging branch
- GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-}
- GIT_BRANCH=staging
@@ -260,6 +281,26 @@ services:
fi
done
# Wait for PHP-FPM to be ready before starting nginx
# This prevents 502 Bad Gateway errors during startup
echo "⏳ [staging-nginx] Waiting for PHP-FPM to be ready..."
MAX_WAIT=30
WAITED=0
while [ $$WAITED -lt $$MAX_WAIT ]; do
# Check if PHP-FPM is accepting connections on port 9000
if nc -z php 9000 2>/dev/null; then
echo "✅ [staging-nginx] PHP-FPM is ready on php:9000"
break
fi
echo " [staging-nginx] PHP-FPM not ready yet... ($$WAITED/$$MAX_WAIT)"
sleep 1
WAITED=$$((WAITED + 1))
done
if [ $$WAITED -ge $$MAX_WAIT ]; then
echo "⚠️ [staging-nginx] PHP-FPM did not become ready within $$MAX_WAIT seconds, starting anyway..."
fi
# Start nginx only (no PHP-FPM, no Git clone - php container handles that)
echo "🚀 [staging-nginx] Starting nginx..."
exec nginx -g "daemon off;"
@@ -275,11 +316,12 @@ services:
# Network
- "traefik.docker.network=traefik-public"
healthcheck:
test: ["CMD-SHELL", "curl -f http://127.0.0.1/health || exit 1"]
interval: 30s
timeout: 10s
# Use /health/live endpoint for lightweight liveness check
test: ["CMD-SHELL", "curl -sf http://127.0.0.1/health/live || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
start_period: 30s
depends_on:
php:
condition: service_started
@@ -346,7 +388,7 @@ services:
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=${APP_DEBUG:-true}
- APP_DEBUG=false
# Database - using separate staging database
- DB_HOST=postgres
- DB_PORT=5432
@@ -355,6 +397,7 @@ services:
- DB_PASSWORD=${DB_PASSWORD}
# Use Docker Secrets via *_FILE pattern (Framework supports this automatically)
- DB_PASSWORD_FILE=/run/secrets/db_user_password
- APP_KEY_FILE=/run/secrets/app_key
# Redis
- REDIS_HOST=redis
- REDIS_PORT=6379
@@ -371,7 +414,7 @@ services:
- app-logs:/var/www/html/storage/logs
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
command: php console.php queue:work --queue=default --timeout=${QUEUE_WORKER_TIMEOUT:-60}
command: php worker.php
healthcheck:
test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"]
interval: 60s
@@ -401,7 +444,7 @@ services:
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=${APP_DEBUG:-true}
- APP_DEBUG=false
# Database - using separate staging database
- DB_HOST=postgres
- DB_PORT=5432
@@ -410,6 +453,7 @@ services:
- DB_PASSWORD=${DB_PASSWORD}
# Use Docker Secrets via *_FILE pattern (Framework supports this automatically)
- DB_PASSWORD_FILE=/run/secrets/db_user_password
- APP_KEY_FILE=/run/secrets/app_key
# Redis
- REDIS_HOST=redis
- REDIS_PORT=6379
@@ -420,7 +464,7 @@ services:
- app-logs:/var/www/html/storage/logs
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
command: php console.php scheduler:run
command: php console.php schedule:run
healthcheck:
test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"]
interval: 60s

View File

@@ -9,6 +9,7 @@ RUN apk add --no-cache \
certbot-nginx \
su-exec \
netcat-openbsd \
curl \
openssl \
bash

View File

@@ -159,10 +159,81 @@ function processUser(User $user): UserProfile
```
**Available Value Objects**:
- Core: Email, RGBColor, Url, Hash, Version, Coordinates
- Core: Email, RGBColor, Url, Hash, Version, Coordinates, ClassName, PhpNamespace
- HTTP: FlashMessage, ValidationError, RouteParameters
- Security: OWASPEventIdentifier, MaskedEmail, ThreatLevel
- Performance: Measurement, MetricContext, MemorySummary
- Filesystem: FilePath
- Framework: FrameworkModule, FrameworkModuleRegistry
## Framework Module System
Das Framework verwendet ein modulares System, bei dem jeder Top-Level-Ordner in `src/Framework/` als eigenständiges Modul behandelt wird.
### FrameworkModule Value Object
Repräsentiert ein einzelnes Framework-Modul:
```php
use App\Framework\Core\ValueObjects\FrameworkModule;
use App\Framework\Filesystem\ValueObjects\FilePath;
// Modul erstellen
$basePath = FilePath::create('/var/www/html/src/Framework');
$httpModule = FrameworkModule::create('Http', $basePath);
// Namespace-Zugehörigkeit prüfen
$namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth');
$httpModule->containsNamespace($namespace); // true
// Klassen-Zugehörigkeit prüfen
$className = ClassName::create('App\\Framework\\Http\\Request');
$httpModule->containsClass($className); // true
// Relative Namespace ermitteln
$relative = $httpModule->getRelativeNamespace($namespace);
// Returns: PhpNamespace für 'Middlewares\\Auth'
```
### FrameworkModuleRegistry
Registry aller Framework-Module mit Lookup-Funktionalität:
```php
use App\Framework\Core\ValueObjects\FrameworkModuleRegistry;
// Automatische Discovery aller Module
$registry = FrameworkModuleRegistry::discover($frameworkPath);
// Oder manuell mit variadic constructor
$registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Database', $basePath),
FrameworkModule::create('Cache', $basePath)
);
// Modul für Namespace finden
$module = $registry->getModuleForNamespace($namespace);
// Modul für Klasse finden
$module = $registry->getModuleForClass($className);
// Prüfen ob zwei Klassen im selben Modul liegen
$inSame = $registry->classesInSameModule($classA, $classB);
// Prüfen ob zwei Namespaces im selben Modul liegen
$inSame = $registry->inSameModule($namespaceA, $namespaceB);
// Prüfen ob zwei Dateien im selben Modul liegen
$inSame = $registry->filesInSameModule($filePathA, $filePathB);
```
### Use Cases
**Dependency Analysis**: Prüfen ob Abhängigkeiten zwischen Modulen bestehen
**Module Boundaries**: Sicherstellen dass Module-interne Klassen nicht extern verwendet werden
**Circular Dependency Detection**: Erkennen von zirkulären Modul-Abhängigkeiten
**Code Organization**: Validieren dass Klassen im richtigen Modul liegen
## Middleware System

View File

@@ -44,10 +44,13 @@ final readonly class NavigationItem
public function toArray(): array
{
$icon = $this->getIcon();
return [
'name' => $this->name,
'url' => $this->url,
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
'icon_html' => $icon?->toHtml('admin-nav__icon') ?? '',
'is_active' => $this->isActive,
];
}

View File

@@ -60,6 +60,8 @@ final readonly class NavigationSection
public function toArray(): array
{
$icon = $this->getIcon();
return [
'section' => $this->name,
'name' => $this->name,
@@ -68,6 +70,7 @@ final readonly class NavigationSection
$this->items
),
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
'icon_html' => $icon?->toHtml('admin-nav__section-icon') ?? '',
];
}

View File

@@ -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',

View 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;
}
}

View 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);
}
}

View File

@@ -47,4 +47,3 @@ final readonly class CreateSeedsTable implements Migration, SafelyReversible
return 'Create seeds table for tracking executed seeders';
}
}

View File

@@ -24,6 +24,9 @@ final readonly class SeedCommand
$fresh = $input->hasOption('fresh');
try {
// Ensure seeds table exists (auto-create if missing)
$this->seedRepository->ensureSeedsTable();
// Clear seeds table if --fresh option is provided
if ($fresh) {
echo "⚠️ Clearing seeds table (--fresh option)...\n";
@@ -35,9 +38,10 @@ final readonly class SeedCommand
// Run specific seeder
echo "Running seeder: {$className}\n";
$seeder = $this->seedLoader->load($className);
if ($seeder === null) {
echo "❌ Seeder '{$className}' not found or cannot be instantiated.\n";
return ExitCode::SOFTWARE_ERROR;
}
@@ -50,6 +54,7 @@ final readonly class SeedCommand
if (empty($seeders)) {
echo "No seeders found.\n";
return ExitCode::SUCCESS;
}
@@ -74,4 +79,3 @@ final readonly class SeedCommand
}
}
}

View File

@@ -61,4 +61,3 @@ final readonly class SeedLoader
return null;
}
}

View File

@@ -5,17 +5,68 @@ declare(strict_types=1);
namespace App\Framework\Database\Seed;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
final readonly class SeedRepository
{
private const string TABLE_NAME = 'seeds';
public function __construct(
private ConnectionInterface $connection,
private DatabasePlatform $platform,
private Clock $clock
) {
}
/**
* Ensure the seeds table exists, create it if not
*
* This method should be called before any seed operations to prevent
* "table does not exist" errors on fresh databases.
*/
public function ensureSeedsTable(): void
{
if ($this->tableExists()) {
return;
}
$this->createSeedsTable();
}
/**
* Check if the seeds table exists
*/
private function tableExists(): bool
{
try {
$sql = $this->platform->getTableExistsSQL(self::TABLE_NAME);
$result = $this->connection->queryScalar(SqlQuery::create($sql));
return (bool) $result;
} catch (\Throwable) {
return false;
}
}
/**
* Create the seeds tracking table
*/
private function createSeedsTable(): void
{
$sql = <<<'SQL'
CREATE TABLE seeds (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
SQL;
$this->connection->execute(SqlQuery::create($sql));
}
/**
* Check if a seeder has already been executed
*/
@@ -60,4 +111,3 @@ final readonly class SeedRepository
return hash('sha256', $name);
}
}

View File

@@ -27,6 +27,7 @@ final readonly class SeedRunner
// Check if already executed
if ($this->seedRepository->hasRun($name)) {
$this->log("Skipping seeder '{$name}' - already executed", 'info');
return;
}
@@ -38,6 +39,7 @@ final readonly class SeedRunner
$this->log("Seeder '{$name}' completed successfully", 'info');
} catch (\Throwable $e) {
$this->log("Seeder '{$name}' failed: {$e->getMessage()}", 'error');
throw $e;
}
}
@@ -71,4 +73,3 @@ final readonly class SeedRunner
}
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Database\Seed;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\DateTime\Clock;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
@@ -20,6 +21,7 @@ final readonly class SeedServicesInitializer
$container->singleton(SeedRepository::class, function (Container $c) {
return new SeedRepository(
$c->get(ConnectionInterface::class),
$c->get(DatabasePlatform::class),
$c->get(Clock::class)
);
});
@@ -27,12 +29,14 @@ final readonly class SeedServicesInitializer
// SeedLoader
$container->singleton(SeedLoader::class, function (Container $c) {
$discoveryRegistry = $c->get(DiscoveryRegistry::class);
return new SeedLoader($discoveryRegistry, $c);
});
// SeedRunner
$container->singleton(SeedRunner::class, function (Container $c) {
$logger = $c->has(Logger::class) ? $c->get(Logger::class) : null;
return new SeedRunner(
$c->get(SeedRepository::class),
$logger
@@ -40,4 +44,3 @@ final readonly class SeedServicesInitializer
});
}
}

View File

@@ -36,4 +36,3 @@ interface Seeder
*/
public function getDescription(): string;
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\ErrorBoundaries\Middleware;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\ErrorBoundaries\ErrorBoundaryFactory;
@@ -12,6 +14,11 @@ use App\Framework\Logging\Logger;
/**
* Service provider for error boundary middleware
*
* Note: The #[Initializer] method uses the Container to get Environment,
* following framework patterns for environment-aware configuration.
* For manual/custom configuration, use the factory methods:
* development(), production(), apiOnly(), or custom().
*/
final readonly class MiddlewareServiceProvider
{
@@ -20,8 +27,34 @@ final readonly class MiddlewareServiceProvider
) {
}
/**
* Initializer for automatic DI registration
*
* Uses environment-aware configuration (development or production)
* based on APP_ENV from Environment service.
*/
#[Initializer]
public function initialize(Container $container): void
public static function initialize(Container $container): void
{
// Get Environment from container (proper framework pattern)
$env = $container->get(Environment::class);
$appEnv = $env->getString(EnvKey::APP_ENV, 'production');
// Determine configuration based on environment
$configuration = match ($appEnv) {
'development', 'local', 'dev' => MiddlewareConfiguration::development(),
'staging' => MiddlewareConfiguration::production(), // Staging uses production config
default => MiddlewareConfiguration::production(),
};
$provider = new self($configuration);
$provider->registerServices($container);
}
/**
* Register all middleware services in the container
*/
private function registerServices(Container $container): void
{
$this->registerMiddlewareConfiguration($container);
$this->registerMiddlewareComponents($container);

View File

@@ -20,4 +20,3 @@ final readonly class DiscoveryWarning
) {
}
}

View File

@@ -101,4 +101,3 @@ final class DiscoveryWarningAggregator
return $this->warningsByFile !== [];
}
}

View File

@@ -25,4 +25,3 @@ final readonly class DiscoveryWarningGroup
return count($this->warnings);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -37,4 +37,3 @@ final readonly class ExceptionCorrelation
];
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
enum ErrorDecision

View File

@@ -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);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
interface ErrorHandlerInterface

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling;
declare(strict_types=1);
use ErrorException;
namespace App\Framework\ExceptionHandling;
interface ErrorHandlerStrategy
{

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Config\EnvironmentType;
/**
* Configuration for the error handling system
*
* This value object centralizes all error handling configuration,
* ensuring debug mode and other settings are consistently passed
* through the error handling pipeline.
*/
final readonly class ErrorHandlingConfig
{
public function __construct(
public bool $debugMode = false,
public bool $logErrors = true,
public bool $displayErrors = false
) {
}
/**
* Create config from EnvironmentType
*
* Uses the environment's debug setting to determine error display behavior.
*/
public static function fromEnvironment(EnvironmentType $environmentType): self
{
$isDebug = $environmentType->isDebugEnabled();
return new self(
debugMode: $isDebug,
logErrors: true,
displayErrors: $isDebug
);
}
/**
* Create config for development environment
*/
public static function forDevelopment(): self
{
return new self(
debugMode: true,
logErrors: true,
displayErrors: true
);
}
/**
* Create config for production environment
*/
public static function forProduction(): self
{
return new self(
debugMode: false,
logErrors: true,
displayErrors: false
);
}
/**
* Check if debug information should be displayed
*/
public function shouldDisplayDebugInfo(): bool
{
return $this->debugMode && $this->displayErrors;
}
}

View File

@@ -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;
@@ -25,6 +25,8 @@ use Throwable;
*/
final readonly class ErrorKernel
{
private ErrorHandlingConfig $config;
public function __construct(
private ErrorRendererFactory $rendererFactory,
private ?Reporter $reporter,
@@ -34,8 +36,10 @@ final readonly class ErrorKernel
private ?ExceptionRateLimiter $rateLimiter = null,
private ?ExecutionContext $executionContext = null,
private ?ConsoleOutput $consoleOutput = null,
private bool $isDebugMode = false
) {}
?ErrorHandlingConfig $config = null
) {
$this->config = $config ?? new ErrorHandlingConfig();
}
/**
* Context-aware exception handler
@@ -57,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);
}
@@ -71,7 +75,7 @@ final readonly class ErrorKernel
$this->errorAggregator->processError(
$e,
$this->contextProvider,
$this->isDebugMode
$this->config->debugMode
);
}
@@ -81,6 +85,7 @@ final readonly class ErrorKernel
// Handle based on context
if ($executionContext->isCli()) {
$this->handleCliException($e);
return null;
}
@@ -122,7 +127,7 @@ final readonly class ErrorKernel
$this->errorAggregator->processError(
$exception,
$this->contextProvider,
$this->isDebugMode
$this->config->debugMode
);
}
}
@@ -147,7 +152,7 @@ final readonly class ErrorKernel
$this->errorAggregator->processError(
$exception,
$this->contextProvider,
$this->isDebugMode
$this->config->debugMode
);
}
}
@@ -169,13 +174,13 @@ final readonly class ErrorKernel
?bool $isDebugMode = null
): HttpResponse {
// Use provided debug mode or instance default
$debugMode = $isDebugMode ?? $this->isDebugMode;
$debugMode = $isDebugMode ?? $this->config->debugMode;
// Get renderer from factory
$renderer = $this->rendererFactory->getRenderer();
// If renderer is ResponseErrorRenderer and debug mode changed, create new one with correct debug mode
if ($renderer instanceof ResponseErrorRenderer && $debugMode !== $this->isDebugMode) {
if ($renderer instanceof ResponseErrorRenderer && $debugMode !== $this->config->debugMode) {
$renderer = $this->rendererFactory->createHttpRenderer($debugMode);
}
@@ -186,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');
}
@@ -217,4 +222,3 @@ final readonly class ErrorKernel
};
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
@@ -16,12 +17,16 @@ use App\Framework\View\Engine;
*/
final readonly class ErrorRendererFactory
{
private ErrorHandlingConfig $config;
public function __construct(
private ExecutionContext $executionContext,
private Engine $engine,
private ?ConsoleOutput $consoleOutput = null,
private bool $isDebugMode = true
) {}
?ErrorHandlingConfig $config = null
) {
$this->config = $config ?? new ErrorHandlingConfig();
}
/**
* Get appropriate renderer for current execution context
@@ -31,10 +36,11 @@ 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);
}
return new ResponseErrorRenderer($this->engine, $this->isDebugMode);
return new ResponseErrorRenderer($this->engine, $this->config->debugMode);
}
/**
@@ -48,7 +54,16 @@ final readonly class ErrorRendererFactory
*/
public function createHttpRenderer(?bool $debugMode = null): ResponseErrorRenderer
{
$debugMode = $debugMode ?? $this->isDebugMode;
$debugMode = $debugMode ?? $this->config->debugMode;
return new ResponseErrorRenderer($this->engine, $debugMode);
}
/**
* Get the current error handling configuration
*/
public function getConfig(): ErrorHandlingConfig
{
return $this->config;
}
}

View File

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

View File

@@ -85,4 +85,3 @@ enum ErrorSeverityType: int
};
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use Throwable;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;

View File

@@ -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
);
}
}

View File

@@ -4,12 +4,15 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Config\EnvironmentType;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Context\ExecutionContext;
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;
@@ -25,13 +28,25 @@ final readonly class ExceptionHandlingInitializer
#[Initializer]
public function initialize(
Container $container,
EnvironmentType $environmentType,
TypedConfiguration $typedConfig,
ExecutionContext $executionContext,
Engine $engine,
?ConsoleOutput $consoleOutput = null,
?Logger $logger = null
): void {
$isDebugMode = $environmentType->isDebugEnabled();
// Get debug mode from AppConfig - this respects both APP_DEBUG env var AND EnvironmentType
$isDebugMode = $typedConfig->app->isDebugEnabled();
$environmentType = $typedConfig->app->type;
// Create centralized error handling configuration
$errorConfig = new ErrorHandlingConfig(
debugMode: $isDebugMode,
logErrors: true,
displayErrors: $isDebugMode
);
// Register ErrorHandlingConfig as singleton for other components
$container->singleton(ErrorHandlingConfig::class, $errorConfig);
// ConsoleOutput - only create if CLI context and not already provided
// For HTTP context, null is acceptable (ConsoleErrorRenderer won't be used)
@@ -49,11 +64,28 @@ final readonly class ExceptionHandlingInitializer
executionContext: $executionContext,
engine: $engine,
consoleOutput: $consoleOutput, // null for HTTP context, ConsoleOutput for CLI context
isDebugMode: $isDebugMode
config: $errorConfig
));
// ErrorKernel - can be autowired (optional dependencies are nullable)
// No explicit binding needed - container will autowire
// ErrorKernel - bind singleton with ErrorHandlingConfig so HTTP renderers respect environment
$container->singleton(ErrorKernel::class, static function (Container $c) use ($errorConfig, $executionContext, $consoleOutput): ErrorKernel {
$errorAggregator = $c->has(ErrorAggregatorInterface::class) ? $c->get(ErrorAggregatorInterface::class) : null;
$contextProvider = $c->has(ExceptionContextProvider::class) ? $c->get(ExceptionContextProvider::class) : null;
$auditLogger = $c->has(ExceptionAuditLogger::class) ? $c->get(ExceptionAuditLogger::class) : null;
$rateLimiter = $c->has(ExceptionRateLimiter::class) ? $c->get(ExceptionRateLimiter::class) : null;
return new ErrorKernel(
rendererFactory: $c->get(ErrorRendererFactory::class),
reporter: $c->get(Reporter::class),
errorAggregator: $errorAggregator,
contextProvider: $contextProvider,
auditLogger: $auditLogger,
rateLimiter: $rateLimiter,
executionContext: $executionContext,
consoleOutput: $consoleOutput,
config: $errorConfig
);
});
// GlobalExceptionHandler - can be autowired
// No explicit binding needed - container will autowire
@@ -67,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());
}
@@ -116,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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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
{

View File

@@ -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;
}
}

View File

@@ -81,4 +81,3 @@ final readonly class ExceptionLocalizer
return array_unique($chain);
}
}

View File

@@ -40,4 +40,3 @@ final readonly class ExceptionMetrics
];
}
}

View File

@@ -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);
}
}

View File

@@ -63,7 +63,7 @@ final readonly class PrometheusExporter
{
// Replace invalid characters
$sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
return $sanitized;
}
}

View File

@@ -180,4 +180,3 @@ final readonly class ErrorScopeMiddleware implements HttpMiddleware
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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;
}
}

View File

@@ -40,4 +40,3 @@ final readonly class FixSuggestion
];
}
}

View File

@@ -51,4 +51,3 @@ final readonly class ExceptionPerformanceMetrics
];
}
}

View File

@@ -108,4 +108,3 @@ final readonly class ExceptionPerformanceTracker
return null;
}
}

View File

@@ -143,4 +143,3 @@ final readonly class ExceptionFingerprint
return $this->hash;
}
}

View File

@@ -106,4 +106,3 @@ final readonly class ExceptionRateLimitConfig
);
}
}

View File

@@ -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);
}
}

View File

@@ -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
{
}

View File

@@ -31,4 +31,3 @@ enum RetryStrategy: string
};
}
}

View File

@@ -20,7 +20,8 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
{
public function __construct(
private ConsoleOutput $output
) {}
) {
}
public function canRender(\Throwable $exception): bool
{
@@ -55,18 +56,28 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
ConsoleColor::RED
);
if ($exception->getPrevious()) {
$previous = $exception->getPrevious();
while ($previous !== null) {
$this->output->writeErrorLine(
" Caused by: " . $exception->getPrevious()->getMessage(),
" Caused by: " . get_class($previous) . ": " . $previous->getMessage(),
ConsoleColor::YELLOW
);
$previous = $previous->getPrevious();
}
$this->output->writeErrorLine(" Stack trace:", ConsoleColor::GRAY);
$stackTrace = StackTrace::fromThrowable($exception);
foreach ($stackTrace->getItems() as $index => $item) {
$formattedLine = $this->formatStackTraceLine($index, $item);
$this->output->writeErrorLine(" " . $formattedLine, ConsoleColor::GRAY);
$items = $stackTrace->getItems();
$indexWidth = strlen((string) max(count($items) - 1, 0));
foreach ($items as $index => $item) {
$formattedLine = $this->formatStackTraceLine($index, $item, $indexWidth);
$color = $item->isVendorFrame() ? ConsoleColor::GRAY : ConsoleColor::WHITE;
if ($index === 0) {
$color = ConsoleColor::BRIGHT_WHITE;
}
$this->output->writeErrorLine(" " . $formattedLine, $color);
}
// Context information if available
@@ -97,25 +108,26 @@ 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);
}
/**
* Format stack trace line with clickable file links
*/
private function formatStackTraceLine(int $index, \App\Framework\ExceptionHandling\ValueObjects\StackItem $item): string
private function formatStackTraceLine(int $index, \App\Framework\ExceptionHandling\ValueObjects\StackItem $item, int $indexWidth): string
{
$baseFormat = sprintf('#%d %s', $index, $item->formatForDisplay());
$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;
}
@@ -124,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) {
@@ -140,4 +152,3 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
return $baseFormat;
}
}

View File

@@ -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
@@ -69,10 +70,11 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
?ExceptionContextProvider $contextProvider
): HttpResponse {
$statusCode = $this->getHttpStatusCode($exception);
$stackTrace = StackTrace::fromThrowable($exception);
// 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()
@@ -84,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) {
@@ -98,7 +100,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
if ($this->isDebugMode) {
$errorData['error']['file'] = $exception->getFile();
$errorData['error']['line'] = $exception->getLine();
$errorData['error']['trace'] = StackTrace::fromThrowable($exception)->toArray();
$errorData['error']['trace'] = $stackTrace->toArray();
$errorData['error']['trace_short'] = $stackTrace->formatShort();
// Add context from WeakMap if available
if ($contextProvider !== null) {
@@ -134,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(
@@ -161,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
@@ -192,6 +197,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
));
// Return null to trigger fallback HTML generation
$templateError = $e->getMessage();
return null;
}
}
@@ -227,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
@@ -236,8 +244,20 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'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);
@@ -261,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');
@@ -272,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) {
@@ -290,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;
@@ -311,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;
@@ -332,14 +411,88 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
font-weight: bold;
color: #666;
}
.stack-trace {
margin-top: 1rem;
}
.stack-trace__header {
display: flex;
align-items: center;
gap: 1rem;
}
.stack-trace__list {
margin-top: 0.5rem;
border: 1px solid #e1e3e6;
border-radius: 6px;
background: #fff;
}
.stack-frame {
border-bottom: 1px solid #f0f0f0;
padding: 0.5rem 0.75rem;
}
.stack-frame:last-child {
border-bottom: none;
}
.stack-frame__index {
display: inline-block;
min-width: 32px;
color: #666;
}
.stack-frame__call {
font-weight: 600;
}
.stack-frame__location {
color: #666;
margin-left: 0.5rem;
}
.stack-frame__code {
margin-top: 0.5rem;
}
.stack-frame[open] summary {
font-weight: 600;
}
.stack-frame summary {
list-style: none;
cursor: pointer;
}
.stack-frame summary::-webkit-details-marker { display:none; }
.stack-frame--vendor {
opacity: 0.8;
background: #fafbfc;
}
.btn-copy {
border: 1px solid #d0d4da;
background: #fff;
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
}
.btn-copy:hover {
background: #eef1f5;
}
</style>
</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>
@@ -360,6 +513,9 @@ HTML;
$line = $exception->getLine();
$stackTrace = StackTrace::fromThrowable($exception);
$trace = $stackTrace->formatForHtml(); // Already HTML-encoded in formatForHtml
$traceShort = htmlspecialchars($stackTrace->formatShort(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$tracePlain = htmlspecialchars($stackTrace->formatForConsole(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$renderedFrames = $this->renderStackTraceList($stackTrace);
$contextHtml = '';
if ($contextProvider !== null) {
@@ -399,11 +555,41 @@ HTML;
<div class="context-item">
<span class="context-label">File:</span> {$file}:{$line}
</div>
<div class="context-item">
<span class="context-label">Short trace:</span> {$traceShort}
</div>
{$contextHtml}
{$codeSnippet}
<h4>Stack Trace:</h4>
<pre>{$trace}</pre>
<div class="stack-trace">
<div class="stack-trace__header">
<h4 style="margin: 0;">Stack Trace</h4>
<button type="button" class="btn-copy" data-copy-target="full-trace-text">Copy stack</button>
</div>
<div class="stack-trace__list">
{$renderedFrames}
</div>
<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>
<script>
document.addEventListener('DOMContentLoaded', function () {
const btn = document.querySelector('[data-copy-target="full-trace-text"]');
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';
setTimeout(() => btn.textContent = 'Copy stack', 1200);
} catch (e) {
btn.textContent = 'Copy failed';
setTimeout(() => btn.textContent = 'Copy stack', 1200);
}
});
});
</script>
HTML;
}
@@ -486,6 +672,7 @@ HTML;
private function getShortClassName(string $fullClassName): string
{
$parts = explode('\\', $fullClassName);
return end($parts);
}
@@ -494,7 +681,7 @@ HTML;
*/
private function getCodeSnippet(string $file, int $line): string
{
if (!file_exists($file)) {
if (! file_exists($file)) {
return '';
}
@@ -512,4 +699,31 @@ HTML;
return '';
}
}
private function renderStackTraceList(StackTrace $stackTrace): string
{
$frames = [];
foreach ($stackTrace->getItems() as $index => $item) {
$call = $item->getCall() !== '' ? $item->getCall() : '{main}';
$callEscaped = htmlspecialchars($call, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$locationEscaped = htmlspecialchars($item->getShortFile() . ':' . $item->line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$isVendor = $item->isVendorFrame();
$codeSnippet = '';
if (! $isVendor && $index < 3) {
$codeSnippet = $this->getCodeSnippet($item->file, $item->line);
}
$frames[] = sprintf(
'<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' : '',
$index,
$callEscaped,
$locationEscaped,
$codeSnippet !== '' ? '<div class="stack-frame__code">' . $codeSnippet . '</div>' : ''
);
}
return implode("\n", $frames);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Reporter;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Reporter;
interface Reporter

View File

@@ -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
}
}
}

View File

@@ -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,
];
}
}

View File

@@ -41,7 +41,8 @@ final readonly class ErrorScopeContext
public ?string $jobId = null,
public ?string $commandName = null,
public array $tags = [],
) {}
) {
}
/**
* Create HTTP scope from request

View File

@@ -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;
}
}

View File

@@ -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
}
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
interface ShutdownHandlerInterface

View File

@@ -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(),

View File

@@ -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,

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;

View 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>

View File

@@ -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
);
}
}

View File

@@ -49,4 +49,3 @@ final readonly class UserFriendlyMessage
return new self($message, helpText: $helpText);
}
}

View File

@@ -12,6 +12,10 @@ namespace App\Framework\ExceptionHandling\ValueObjects;
*/
final readonly class StackItem
{
private const MAX_STRING_LENGTH = 80;
private const MAX_ARGS = 6;
private const MAX_PARAMS_LENGTH = 200;
public function __construct(
public string $file,
public int $line,
@@ -57,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
);
}
@@ -73,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';
@@ -98,6 +103,7 @@ final readonly class StackItem
if ($parentClass !== false) {
return sprintf('Anonymous(%s)', $parentClass);
}
return 'Anonymous';
}
@@ -108,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
);
}
@@ -141,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, '/')) {
@@ -157,7 +165,7 @@ final readonly class StackItem
$parts = explode('/', $normalizedClass);
$shortName = end($parts);
}
return $shortName;
}
@@ -166,7 +174,7 @@ final readonly class StackItem
*/
public function getShortFile(): string
{
$projectRoot = dirname(__DIR__, 4); // Von src/Framework/ExceptionHandling/ValueObjects nach root
$projectRoot = self::projectRoot();
if (str_starts_with($this->file, $projectRoot)) {
return substr($this->file, strlen($projectRoot) + 1);
@@ -175,6 +183,17 @@ final readonly class StackItem
return $this->file;
}
/**
* True wenn Frame im vendor/ Verzeichnis liegt
*/
public function isVendorFrame(): bool
{
$projectRoot = self::projectRoot();
$vendorPath = $projectRoot . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR;
return str_starts_with($this->file, $vendorPath);
}
/**
* Gibt vollständigen Method/Function Call zurück (ohne Namespace)
*/
@@ -200,15 +219,30 @@ final readonly class StackItem
/**
* Formatiert Parameter für Display (kompakte Darstellung)
*/
public function formatParameters(): string
public function formatParameters(int $maxArgs = self::MAX_ARGS, int $maxTotalLength = self::MAX_PARAMS_LENGTH): string
{
if (empty($this->args)) {
return '';
}
$formatted = [];
foreach ($this->args as $arg) {
$formatted[] = $this->formatParameterForDisplay($arg);
$length = 0;
foreach ($this->args as $index => $arg) {
if ($index >= $maxArgs) {
$formatted[] = '…';
break;
}
$param = $this->formatParameterForDisplay($arg);
$length += strlen($param);
$formatted[] = $param;
if ($length > $maxTotalLength) {
$formatted[] = '…';
break;
}
}
return implode(', ', $formatted);
@@ -243,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);
}
@@ -254,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);
}
}
@@ -262,12 +298,13 @@ 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);
}
// Lange Strings kürzen
if (strlen($value) > 50) {
return sprintf("'%s...'", substr($value, 0, 50));
if (strlen($value) > self::MAX_STRING_LENGTH) {
return sprintf("'%s...'", substr($value, 0, self::MAX_STRING_LENGTH));
}
return sprintf("'%s'", $value);
@@ -291,8 +328,10 @@ final readonly class StackItem
$interfaceName = $matches[1];
$interfaceParts = explode('\\', $interfaceName);
$shortInterface = end($interfaceParts);
return $shortInterface . ' (anonymous)';
}
return 'Anonymous';
}
@@ -313,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);
}
@@ -346,9 +386,20 @@ final readonly class StackItem
// Normalisiere Forward-Slashes zu Backslashes
$normalized = str_replace('/', '\\', $classString);
$parts = explode('\\', $normalized);
return end($parts);
}
/**
* Kurzform für kompaktes Logging / JSON
*/
public function formatShort(): string
{
$call = $this->getCall() !== '' ? $this->getCall() : '{main}';
return sprintf('%s @ %s:%d', $call, $this->getShortFile(), $this->line);
}
/**
* Formatiert für Display (HTML/Console)
* Verwendet Standard PHP Stack Trace Format: ClassName->methodName($param1, $param2, ...) in file.php:line
@@ -365,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);
}
@@ -372,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);
}
@@ -405,7 +458,7 @@ final readonly class StackItem
$data['type'] = $this->type;
}
if (!empty($this->args)) {
if (! empty($this->args)) {
$data['args'] = $this->serializeArgs();
}
@@ -420,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
);
}
@@ -437,5 +490,16 @@ final readonly class StackItem
default => $value,
};
}
}
private static function projectRoot(): string
{
static $projectRoot = null;
if ($projectRoot !== null) {
return $projectRoot;
}
$projectRoot = dirname(__DIR__, 4);
return $projectRoot;
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling\ValueObjects;
use IteratorAggregate;
use Countable;
use IteratorAggregate;
use Throwable;
/**
@@ -39,6 +39,21 @@ final readonly class StackTrace implements IteratorAggregate, Countable
return new self($items);
}
/**
* Liefert nur App-Frames (keine vendor Frames)
*/
public function appFrames(): self
{
return new self(
array_values(
array_filter(
$this->items,
fn (StackItem $item) => ! $item->isVendorFrame()
)
)
);
}
/**
* Begrenzt Anzahl der Frames
*/
@@ -82,6 +97,24 @@ final readonly class StackTrace implements IteratorAggregate, Countable
return implode("\n", $lines);
}
/**
* Kompakte, einzeilige Darstellung für Logs/JSON
*/
public function formatShort(int $maxFrames = 5): string
{
$frames = array_slice($this->items, 0, $maxFrames);
$formatted = array_map(
fn (StackItem $item) => $item->formatShort(),
$frames
);
$suffix = count($this->items) > $maxFrames
? sprintf(' … +%d frames', count($this->items) - $maxFrames)
: '';
return implode(' | ', $formatted) . $suffix;
}
/**
* Konvertiert zu Array für JSON-Serialisierung
*
@@ -90,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
);
}
@@ -115,4 +148,3 @@ final readonly class StackTrace implements IteratorAggregate, Countable
return count($this->items);
}
}

View File

@@ -203,7 +203,10 @@ final readonly class CsrfProtection
$otherTokenData = $csrfData->getFormData($otherFormId);
if ($otherTokenData !== null && $otherTokenData->matches($tokenString)) {
$foundInOtherForm = $otherFormId;
error_log("CsrfProtection::validateToken - Token found in different form ID: $otherFormId (requested: $formId)");
$this->debugLog('CsrfProtection::validateToken - Token found in different form ID', [
'found_in_form_id' => $otherFormId,
'requested_form_id' => $formId
]);
break;
}
}
@@ -224,10 +227,13 @@ final readonly class CsrfProtection
// Debug: Log token comparison
$storedTokenString = $tokenData->token->toString();
$requestTokenString = $token->toString();
error_log("CsrfProtection::validateToken - Comparing tokens:");
error_log(" Stored: " . substr($storedTokenString, 0, 20) . "... (length: " . strlen($storedTokenString) . ")");
error_log(" Request: " . substr($requestTokenString, 0, 20) . "... (length: " . strlen($requestTokenString) . ")");
error_log(" Match: " . ($tokenData->matches($token->toString()) ? 'YES' : 'NO'));
$this->debugLog('CsrfProtection::validateToken - Comparing tokens', [
'stored_token' => $storedTokenString,
'request_token' => $requestTokenString,
'stored_length' => strlen($storedTokenString),
'request_length' => strlen($requestTokenString),
'match' => $tokenData->matches($token->toString()) ? 'YES' : 'NO'
]);
if ($tokenData->matches($token->toString())) {
// Check if token is expired
@@ -247,7 +253,7 @@ final readonly class CsrfProtection
// Token validated - rotate to new token
$newToken = $this->tokenGenerator->generate();
error_log("CsrfProtection::validateToken - Token validated, rotating to new token for formId: $formId");
$this->debugLog('CsrfProtection::validateToken - Token validated, rotating to new token', ['form_id' => $formId]);
if ($this->sessionManager !== null) {
$this->sessionManager->updateSessionDataAtomically(
@@ -269,10 +275,12 @@ final readonly class CsrfProtection
return ['valid' => true, 'new_token' => $newToken];
}
// No matching token found - add more debug info
error_log("CsrfProtection::validateToken - No matching token found. Stored token: " . substr($tokenData->token->toString(), 0, 20) . "...");
$this->debugLog('CsrfProtection::validateToken - No matching token found', [
'stored_token' => $tokenData->token->toString()
]);
// Check if token exists for another form ID (common mistake)
$tokenString = $token->toString();
$foundInOtherForm = null;
@@ -283,7 +291,10 @@ final readonly class CsrfProtection
$otherTokenData = $csrfData->getFormData($otherFormId);
if ($otherTokenData !== null && $otherTokenData->matches($tokenString)) {
$foundInOtherForm = $otherFormId;
error_log("CsrfProtection::validateToken - Token found in different form ID: $otherFormId (requested: $formId)");
$this->debugLog('CsrfProtection::validateToken - Token found in different form ID', [
'found_in_form_id' => $otherFormId,
'requested_form_id' => $formId
]);
break;
}
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\FrameworkModule;
use App\Framework\Core\ValueObjects\FrameworkModuleRegistry;
use App\Framework\Core\ValueObjects\PhpNamespace;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('FrameworkModuleRegistry', function () {
it('creates registry with variadic modules', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Database', $basePath),
FrameworkModule::create('Cache', $basePath)
);
expect($registry->count())->toBe(3);
expect($registry->hasModule('Http'))->toBeTrue();
expect($registry->hasModule('Database'))->toBeTrue();
expect($registry->hasModule('Cache'))->toBeTrue();
});
it('creates empty registry', function () {
$registry = new FrameworkModuleRegistry();
expect($registry->count())->toBe(0);
expect($registry->getAllModules())->toBe([]);
});
describe('discover', function () {
it('discovers modules from Framework directory', function () {
// Use actual project path (tests run outside Docker)
$projectRoot = dirname(__DIR__, 5);
$frameworkPath = FilePath::create($projectRoot . '/src/Framework');
$registry = FrameworkModuleRegistry::discover($frameworkPath);
expect($registry->count())->toBeGreaterThan(50);
expect($registry->hasModule('Http'))->toBeTrue();
expect($registry->hasModule('Database'))->toBeTrue();
expect($registry->hasModule('Core'))->toBeTrue();
expect($registry->hasModule('Cache'))->toBeTrue();
});
});
describe('getModuleForNamespace', function () {
beforeEach(function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$this->registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Database', $basePath),
FrameworkModule::create('Cache', $basePath)
);
});
it('finds module for namespace', function () {
$namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth');
$module = $this->registry->getModuleForNamespace($namespace);
expect($module)->not->toBeNull();
expect($module->name)->toBe('Http');
});
it('finds module for root module namespace', function () {
$namespace = PhpNamespace::fromString('App\\Framework\\Database');
$module = $this->registry->getModuleForNamespace($namespace);
expect($module)->not->toBeNull();
expect($module->name)->toBe('Database');
});
it('returns null for non-framework namespace', function () {
$namespace = PhpNamespace::fromString('App\\Domain\\User\\Services');
$module = $this->registry->getModuleForNamespace($namespace);
expect($module)->toBeNull();
});
it('returns null for unknown module', function () {
$namespace = PhpNamespace::fromString('App\\Framework\\UnknownModule\\Something');
$module = $this->registry->getModuleForNamespace($namespace);
expect($module)->toBeNull();
});
it('returns null for bare Framework namespace', function () {
$namespace = PhpNamespace::fromString('App\\Framework');
$module = $this->registry->getModuleForNamespace($namespace);
expect($module)->toBeNull();
});
});
describe('getModuleForClass', function () {
beforeEach(function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$this->registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Core', $basePath)
);
});
it('finds module for class', function () {
$className = ClassName::create('App\\Framework\\Http\\Request');
$module = $this->registry->getModuleForClass($className);
expect($module)->not->toBeNull();
expect($module->name)->toBe('Http');
});
it('finds module for deeply nested class', function () {
$className = ClassName::create('App\\Framework\\Core\\ValueObjects\\PhpNamespace');
$module = $this->registry->getModuleForClass($className);
expect($module)->not->toBeNull();
expect($module->name)->toBe('Core');
});
});
describe('inSameModule', function () {
beforeEach(function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$this->registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Database', $basePath)
);
});
it('returns true for namespaces in same module', function () {
$a = PhpNamespace::fromString('App\\Framework\\Http\\Request');
$b = PhpNamespace::fromString('App\\Framework\\Http\\Response');
expect($this->registry->inSameModule($a, $b))->toBeTrue();
});
it('returns true for deeply nested namespaces in same module', function () {
$a = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth');
$b = PhpNamespace::fromString('App\\Framework\\Http\\ValueObjects\\StatusCode');
expect($this->registry->inSameModule($a, $b))->toBeTrue();
});
it('returns false for namespaces in different modules', function () {
$a = PhpNamespace::fromString('App\\Framework\\Http\\Request');
$b = PhpNamespace::fromString('App\\Framework\\Database\\Connection');
expect($this->registry->inSameModule($a, $b))->toBeFalse();
});
it('returns false when one namespace is not in framework', function () {
$a = PhpNamespace::fromString('App\\Framework\\Http\\Request');
$b = PhpNamespace::fromString('App\\Domain\\User\\UserService');
expect($this->registry->inSameModule($a, $b))->toBeFalse();
});
it('returns false when both namespaces are not in framework', function () {
$a = PhpNamespace::fromString('App\\Domain\\User');
$b = PhpNamespace::fromString('App\\Domain\\Order');
expect($this->registry->inSameModule($a, $b))->toBeFalse();
});
});
describe('classesInSameModule', function () {
beforeEach(function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$this->registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Cache', $basePath)
);
});
it('returns true for classes in same module', function () {
$a = ClassName::create('App\\Framework\\Http\\Request');
$b = ClassName::create('App\\Framework\\Http\\Response');
expect($this->registry->classesInSameModule($a, $b))->toBeTrue();
});
it('returns false for classes in different modules', function () {
$a = ClassName::create('App\\Framework\\Http\\Request');
$b = ClassName::create('App\\Framework\\Cache\\CacheItem');
expect($this->registry->classesInSameModule($a, $b))->toBeFalse();
});
});
describe('getModule', function () {
it('returns module by name', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath)
);
$module = $registry->getModule('Http');
expect($module)->not->toBeNull();
expect($module->name)->toBe('Http');
});
it('returns null for unknown module', function () {
$registry = new FrameworkModuleRegistry();
expect($registry->getModule('Unknown'))->toBeNull();
});
});
describe('getModuleNames', function () {
it('returns all module names', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$registry = new FrameworkModuleRegistry(
FrameworkModule::create('Http', $basePath),
FrameworkModule::create('Database', $basePath),
FrameworkModule::create('Cache', $basePath)
);
$names = $registry->getModuleNames();
expect($names)->toContain('Http');
expect($names)->toContain('Database');
expect($names)->toContain('Cache');
expect($names)->toHaveCount(3);
});
});
});

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\FrameworkModule;
use App\Framework\Core\ValueObjects\PhpNamespace;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('FrameworkModule', function () {
it('creates module with valid name', function () {
$path = FilePath::create('/var/www/html/src/Framework/Http');
$module = new FrameworkModule('Http', $path);
expect($module->name)->toBe('Http');
expect($module->path->toString())->toBe('/var/www/html/src/Framework/Http');
expect($module->namespace->toString())->toBe('App\\Framework\\Http');
});
it('creates module via factory method', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Database', $basePath);
expect($module->name)->toBe('Database');
expect($module->path->toString())->toBe('/var/www/html/src/Framework/Database');
});
it('rejects empty module name', function () {
$path = FilePath::create('/var/www/html/src/Framework/Empty');
new FrameworkModule('', $path);
})->throws(InvalidArgumentException::class, 'Module name cannot be empty');
it('rejects lowercase module name', function () {
$path = FilePath::create('/var/www/html/src/Framework/http');
new FrameworkModule('http', $path);
})->throws(InvalidArgumentException::class, 'Must be PascalCase');
it('rejects module name with invalid characters', function () {
$path = FilePath::create('/var/www/html/src/Framework/Http-Client');
new FrameworkModule('Http-Client', $path);
})->throws(InvalidArgumentException::class, 'Must be PascalCase');
describe('containsNamespace', function () {
it('returns true for namespace within module', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares');
expect($module->containsNamespace($namespace))->toBeTrue();
});
it('returns true for module root namespace', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$namespace = PhpNamespace::fromString('App\\Framework\\Http');
expect($module->containsNamespace($namespace))->toBeTrue();
});
it('returns false for namespace in different module', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$namespace = PhpNamespace::fromString('App\\Framework\\Database\\Query');
expect($module->containsNamespace($namespace))->toBeFalse();
});
it('returns false for non-framework namespace', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$namespace = PhpNamespace::fromString('App\\Domain\\User');
expect($module->containsNamespace($namespace))->toBeFalse();
});
});
describe('containsClass', function () {
it('returns true for class within module', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$className = ClassName::create('App\\Framework\\Http\\Request');
expect($module->containsClass($className))->toBeTrue();
});
it('returns false for class in different module', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$className = ClassName::create('App\\Framework\\Cache\\CacheItem');
expect($module->containsClass($className))->toBeFalse();
});
});
describe('getRelativeNamespace', function () {
it('returns relative namespace within module', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$namespace = PhpNamespace::fromString('App\\Framework\\Http\\Middlewares\\Auth');
$relative = $module->getRelativeNamespace($namespace);
expect($relative)->not->toBeNull();
expect($relative->toString())->toBe('Middlewares\\Auth');
});
it('returns global namespace for module root', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$namespace = PhpNamespace::fromString('App\\Framework\\Http');
$relative = $module->getRelativeNamespace($namespace);
expect($relative)->not->toBeNull();
expect($relative->isGlobal())->toBeTrue();
});
it('returns null for namespace not in module', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
$namespace = PhpNamespace::fromString('App\\Framework\\Database');
$relative = $module->getRelativeNamespace($namespace);
expect($relative)->toBeNull();
});
});
describe('equals', function () {
it('returns true for modules with same name', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module1 = FrameworkModule::create('Http', $basePath);
$module2 = FrameworkModule::create('Http', $basePath);
expect($module1->equals($module2))->toBeTrue();
});
it('returns false for modules with different names', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module1 = FrameworkModule::create('Http', $basePath);
$module2 = FrameworkModule::create('Cache', $basePath);
expect($module1->equals($module2))->toBeFalse();
});
});
it('converts to string', function () {
$basePath = FilePath::create('/var/www/html/src/Framework');
$module = FrameworkModule::create('Http', $basePath);
expect((string) $module)->toBe('Http');
expect($module->toString())->toBe('Http');
});
});

View File

@@ -4,13 +4,187 @@ declare(strict_types=1);
namespace Tests\Integration;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheIdentifier;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheResult;
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Core\PathProvider;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DI\DefaultContainer;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\ErrorHandlingConfig;
use App\Framework\ExceptionHandling\ErrorKernel;
use App\Framework\ExceptionHandling\ErrorRendererFactory;
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
use App\Framework\Http\Status;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceConfig;
use App\Framework\Performance\PerformanceMetric;
use App\Framework\Performance\PerformanceService;
use App\Framework\Serialization\Serializer;
use App\Framework\View\Engine;
use App\Framework\Context\ExecutionContext;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\TemplateProcessor;
use Mockery;
use RuntimeException;
/**
* Null performance collector for testing (no-op implementation)
*/
class TestPerformanceCollector implements PerformanceCollectorInterface
{
public function startTiming(string $key, PerformanceCategory $category, array $context = []): void {}
public function endTiming(string $key): void {}
public function measure(string $key, PerformanceCategory $category, callable $callback, array $context = []): mixed
{
return $callback();
}
public function recordMetric(string $key, PerformanceCategory $category, float $value, array $context = []): void {}
public function increment(string $key, PerformanceCategory $category, int $amount = 1, array $context = []): void {}
public function getMetrics(?PerformanceCategory $category = null): array { return []; }
public function getMetric(string $key): ?PerformanceMetric { return null; }
public function getTotalRequestTime(): float { return 0.0; }
public function getTotalRequestMemory(): int { return 0; }
public function getPeakMemory(): int { return 0; }
public function reset(): void {}
public function isEnabled(): bool { return false; }
public function setEnabled(bool $enabled): void {}
}
/**
* Simple cache wrapper for testing - adapts InMemoryCache to Cache interface
*/
class SimpleCacheWrapper implements Cache
{
public function __construct(private InMemoryCache $driver) {}
public function get(CacheIdentifier ...$identifiers): CacheResult
{
$keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey);
return $this->driver->get(...$keys);
}
public function set(CacheItem ...$items): bool
{
return $this->driver->set(...$items);
}
public function has(CacheIdentifier ...$identifiers): array
{
$keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey);
return $this->driver->has(...$keys);
}
public function forget(CacheIdentifier ...$identifiers): bool
{
$keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey);
return $this->driver->forget(...$keys);
}
public function clear(): bool
{
return $this->driver->clear();
}
public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
{
$result = $this->driver->get($key);
$item = $result->getItem($key);
if ($item->isHit) {
return $item;
}
$value = $callback();
$newItem = CacheItem::forSet($key, $value, $ttl);
$this->driver->set($newItem);
return CacheItem::hit($key, $value);
}
}
/**
* Helper functions to create test dependencies
* Following the dependency chain: TemplateLoader → Engine → ErrorRendererFactory → ErrorKernel
*/
function createTestEngine(): Engine
{
$projectRoot = dirname(__DIR__, 2);
$pathProvider = new PathProvider($projectRoot);
$cache = new SimpleCacheWrapper(new InMemoryCache());
$templateLoader = new TemplateLoader(
pathProvider: $pathProvider,
cache: $cache,
discoveryRegistry: null,
templates: [],
templatePath: '/src/Framework/ExceptionHandling/Templates',
useMtimeInvalidation: false,
cacheEnabled: false,
);
$performanceCollector = new TestPerformanceCollector();
$performanceConfig = new PerformanceConfig(enabled: false);
$performanceService = new PerformanceService(
collector: $performanceCollector,
config: $performanceConfig,
);
$container = new DefaultContainer();
$templateProcessor = new TemplateProcessor(
astTransformers: [],
stringProcessors: [],
container: $container,
);
return new Engine(
loader: $templateLoader,
performanceService: $performanceService,
processor: $templateProcessor,
cache: $cache,
cacheEnabled: false,
);
}
function createTestErrorRendererFactory(?bool $isDebugMode = null): ErrorRendererFactory
{
$executionContext = ExecutionContext::forWeb();
$engine = createTestEngine();
$config = $isDebugMode !== null
? new ErrorHandlingConfig(isDebugMode: $isDebugMode)
: null;
return new ErrorRendererFactory(
executionContext: $executionContext,
engine: $engine,
consoleOutput: null,
config: $config,
);
}
function createTestErrorKernel(): ErrorKernel
{
$rendererFactory = createTestErrorRendererFactory();
return new ErrorKernel(
rendererFactory: $rendererFactory,
reporter: null,
);
}
function createTestResponseErrorRenderer(bool $isDebugMode = false): ResponseErrorRenderer
{
$engine = createTestEngine();
return new ResponseErrorRenderer(
engine: $engine,
isDebugMode: $isDebugMode,
);
}
/**
* Integration tests for unified ExceptionHandling module
*
@@ -32,7 +206,7 @@ describe('ErrorKernel HTTP Response Generation', function () {
});
it('creates JSON API error response without context', function () {
$errorKernel = new ErrorKernel();
$errorKernel = createTestErrorKernel();
$exception = new RuntimeException('Test API error', 500);
$response = $errorKernel->createHttpResponse($exception, null, isDebugMode: false);
@@ -47,7 +221,7 @@ describe('ErrorKernel HTTP Response Generation', function () {
});
it('creates JSON API error response with debug mode', function () {
$errorKernel = new ErrorKernel();
$errorKernel = createTestErrorKernel();
$exception = new RuntimeException('Database connection failed', 500);
$response = $errorKernel->createHttpResponse($exception, null, isDebugMode: true);
@@ -63,7 +237,7 @@ describe('ErrorKernel HTTP Response Generation', function () {
});
it('creates JSON API error response with WeakMap context', function () {
$errorKernel = new ErrorKernel();
$errorKernel = createTestErrorKernel();
$contextProvider = new ExceptionContextProvider();
$exception = new RuntimeException('User operation failed', 500);
@@ -100,10 +274,10 @@ describe('ResponseErrorRenderer', function () {
});
it('detects API requests correctly', function () {
$renderer = new ResponseErrorRenderer(isDebugMode: false);
$renderer = createTestResponseErrorRenderer(isDebugMode: false);
$exception = new RuntimeException('Test error');
$response = $renderer->createResponse($exception, null);
$response = $renderer->render($exception, null);
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
});
@@ -113,10 +287,10 @@ describe('ResponseErrorRenderer', function () {
$_SERVER['HTTP_ACCEPT'] = 'text/html';
$_SERVER['REQUEST_URI'] = '/web/page';
$renderer = new ResponseErrorRenderer(isDebugMode: false);
$renderer = createTestResponseErrorRenderer(isDebugMode: false);
$exception = new RuntimeException('Page error');
$response = $renderer->createResponse($exception, null);
$response = $renderer->render($exception, null);
expect($response->headers->getFirst('Content-Type'))->toBe('text/html; charset=utf-8');
expect($response->body)->toContain('<!DOCTYPE html>');
@@ -127,7 +301,7 @@ describe('ResponseErrorRenderer', function () {
$_SERVER['HTTP_ACCEPT'] = 'text/html';
$_SERVER['REQUEST_URI'] = '/web/page';
$renderer = new ResponseErrorRenderer(isDebugMode: true);
$renderer = createTestResponseErrorRenderer(isDebugMode: true);
$contextProvider = new ExceptionContextProvider();
$exception = new RuntimeException('Debug test error');
@@ -139,7 +313,7 @@ describe('ResponseErrorRenderer', function () {
);
$contextProvider->attach($exception, $contextData);
$response = $renderer->createResponse($exception, $contextProvider);
$response = $renderer->render($exception, $contextProvider);
expect($response->body)->toContain('Debug Information');
expect($response->body)->toContain('page.render');
@@ -148,21 +322,21 @@ describe('ResponseErrorRenderer', function () {
});
it('maps exception types to HTTP status codes correctly', function () {
$renderer = new ResponseErrorRenderer();
$renderer = createTestResponseErrorRenderer();
// InvalidArgumentException → 400
$exception = new \InvalidArgumentException('Invalid input');
$response = $renderer->createResponse($exception, null);
$response = $renderer->render($exception, null);
expect($response->status)->toBe(Status::BAD_REQUEST);
// RuntimeException → 500
$exception = new RuntimeException('Runtime error');
$response = $renderer->createResponse($exception, null);
$response = $renderer->render($exception, null);
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
// Custom code in valid range
$exception = new RuntimeException('Not found', 404);
$response = $renderer->createResponse($exception, null);
$response = $renderer->render($exception, null);
expect($response->status)->toBe(Status::NOT_FOUND);
});
});
@@ -297,7 +471,7 @@ describe('End-to-end integration scenario', function () {
it('demonstrates full exception handling flow with context enrichment', function () {
// Setup
$errorKernel = new ErrorKernel();
$errorKernel = createTestErrorKernel();
$contextProvider = new ExceptionContextProvider();
// 1. Exception occurs in service layer