Compare commits
25 Commits
77abc65cd7
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| c93d3f07a2 | |||
| 386baff65f | |||
| 7f7029ae2a | |||
| 22fd89b013 | |||
| 85e2360a90 | |||
| 7785e65d08 | |||
| 520d082393 | |||
| f9063aa151 | |||
| 4309ea7972 | |||
| 26f87060d5 | |||
| dd7cfd97e6 | |||
| 57eabe30a5 | |||
| 77505edabf | |||
| 68a59f460f | |||
| 2d762eafdf | |||
| 760690549d | |||
| 417c7d7a7d | |||
| 5e74ce73a6 | |||
| 6c266861ec | |||
| 1f93377ded | |||
| 5c36517046 | |||
| 4d0328bfe3 | |||
| 4cadd7ce1c | |||
| abe68af124 | |||
| a0762623bc |
@@ -9,10 +9,19 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
runs-on: php-ci
|
||||
steps:
|
||||
# Manual checkout - works without Node.js
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
run: |
|
||||
echo "📥 Checking out repository..."
|
||||
if [ -d ".git" ]; then
|
||||
git fetch origin
|
||||
git checkout ${{ github.ref_name }}
|
||||
git reset --hard origin/${{ github.ref_name }}
|
||||
else
|
||||
git clone --branch ${{ github.ref_name }} --single-branch ${{ github.server_url }}/${{ github.repository }}.git .
|
||||
fi
|
||||
|
||||
- name: Determine environment
|
||||
id: env
|
||||
@@ -27,14 +36,18 @@ jobs:
|
||||
|
||||
- name: Deploy to server
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$SSH_KEY" > /tmp/ssh_key
|
||||
# Validate required secret
|
||||
if [ -z "$SSH_PRIVATE_KEY" ]; then
|
||||
echo "❌ Missing required secret: SSH_PRIVATE_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$SSH_PRIVATE_KEY" > /tmp/ssh_key
|
||||
chmod 600 /tmp/ssh_key
|
||||
|
||||
ssh -i /tmp/ssh_key -o StrictHostKeyChecking=no $SSH_USER@$SSH_HOST << EOF
|
||||
|
||||
ssh -i /tmp/ssh_key -o StrictHostKeyChecking=no deploy@94.16.110.151 << EOF
|
||||
set -e
|
||||
cd /home/deploy/michaelschiemer/current
|
||||
|
||||
@@ -42,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
|
||||
|
||||
57
.gitea/workflows/test-runner.yml
Normal file
57
.gitea/workflows/test-runner.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Test Runner
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- staging
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test-basic:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
# Manual checkout - works without Node.js
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "📥 Checking out repository..."
|
||||
if [ -d ".git" ]; then
|
||||
git fetch origin
|
||||
git checkout ${{ github.ref_name }}
|
||||
git reset --hard origin/${{ github.ref_name }}
|
||||
else
|
||||
git clone --branch ${{ github.ref_name }} --single-branch ${{ github.server_url }}/${{ github.repository }}.git .
|
||||
fi
|
||||
|
||||
- name: Test basic runner
|
||||
run: |
|
||||
echo "✅ Runner is working!"
|
||||
echo "Runner OS: $(uname -a)"
|
||||
echo "Docker version: $(docker --version || echo 'Docker not available')"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git branch: $(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "Git commit: $(git rev-parse --short HEAD)"
|
||||
|
||||
test-php:
|
||||
runs-on: php-ci
|
||||
steps:
|
||||
# Manual checkout - works without Node.js
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "📥 Checking out repository..."
|
||||
if [ -d ".git" ]; then
|
||||
git fetch origin
|
||||
git checkout ${{ github.ref_name }}
|
||||
git reset --hard origin/${{ github.ref_name }}
|
||||
else
|
||||
git clone --branch ${{ github.ref_name }} --single-branch ${{ github.server_url }}/${{ github.repository }}.git .
|
||||
fi
|
||||
|
||||
- name: Test PHP environment
|
||||
run: |
|
||||
echo "✅ PHP Runner is working!"
|
||||
php -v
|
||||
composer --version
|
||||
echo "PHP Extensions:"
|
||||
php -m | grep -E "(pdo|redis|zip|gd|mbstring)" || echo "Some extensions not found"
|
||||
|
||||
@@ -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"}}
|
||||
@@ -117,9 +117,28 @@ fi
|
||||
print_info "Pulling latest images..."
|
||||
docker compose $COMPOSE_FILES pull || print_warning "Failed to pull some images, continuing..."
|
||||
|
||||
# Stop and remove existing containers to prevent name conflicts
|
||||
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
|
||||
print_warning "Removing orphaned container: $container"
|
||||
docker rm -f "$container" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Deploy stack
|
||||
print_info "Deploying application stack..."
|
||||
docker compose $COMPOSE_FILES up -d
|
||||
docker compose $COMPOSE_FILES up -d --force-recreate --remove-orphans
|
||||
|
||||
# Wait for services to be healthy
|
||||
print_info "Waiting for services to be healthy..."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -240,21 +261,46 @@ services:
|
||||
find /var/www/html.orig -mindepth 1 -maxdepth 1 ! -name "storage" -exec cp -r {} "$$GIT_TARGET_DIR/" \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Fix nginx upstream configuration - sites-enabled/default overrides conf.d/default.conf
|
||||
# This is critical: nginx sites-available/default uses 127.0.0.1:9000 but PHP-FPM runs in php container
|
||||
if [ -f "/etc/nginx/sites-available/default" ]; then
|
||||
echo "🔧 [staging-nginx] Fixing PHP-FPM upstream configuration..."
|
||||
# Replace in upstream block - use php container name (as defined in docker-compose.staging.yml)
|
||||
sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server php:9000;|g' /etc/nginx/sites-available/default || true
|
||||
sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server php:9000;|g' /etc/nginx/sites-available/default || true
|
||||
# Replace any auto-generated container names (like 5aad84af7c9e_php)
|
||||
sed -i '/upstream php-upstream {/,/}/s|server [a-f0-9_]*php:9000;|server php:9000;|g' /etc/nginx/sites-available/default || true
|
||||
# Replace any direct fastcgi_pass references too
|
||||
sed -i 's|fastcgi_pass 127.0.0.1:9000;|fastcgi_pass php-upstream;|g' /etc/nginx/sites-available/default || true
|
||||
sed -i 's|fastcgi_pass localhost:9000;|fastcgi_pass php-upstream;|g' /etc/nginx/sites-available/default || true
|
||||
echo "✅ [staging-nginx] PHP-FPM upstream fixed"
|
||||
fi
|
||||
# Fix nginx upstream configuration - sites-enabled/default is a symlink to sites-available/default
|
||||
# This is critical: nginx config uses production-php:9000 but staging uses php container
|
||||
for NGINX_CONF in /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default; do
|
||||
if [ -f "$$NGINX_CONF" ]; then
|
||||
echo "🔧 [staging-nginx] Fixing PHP-FPM upstream in $$NGINX_CONF..."
|
||||
# Replace production-php with staging php container name
|
||||
sed -i 's|server production-php:9000;|server php:9000;|g' "$$NGINX_CONF" || true
|
||||
# Replace localhost/127.0.0.1 references
|
||||
sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server php:9000;|g' "$$NGINX_CONF" || true
|
||||
sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server php:9000;|g' "$$NGINX_CONF" || true
|
||||
# Replace any auto-generated container names (like 5aad84af7c9e_php)
|
||||
sed -i 's|server [a-zA-Z0-9_-]*php:9000;|server php:9000;|g' "$$NGINX_CONF" || true
|
||||
# Replace any direct fastcgi_pass references too
|
||||
sed -i 's|fastcgi_pass 127.0.0.1:9000;|fastcgi_pass php-upstream;|g' "$$NGINX_CONF" || true
|
||||
sed -i 's|fastcgi_pass localhost:9000;|fastcgi_pass php-upstream;|g' "$$NGINX_CONF" || true
|
||||
sed -i 's|fastcgi_pass production-php:9000;|fastcgi_pass php-upstream;|g' "$$NGINX_CONF" || true
|
||||
echo "✅ [staging-nginx] PHP-FPM upstream fixed in $$NGINX_CONF"
|
||||
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;"
|
||||
@@ -270,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
|
||||
@@ -341,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
|
||||
@@ -350,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
|
||||
@@ -366,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
|
||||
@@ -396,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
|
||||
@@ -405,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
|
||||
@@ -415,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
|
||||
|
||||
@@ -9,6 +9,7 @@ RUN apk add --no-cache \
|
||||
certbot-nginx \
|
||||
su-exec \
|
||||
netcat-openbsd \
|
||||
curl \
|
||||
openssl \
|
||||
bash
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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') ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -330,8 +330,9 @@ final readonly class MethodSignatureAnalyzer
|
||||
$returnType = $method->getReturnType();
|
||||
if ($returnType instanceof ReflectionNamedType) {
|
||||
$returnTypeName = $returnType->getName();
|
||||
// Accept: int, ExitCode, ActionResult, or array
|
||||
// Accept: void, int, ExitCode, ActionResult, or array
|
||||
$validReturnTypes = [
|
||||
'void',
|
||||
'int',
|
||||
ExitCode::class,
|
||||
'App\Framework\MagicLinks\Actions\ActionResult',
|
||||
|
||||
116
src/Framework/Core/ValueObjects/FrameworkModule.php
Normal file
116
src/Framework/Core/ValueObjects/FrameworkModule.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Core\ValueObjects;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
use InvalidArgumentException;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* Immutable value object representing a Framework module
|
||||
*
|
||||
* A module is a top-level directory within src/Framework/ that groups
|
||||
* related functionality (e.g., Http, Database, Cache, Queue).
|
||||
*/
|
||||
final readonly class FrameworkModule implements Stringable
|
||||
{
|
||||
private const string FRAMEWORK_NAMESPACE_PREFIX = 'App\\Framework\\';
|
||||
|
||||
public PhpNamespace $namespace;
|
||||
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public FilePath $path
|
||||
) {
|
||||
if (empty($name)) {
|
||||
throw new InvalidArgumentException('Module name cannot be empty');
|
||||
}
|
||||
|
||||
if (! preg_match('/^[A-Z][a-zA-Z0-9]*$/', $name)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Invalid module name: {$name}. Must be PascalCase starting with uppercase letter."
|
||||
);
|
||||
}
|
||||
|
||||
$this->namespace = PhpNamespace::fromString(self::FRAMEWORK_NAMESPACE_PREFIX . $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create module from name and base path
|
||||
*/
|
||||
public static function create(string $name, FilePath $frameworkBasePath): self
|
||||
{
|
||||
$modulePath = $frameworkBasePath->join($name);
|
||||
|
||||
return new self($name, $modulePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a namespace belongs to this module
|
||||
*/
|
||||
public function containsNamespace(PhpNamespace $namespace): bool
|
||||
{
|
||||
return $namespace->startsWith($this->namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a class belongs to this module
|
||||
*/
|
||||
public function containsClass(ClassName $className): bool
|
||||
{
|
||||
return $this->containsNamespace($className->getNamespaceObject());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path belongs to this module
|
||||
*/
|
||||
public function containsFile(FilePath $filePath): bool
|
||||
{
|
||||
return $this->path->contains($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relative namespace within this module
|
||||
*
|
||||
* Example: For Http module and namespace App\Framework\Http\Middlewares\Auth
|
||||
* Returns: Middlewares\Auth
|
||||
*/
|
||||
public function getRelativeNamespace(PhpNamespace $namespace): ?PhpNamespace
|
||||
{
|
||||
if (! $this->containsNamespace($namespace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$moduleParts = $this->namespace->parts();
|
||||
$namespaceParts = $namespace->parts();
|
||||
|
||||
// Remove module prefix parts
|
||||
$relativeParts = array_slice($namespaceParts, count($moduleParts));
|
||||
|
||||
if (empty($relativeParts)) {
|
||||
return PhpNamespace::global();
|
||||
}
|
||||
|
||||
return PhpNamespace::fromParts($relativeParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare for equality
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->name === $other->name;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
208
src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php
Normal file
208
src/Framework/Core/ValueObjects/FrameworkModuleRegistry.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Core\ValueObjects;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Registry of all Framework modules with lookup capabilities
|
||||
*
|
||||
* Provides efficient lookup of which module a namespace, class, or file belongs to.
|
||||
* Modules are discovered lazily from the Framework directory structure.
|
||||
*/
|
||||
final readonly class FrameworkModuleRegistry
|
||||
{
|
||||
private const string FRAMEWORK_NAMESPACE_PREFIX = 'App\\Framework\\';
|
||||
|
||||
/** @var array<string, FrameworkModule> Module name => Module */
|
||||
private array $modules;
|
||||
|
||||
public function __construct(FrameworkModule ...$modules)
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($modules as $module) {
|
||||
$indexed[$module->name] = $module;
|
||||
}
|
||||
|
||||
$this->modules = $indexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create registry by scanning Framework directory
|
||||
*/
|
||||
public static function discover(FilePath $frameworkPath): self
|
||||
{
|
||||
if (! $frameworkPath->isDirectory()) {
|
||||
throw new InvalidArgumentException(
|
||||
"Framework path does not exist or is not a directory: {$frameworkPath}"
|
||||
);
|
||||
}
|
||||
|
||||
$modules = [];
|
||||
$entries = scandir($frameworkPath->toString());
|
||||
|
||||
if ($entries === false) {
|
||||
throw new InvalidArgumentException(
|
||||
"Cannot read Framework directory: {$frameworkPath}"
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
// Skip hidden files and special entries
|
||||
if ($entry === '.' || $entry === '..' || str_starts_with($entry, '.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entryPath = $frameworkPath->join($entry);
|
||||
|
||||
// Only consider directories as modules
|
||||
if (! $entryPath->isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Module names must be PascalCase
|
||||
if (! preg_match('/^[A-Z][a-zA-Z0-9]*$/', $entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$modules[] = FrameworkModule::create($entry, $frameworkPath);
|
||||
}
|
||||
|
||||
return new self(...$modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module for a PHP namespace
|
||||
*/
|
||||
public function getModuleForNamespace(PhpNamespace $namespace): ?FrameworkModule
|
||||
{
|
||||
$namespaceStr = $namespace->toString();
|
||||
|
||||
// Must be a Framework namespace
|
||||
if (! str_starts_with($namespaceStr, self::FRAMEWORK_NAMESPACE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract module name (first part after App\Framework\)
|
||||
$afterPrefix = substr($namespaceStr, strlen(self::FRAMEWORK_NAMESPACE_PREFIX));
|
||||
$parts = explode('\\', $afterPrefix);
|
||||
$moduleName = $parts[0] ?? null;
|
||||
|
||||
if ($moduleName === null || $moduleName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->modules[$moduleName] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module for a class name
|
||||
*/
|
||||
public function getModuleForClass(ClassName $className): ?FrameworkModule
|
||||
{
|
||||
return $this->getModuleForNamespace($className->getNamespaceObject());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module for a file path
|
||||
*/
|
||||
public function getModuleForFile(FilePath $filePath): ?FrameworkModule
|
||||
{
|
||||
foreach ($this->modules as $module) {
|
||||
if ($module->containsFile($filePath)) {
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two namespaces belong to the same module
|
||||
*/
|
||||
public function inSameModule(PhpNamespace $a, PhpNamespace $b): bool
|
||||
{
|
||||
$moduleA = $this->getModuleForNamespace($a);
|
||||
$moduleB = $this->getModuleForNamespace($b);
|
||||
|
||||
if ($moduleA === null || $moduleB === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $moduleA->equals($moduleB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two classes belong to the same module
|
||||
*/
|
||||
public function classesInSameModule(ClassName $a, ClassName $b): bool
|
||||
{
|
||||
return $this->inSameModule(
|
||||
$a->getNamespaceObject(),
|
||||
$b->getNamespaceObject()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two files belong to the same module
|
||||
*/
|
||||
public function filesInSameModule(FilePath $a, FilePath $b): bool
|
||||
{
|
||||
$moduleA = $this->getModuleForFile($a);
|
||||
$moduleB = $this->getModuleForFile($b);
|
||||
|
||||
if ($moduleA === null || $moduleB === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $moduleA->equals($moduleB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific module by name
|
||||
*/
|
||||
public function getModule(string $name): ?FrameworkModule
|
||||
{
|
||||
return $this->modules[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module exists
|
||||
*/
|
||||
public function hasModule(string $name): bool
|
||||
{
|
||||
return isset($this->modules[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all modules
|
||||
*
|
||||
* @return array<string, FrameworkModule>
|
||||
*/
|
||||
public function getAllModules(): array
|
||||
{
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all module names
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getModuleNames(): array
|
||||
{
|
||||
return array_keys($this->modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module count
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->modules);
|
||||
}
|
||||
}
|
||||
@@ -47,4 +47,3 @@ final readonly class CreateSeedsTable implements Migration, SafelyReversible
|
||||
return 'Create seeds table for tracking executed seeders';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,4 +61,3 @@ final readonly class SeedLoader
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,4 +36,3 @@ interface Seeder
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,4 +20,3 @@ final readonly class DiscoveryWarning
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,4 +101,3 @@ final class DiscoveryWarningAggregator
|
||||
return $this->warningsByFile !== [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,3 @@ final readonly class DiscoveryWarningGroup
|
||||
return count($this->warnings);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Framework\ExceptionHandling\Audit;
|
||||
|
||||
use App\Framework\Audit\AuditLogger;
|
||||
use App\Framework\Audit\ValueObjects\AuditEntry;
|
||||
use App\Framework\Audit\ValueObjects\AuditableAction;
|
||||
use App\Framework\Audit\ValueObjects\AuditEntry;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
@@ -44,7 +44,7 @@ final readonly class ExceptionAuditLogger
|
||||
$context = $context ?? $this->getContext($exception);
|
||||
|
||||
// Skip if not auditable
|
||||
if (!$this->isAuditable($context)) {
|
||||
if (! $this->isAuditable($context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ final readonly class ExceptionAuditLogger
|
||||
// Remove common suffixes
|
||||
$component = $context->component;
|
||||
$component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component);
|
||||
|
||||
return strtolower($component);
|
||||
}
|
||||
|
||||
@@ -246,7 +247,7 @@ final readonly class ExceptionAuditLogger
|
||||
];
|
||||
|
||||
// Add context data
|
||||
if (!empty($context->data)) {
|
||||
if (! empty($context->data)) {
|
||||
$metadata['context_data'] = $context->data;
|
||||
}
|
||||
|
||||
@@ -267,7 +268,7 @@ final readonly class ExceptionAuditLogger
|
||||
}
|
||||
|
||||
// Add tags
|
||||
if (!empty($context->tags)) {
|
||||
if (! empty($context->tags)) {
|
||||
$metadata['tags'] = $context->tags;
|
||||
}
|
||||
|
||||
@@ -283,7 +284,7 @@ final readonly class ExceptionAuditLogger
|
||||
// Merge with existing metadata (but exclude internal fields)
|
||||
$excludeKeys = ['auditable', 'audit_action', 'entity_type'];
|
||||
foreach ($context->metadata as $key => $value) {
|
||||
if (!in_array($key, $excludeKeys, true)) {
|
||||
if (! in_array($key, $excludeKeys, true)) {
|
||||
$metadata[$key] = $value;
|
||||
}
|
||||
}
|
||||
@@ -306,4 +307,3 @@ final readonly class ExceptionAuditLogger
|
||||
return $this->contextProvider->get($exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -32,4 +33,3 @@ final readonly class BasicErrorHandler implements ErrorHandlerInterface
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -31,4 +32,3 @@ final readonly class BasicGlobalExceptionHandler implements ExceptionHandler
|
||||
file_put_contents('php://stderr', $errorOutput);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -16,7 +17,7 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
|
||||
{
|
||||
$last = error_get_last();
|
||||
|
||||
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
|
||||
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,4 @@ final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ final readonly class ExceptionContextBuilder
|
||||
if ($baseContext !== null) {
|
||||
return $this->mergeContexts($cached, $baseContext);
|
||||
}
|
||||
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
@@ -109,6 +110,7 @@ final readonly class ExceptionContextBuilder
|
||||
// Extract session ID
|
||||
if (property_exists($request, 'session') && $request->session !== null) {
|
||||
$sessionId = $request->session->id->toString();
|
||||
|
||||
try {
|
||||
$context = $context->withSessionId(SessionId::fromString($sessionId));
|
||||
} catch (\InvalidArgumentException) {
|
||||
@@ -162,13 +164,13 @@ final readonly class ExceptionContextBuilder
|
||||
if ($base->component !== null && $merged->component === null) {
|
||||
$merged = $merged->withOperation($merged->operation ?? '', $base->component);
|
||||
}
|
||||
if (!empty($base->data)) {
|
||||
if (! empty($base->data)) {
|
||||
$merged = $merged->addData($base->data);
|
||||
}
|
||||
if (!empty($base->debug)) {
|
||||
if (! empty($base->debug)) {
|
||||
$merged = $merged->addDebug($base->debug);
|
||||
}
|
||||
if (!empty($base->metadata)) {
|
||||
if (! empty($base->metadata)) {
|
||||
$merged = $merged->addMetadata($base->metadata);
|
||||
}
|
||||
if ($base->userId !== null) {
|
||||
@@ -186,7 +188,7 @@ final readonly class ExceptionContextBuilder
|
||||
if ($base->userAgent !== null) {
|
||||
$merged = $merged->withUserAgent($base->userAgent);
|
||||
}
|
||||
if (!empty($base->tags)) {
|
||||
if (! empty($base->tags)) {
|
||||
$merged = $merged->withTags(...array_merge($merged->tags, $base->tags));
|
||||
}
|
||||
|
||||
@@ -261,7 +263,7 @@ final readonly class ExceptionContextBuilder
|
||||
]);
|
||||
|
||||
// Add scope tags
|
||||
if (!empty($scopeContext->tags)) {
|
||||
if (! empty($scopeContext->tags)) {
|
||||
$context = $context->withTags(...$scopeContext->tags);
|
||||
}
|
||||
|
||||
@@ -299,4 +301,3 @@ final readonly class ExceptionContextBuilder
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ final readonly class ExceptionContextCache
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -210,4 +210,3 @@ final readonly class ExceptionContextCache
|
||||
$this->cache->set($cacheItem);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,4 +37,3 @@ final readonly class ExceptionCorrelation
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,13 @@ final readonly class ExceptionCorrelationEngine
|
||||
// Prefer Request-ID, then Session-ID, then User-ID
|
||||
if ($context->requestId !== null) {
|
||||
$requestId = is_string($context->requestId) ? $context->requestId : $context->requestId->toString();
|
||||
|
||||
return 'request:' . $requestId;
|
||||
}
|
||||
|
||||
if ($context->sessionId !== null) {
|
||||
$sessionId = is_string($context->sessionId) ? $context->sessionId : $context->sessionId->toString();
|
||||
|
||||
return 'session:' . $sessionId;
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ final readonly class ExceptionCorrelationEngine
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return new ExceptionCorrelation(correlationKey: '');
|
||||
}
|
||||
|
||||
@@ -128,4 +130,3 @@ final readonly class ExceptionCorrelationEngine
|
||||
$this->cache->set($cacheItem);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
use App\Framework\Core\ValueObjects\LineNumber;
|
||||
use ErrorException;
|
||||
|
||||
final readonly class ErrorContext
|
||||
{
|
||||
@@ -14,7 +14,8 @@ final readonly class ErrorContext
|
||||
public ?string $file = null,
|
||||
public ?LineNumber $line = null,
|
||||
public bool $isSuppressed = false,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(
|
||||
int $severity,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
enum ErrorDecision
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -9,9 +10,9 @@ use ErrorException;
|
||||
final readonly class ErrorHandler implements ErrorHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy,
|
||||
) {}
|
||||
|
||||
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy(),
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ErrorException
|
||||
@@ -42,6 +43,6 @@ final readonly class ErrorHandler implements ErrorHandlerInterface
|
||||
|
||||
private function isSuppressed($severity): bool
|
||||
{
|
||||
return !(error_reporting() & $severity);
|
||||
return ! (error_reporting() & $severity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
interface ErrorHandlerInterface
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
declare(strict_types=1);
|
||||
|
||||
use ErrorException;
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
interface ErrorHandlerStrategy
|
||||
{
|
||||
|
||||
72
src/Framework/ExceptionHandling/ErrorHandlingConfig.php
Normal file
72
src/Framework/ExceptionHandling/ErrorHandlingConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
final class ErrorScopeContext
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
@@ -85,4 +85,3 @@ enum ErrorSeverityType: int
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
use Throwable;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
@@ -17,7 +17,8 @@ final readonly class ExceptionHandlerManagerFactory
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and register ExceptionHandlerManager for current execution context
|
||||
@@ -62,4 +63,3 @@ final readonly class ExceptionHandlerManagerFactory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ final readonly class ExceptionFactory
|
||||
public function __construct(
|
||||
private ExceptionContextProvider $contextProvider,
|
||||
private ErrorScope $errorScope
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception with context
|
||||
@@ -300,7 +301,7 @@ final readonly class ExceptionFactory
|
||||
}
|
||||
|
||||
// Add scope tags
|
||||
if (!empty($scopeContext->tags)) {
|
||||
if (! empty($scopeContext->tags)) {
|
||||
$enriched = $enriched->withTags(...$scopeContext->tags);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -16,7 +17,7 @@ final class FatalErrorTypes
|
||||
E_PARSE,
|
||||
E_CORE_ERROR,
|
||||
E_COMPILE_ERROR,
|
||||
E_USER_ERROR
|
||||
E_USER_ERROR,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -27,4 +28,3 @@ final class FatalErrorTypes
|
||||
return in_array($type, self::FATAL_TYPES, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -7,7 +8,8 @@ final readonly class GlobalExceptionHandler implements ExceptionHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorKernel $errorKernel
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(\Throwable $throwable): void
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Framework\Health\HealthCheckResult;
|
||||
final readonly class ExceptionHealthChecker implements HealthCheckInterface
|
||||
{
|
||||
public readonly string $name;
|
||||
|
||||
public readonly int $timeout;
|
||||
|
||||
/**
|
||||
@@ -105,4 +106,3 @@ final readonly class ExceptionHealthChecker implements HealthCheckInterface
|
||||
return \App\Framework\Health\HealthCheckCategory::APPLICATION;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,4 +81,3 @@ final readonly class ExceptionLocalizer
|
||||
return array_unique($chain);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,4 +40,3 @@ final readonly class ExceptionMetrics
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ final readonly class ExceptionMetricsCollector
|
||||
private function getMetric(string $metricName): int
|
||||
{
|
||||
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $metricName);
|
||||
|
||||
return $this->getMetricValue($cacheKey);
|
||||
}
|
||||
|
||||
@@ -111,11 +112,12 @@ final readonly class ExceptionMetricsCollector
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = $item->value;
|
||||
|
||||
return is_int($value) ? $value : 0;
|
||||
}
|
||||
|
||||
@@ -163,7 +165,7 @@ final readonly class ExceptionMetricsCollector
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit || !is_array($item->value)) {
|
||||
if (! $item->isHit || ! is_array($item->value)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@@ -175,4 +177,3 @@ final readonly class ExceptionMetricsCollector
|
||||
return array_sum($times) / count($times);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ final readonly class PrometheusExporter
|
||||
{
|
||||
// Replace invalid characters
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,4 +180,3 @@ final readonly class ErrorScopeMiddleware implements HttpMiddleware
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,11 +36,10 @@ final readonly class ExceptionPattern
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'description' => $this->description,
|
||||
'fix_suggestions' => array_map(
|
||||
fn(FixSuggestion $suggestion) => $suggestion->toArray(),
|
||||
fn (FixSuggestion $suggestion) => $suggestion->toArray(),
|
||||
$this->fixSuggestions
|
||||
),
|
||||
'occurrence_count' => $this->occurrenceCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ final readonly class ExceptionPatternDetector
|
||||
if (isset($this->knowledgeBase[$exceptionClass])) {
|
||||
$patternData = $this->knowledgeBase[$exceptionClass];
|
||||
$fixSuggestions = array_map(
|
||||
fn(array $fix) => new FixSuggestion(
|
||||
fn (array $fix) => new FixSuggestion(
|
||||
title: $fix['title'] ?? '',
|
||||
description: $fix['description'] ?? '',
|
||||
codeExample: $fix['code'] ?? null,
|
||||
@@ -66,4 +66,3 @@ final readonly class ExceptionPatternDetector
|
||||
return $patterns;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,4 +40,3 @@ final readonly class FixSuggestion
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,4 +51,3 @@ final readonly class ExceptionPerformanceMetrics
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,4 +108,3 @@ final readonly class ExceptionPerformanceTracker
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,4 +143,3 @@ final readonly class ExceptionFingerprint
|
||||
return $this->hash;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,4 +106,3 @@ final readonly class ExceptionRateLimitConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Framework\ExceptionHandling\RateLimit;
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheItem;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use Throwable;
|
||||
|
||||
@@ -39,7 +38,7 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldProcess(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled) {
|
||||
if (! $this->config->enabled) {
|
||||
return true; // Rate limiting disabled, always process
|
||||
}
|
||||
|
||||
@@ -70,11 +69,11 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldSkipLogging(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->skipLoggingOnLimit) {
|
||||
if (! $this->config->enabled || ! $this->config->skipLoggingOnLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->shouldProcess($exception, $context);
|
||||
return ! $this->shouldProcess($exception, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,11 +81,11 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldSkipAudit(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->skipAuditOnLimit) {
|
||||
if (! $this->config->enabled || ! $this->config->skipAuditOnLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->shouldProcess($exception, $context);
|
||||
return ! $this->shouldProcess($exception, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +93,7 @@ final readonly class ExceptionRateLimiter
|
||||
*/
|
||||
public function shouldTrackMetrics(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->trackMetricsOnLimit) {
|
||||
if (! $this->config->enabled || ! $this->config->trackMetricsOnLimit) {
|
||||
return true; // Always track if not enabled or tracking not disabled
|
||||
}
|
||||
|
||||
@@ -116,6 +115,7 @@ final readonly class ExceptionRateLimiter
|
||||
: ExceptionFingerprint::fromException($exception);
|
||||
|
||||
$cacheKey = $this->buildCacheKey($fingerprint);
|
||||
|
||||
return $this->getCachedCount($cacheKey);
|
||||
}
|
||||
|
||||
@@ -135,11 +135,12 @@ final readonly class ExceptionRateLimiter
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit) {
|
||||
if (! $item->isHit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = $item->value;
|
||||
|
||||
return is_int($value) ? $value : 0;
|
||||
}
|
||||
|
||||
@@ -179,4 +180,3 @@ final readonly class ExceptionRateLimiter
|
||||
$this->cache->forget($cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Framework\ExceptionHandling\Recovery;
|
||||
|
||||
use App\Framework\Exception\ExceptionMetadata;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -34,7 +33,7 @@ final readonly class ExceptionRecoveryManager
|
||||
}
|
||||
|
||||
// Check if exception is retryable (implements marker interface or is in whitelist)
|
||||
if (!$this->isRetryable($exception)) {
|
||||
if (! $this->isRetryable($exception)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -102,4 +101,3 @@ final readonly class ExceptionRecoveryManager
|
||||
interface RetryableException
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -31,4 +31,3 @@ enum RetryStrategy: string
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
|
||||
{
|
||||
public function __construct(
|
||||
private ConsoleOutput $output
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function canRender(\Throwable $exception): bool
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Reporter;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Reporter;
|
||||
|
||||
interface Reporter
|
||||
|
||||
@@ -17,6 +17,7 @@ final readonly class ReporterRegistry implements Reporter
|
||||
* @param Reporter[] $reporters Variadic list of reporter instances
|
||||
*/
|
||||
private array $reporters;
|
||||
|
||||
public function __construct(
|
||||
Reporter ...$reporters
|
||||
) {
|
||||
@@ -46,4 +47,3 @@ final readonly class ReporterRegistry implements Reporter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ final class ErrorScope
|
||||
{
|
||||
$id = $this->fiberId();
|
||||
|
||||
if (!isset($this->stack[$id])) {
|
||||
if (! isset($this->stack[$id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ final class ErrorScope
|
||||
array_pop($this->stack[$id]);
|
||||
} else {
|
||||
// Exit all scopes until token depth
|
||||
while (!empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
|
||||
while (! empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
|
||||
array_pop($this->stack[$id]);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ final class ErrorScope
|
||||
$stack = $this->stack[$id] ?? [];
|
||||
|
||||
$current = end($stack);
|
||||
|
||||
return $current !== false ? $current : null;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,8 @@ final class ErrorScope
|
||||
public function hasScope(): bool
|
||||
{
|
||||
$id = $this->fiberId();
|
||||
return !empty($this->stack[$id]);
|
||||
|
||||
return ! empty($this->stack[$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +101,7 @@ final class ErrorScope
|
||||
public function depth(): int
|
||||
{
|
||||
$id = $this->fiberId();
|
||||
|
||||
return count($this->stack[$id] ?? []);
|
||||
}
|
||||
|
||||
@@ -110,6 +113,7 @@ final class ErrorScope
|
||||
private function fiberId(): int
|
||||
{
|
||||
$fiber = Fiber::getCurrent();
|
||||
|
||||
return $fiber ? spl_object_id($fiber) : 0;
|
||||
}
|
||||
|
||||
@@ -129,7 +133,7 @@ final class ErrorScope
|
||||
return [
|
||||
'active_fibers' => count($this->stack),
|
||||
'total_scopes' => array_sum(array_map('count', $this->stack)),
|
||||
'max_depth' => !empty($this->stack) ? max(array_map('count', $this->stack)) : 0,
|
||||
'max_depth' => ! empty($this->stack) ? max(array_map('count', $this->stack)) : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ final readonly class ErrorScopeContext
|
||||
public ?string $jobId = null,
|
||||
public ?string $commandName = null,
|
||||
public array $tags = [],
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTTP scope from request
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Framework\ExceptionHandling\Serialization;
|
||||
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
||||
use App\Framework\Logging\ValueObjects\ExceptionContext as LoggingExceptionContext;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -36,6 +35,7 @@ final readonly class ExceptionSerializer
|
||||
public function toJson(Throwable $exception, array $options = []): string
|
||||
{
|
||||
$data = $this->toArray($exception, $options);
|
||||
|
||||
return json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
@@ -109,12 +109,12 @@ final readonly class ExceptionSerializer
|
||||
]);
|
||||
|
||||
// Add domain data
|
||||
if (!empty($context->data)) {
|
||||
if (! empty($context->data)) {
|
||||
$logData['domain_data'] = $context->data;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
if (!empty($context->metadata)) {
|
||||
if (! empty($context->metadata)) {
|
||||
$logData['metadata'] = $context->metadata;
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,7 @@ final readonly class ExceptionSerializer
|
||||
$serialized[] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
return $serialized;
|
||||
}
|
||||
|
||||
@@ -332,4 +333,3 @@ final readonly class ExceptionSerializer
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
@@ -9,13 +10,14 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorKernel $errorKernel
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$last = error_get_last();
|
||||
|
||||
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
|
||||
if (! $last || ! FatalErrorTypes::isFatal($last['type'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +26,7 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
||||
$file = (string)($last['file'] ?? 'unknown');
|
||||
$line = (int)($last['line'] ?? 0);
|
||||
|
||||
$error = new Error($last['message'] ?? 'Fatal error',0);
|
||||
$error = new Error($last['message'] ?? 'Fatal error', 0);
|
||||
|
||||
|
||||
|
||||
@@ -49,5 +51,4 @@ final readonly class ShutdownHandler implements ShutdownHandlerInterface
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling;
|
||||
|
||||
interface ShutdownHandlerInterface
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
@@ -11,13 +12,14 @@ final readonly class ErrorPolicyResolver
|
||||
{
|
||||
public function __construct(
|
||||
private ?Logger $logger = null
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
|
||||
{
|
||||
return match(true) {
|
||||
$environmentType->isProduction() => new StrictErrorPolicy(),
|
||||
$environmentType->isDevelopment() => $this->logger !== null
|
||||
$environmentType->isDevelopment() => $this->logger !== null
|
||||
? new LenientPolicy($this->logger)
|
||||
: new StrictErrorPolicy(),
|
||||
default => new SilentErrorPolicy(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
@@ -19,19 +20,21 @@ final readonly class LenientPolicy implements ErrorHandlerStrategy
|
||||
|
||||
public function handle(ErrorContext $context): ErrorDecision
|
||||
{
|
||||
if($context->isDeprecation()) {
|
||||
$this->logger->notice("[Deprecation] {$context->message}",
|
||||
if ($context->isDeprecation()) {
|
||||
$this->logger->notice(
|
||||
"[Deprecation] {$context->message}",
|
||||
LogContext::withData(
|
||||
[
|
||||
'file' => $context->file,
|
||||
'line' => $context->line?->toInt()
|
||||
'line' => $context->line?->toInt(),
|
||||
]
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
return ErrorDecision::HANDLED;
|
||||
}
|
||||
|
||||
if($context->isFatal()) {
|
||||
if ($context->isFatal()) {
|
||||
throw new ErrorException(
|
||||
$context->message,
|
||||
0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\Strategy;
|
||||
|
||||
418
src/Framework/ExceptionHandling/Templates/errors/error.view.php
Normal file
418
src/Framework/ExceptionHandling/Templates/errors/error.view.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $title }}</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-bg: #f5f5f5;
|
||||
--color-surface: #ffffff;
|
||||
--color-error: #d32f2f;
|
||||
--color-text: #333333;
|
||||
--color-text-muted: #666666;
|
||||
--color-border: #e1e3e6;
|
||||
--color-warning-bg: #fff3cd;
|
||||
--color-warning-border: #ffc107;
|
||||
--color-debug-bg: #e8f5e9;
|
||||
--color-debug-text: #1b5e20;
|
||||
--color-prod-bg: #fff3cd;
|
||||
--color-prod-text: #8a6d3b;
|
||||
--color-code-bg: #f8f9fa;
|
||||
--color-vendor-bg: #fafbfc;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--font-mono: 'Courier New', monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: var(--color-error);
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.badge--debug {
|
||||
background: var(--color-debug-bg);
|
||||
color: var(--color-debug-text);
|
||||
}
|
||||
|
||||
.badge--prod {
|
||||
background: var(--color-prod-bg);
|
||||
color: var(--color-prod-text);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--color-warning-bg);
|
||||
border-left: 4px solid var(--color-warning-border);
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 1.25rem 0;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: #f9fafb;
|
||||
color: #555;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notice--warn {
|
||||
border-color: #ffe599;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Debug Section Styles */
|
||||
.debug-section {
|
||||
background: var(--color-code-bg);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.debug-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.context-item {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.context-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.context-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Code Snippet Styles */
|
||||
.code-context {
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.code-context h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Stack Trace Styles */
|
||||
.stack-trace {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.stack-trace__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.stack-trace__header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.stack-trace__list {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stack-frame {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stack-frame:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stack-frame summary {
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stack-frame summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack-frame summary::before {
|
||||
content: '\25B6';
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.stack-frame[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.stack-frame__index {
|
||||
min-width: 28px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stack-frame__call {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stack-frame__location {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stack-frame__code {
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.stack-frame--vendor {
|
||||
background: var(--color-vendor-bg);
|
||||
}
|
||||
|
||||
.stack-frame--vendor summary {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.stack-trace__raw {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border: 1px solid #d0d4da;
|
||||
background: var(--color-surface);
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #eef1f5;
|
||||
}
|
||||
|
||||
.btn--copied {
|
||||
background: var(--color-debug-bg);
|
||||
border-color: var(--color-debug-text);
|
||||
color: var(--color-debug-text);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.error-footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-header">
|
||||
<h1 class="error-title">{{ $title }}</h1>
|
||||
<span class="badge badge--{{ $isDebugMode ? 'debug' : 'prod' }}">{{ $isDebugMode ? 'DEBUG' : 'PROD' }}</span>
|
||||
</div>
|
||||
|
||||
<div if="{{ $templateError ?? false }}" class="notice notice--warn">
|
||||
Template fallback: {{ $templateError }}
|
||||
</div>
|
||||
|
||||
<div class="error-message">
|
||||
<p>{{ $message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value">{{ $statusCode }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Exception</span>
|
||||
<span class="meta-value">{{ $exceptionClass }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">Request</span>
|
||||
<span class="meta-value">{{ $requestMethod }} {{ $requestUri }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div if="{{ $isDebugMode }}" class="debug-section">
|
||||
<h3>Debug Information</h3>
|
||||
|
||||
<div class="context-item">
|
||||
<span class="context-label">Exception:</span>
|
||||
<span class="context-value">{{ $exceptionClass }}</span>
|
||||
</div>
|
||||
<div class="context-item">
|
||||
<span class="context-label">File:</span>
|
||||
<span class="context-value">{{ $debug['file'] ?? '' }}:{{ $debug['line'] ?? '' }}</span>
|
||||
</div>
|
||||
<div class="context-item">
|
||||
<span class="context-label">Short trace:</span>
|
||||
<span class="context-value">{{ $debug['trace_short'] ?? '' }}</span>
|
||||
</div>
|
||||
|
||||
<div if="{{ isset($context) }}" class="context-details">
|
||||
<div if="{{ $context['operation'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Operation:</span>
|
||||
<span class="context-value">{{ $context['operation'] }}</span>
|
||||
</div>
|
||||
<div if="{{ $context['component'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Component:</span>
|
||||
<span class="context-value">{{ $context['component'] }}</span>
|
||||
</div>
|
||||
<div if="{{ $context['request_id'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Request ID:</span>
|
||||
<span class="context-value">{{ $context['request_id'] }}</span>
|
||||
</div>
|
||||
<div if="{{ $context['occurred_at'] ?? '' }}" class="context-item">
|
||||
<span class="context-label">Occurred At:</span>
|
||||
<span class="context-value">{{ $context['occurred_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div if="{{ $codeSnippet ?? '' }}" class="code-context">
|
||||
{!! $codeSnippet !!}
|
||||
</div>
|
||||
|
||||
<div if="{{ isset($debug['trace_frames']) }}" class="stack-trace">
|
||||
<div class="stack-trace__header">
|
||||
<h4>Stack Trace</h4>
|
||||
<button type="button" class="btn" id="btn-copy-trace">Copy stack</button>
|
||||
</div>
|
||||
<div class="stack-trace__list">
|
||||
{!! $stackTraceHtml !!}
|
||||
</div>
|
||||
<pre id="full-trace-text" class="stack-trace__raw">{{ $debug['trace'] ?? '' }}</pre>
|
||||
<pre id="full-trace-plain" class="stack-trace__raw">{{ $debug['trace_plain'] ?? '' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-footer">
|
||||
Custom PHP Framework
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script if="{{ $isDebugMode }}">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const btn = document.getElementById('btn-copy-trace');
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
const raw = document.getElementById('full-trace-plain');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw.textContent || '');
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('btn--copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copy stack';
|
||||
btn.classList.remove('btn--copied');
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
btn.textContent = 'Copy failed';
|
||||
setTimeout(() => btn.textContent = 'Copy stack', 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -52,6 +52,7 @@ final readonly class ExceptionMessageTranslator
|
||||
// Process template
|
||||
if (is_string($template)) {
|
||||
$message = $this->processTemplate($template, $exception, $context);
|
||||
|
||||
return UserFriendlyMessage::simple($message);
|
||||
}
|
||||
|
||||
@@ -97,4 +98,3 @@ final readonly class ExceptionMessageTranslator
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,3 @@ final readonly class UserFriendlyMessage
|
||||
return new self($message, helpText: $helpText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
160
tests/Unit/Framework/Core/ValueObjects/FrameworkModuleTest.php
Normal file
160
tests/Unit/Framework/Core/ValueObjects/FrameworkModuleTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user