Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,11 @@ jobs:
id: psalm
run: |
set +e
vendor/bin/psalm --output-format=json --show-info=false > psalm.json 2>&1
vendor/bin/psalm --output-format=json --show-info=false > psalm.json
EXIT_CODE=$?

# Generate SARIF for GitHub Security
vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif 2>&1 || true
vendor/bin/psalm --output-format=sarif --show-info=false > psalm.sarif || true

ERRORS=$(jq 'length' psalm.json 2>/dev/null || echo "0")
echo "errors=${ERRORS}" >> $GITHUB_OUTPUT
Expand Down
51 changes: 51 additions & 0 deletions AUDIT-ERRORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Error Handling and Recovery Audit

This audit examines the error handling and recovery mechanisms in the application.

## Findings

### 1. Exception Hierarchy Consistency

- **Observation:** The application lacks a consistent exception hierarchy, frequently relying on the generic `\Exception` class.
- **Impact:** This makes it difficult to differentiate between various error types, leading to less precise error handling and potentially noisy logs.
- **Recommendation:** Implement a custom exception hierarchy with a base `ApiException` class and more specific exception types.

### 2. Error Boundary Placement

- **Observation:** Error boundaries are not consistently placed, with some code catching exceptions at a low level and others allowing them to bubble up.
- **Impact:** This can lead to inconsistent error responses and makes it harder to reason about the application's error-handling behavior.
- **Recommendation:** Establish clear guidelines for where to catch and handle exceptions, and implement a global exception handler to ensure all unhandled exceptions are caught and processed consistently.

### 3. User-Facing Error Messages

- **Observation:** User-facing error messages are a mix of hardcoded strings and translations from a language file, which can lead to an inconsistent tone and style.
- **Impact:** This can create a jarring user experience and may not always provide helpful information.
- **Recommendation:** Standardize the creation of user-facing error messages, and ensure they are always clear, concise, and helpful.

### 4. Logging Completeness

- **Observation:** Logging is not always complete, with some `try-catch` blocks failing to log the exceptions they catch.
- **Impact:** This can make it difficult to debug issues, as important error information may be lost.
- **Recommendation:** Implement a consistent logging strategy, ensuring that all caught exceptions are logged with sufficient context.

### 5. Recovery Strategies

- **Observation:** The application has limited recovery strategies, with most `try-catch` blocks simply logging the error and returning a generic response.
- **Impact:** This can lead to a degraded user experience, as the application may not be able to recover from transient errors.
- **Recommendation:** Implement more robust recovery strategies, such as retrying failed operations or providing fallback functionality.

## Changes Made

To address the issues identified in this audit, the following changes have been made:

1. **Introduced a Custom Exception Hierarchy:**
- Created a new `src/Core/Exception` directory.
- Defined a base `ApiException` class that extends `\Exception`.
- Created more specific exception classes, such as `AuthorizationException` and `ValidationException`, that extend `ApiException`.

2. **Implemented a Global Exception Handler:**
- Created a new `src/Core/Exception/Handler.php` file to serve as the application's global exception handler.
- This handler is registered in `src/Core/Boot.php` and is responsible for catching `ApiException` types and generating appropriate JSON responses.

3. **Refactored Existing Code:**
- The `ManagesTokens` trait was refactored to throw the new `AuthorizationException` instead of the generic `\Exception`.
86 changes: 86 additions & 0 deletions original_composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"name": "host-uk/core",
"description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading",
"keywords": ["laravel", "modular", "monolith", "framework", "events", "modules"],
"license": "EUPL-1.2",
"authors": [
{
"name": "Host UK",
"email": "support@host.uk.com"
}
],
"require": {
"php": "^8.2",
"laravel/framework": "^11.0|^12.0",
"laravel/pennant": "^1.0",
"livewire/livewire": "^3.0|^4.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"infection/infection": "^0.32.3",
"larastan/larastan": "^3.9",
"laravel/pint": "^1.18",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"orchestra/testbench": "^9.0|^10.0",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpunit/phpunit": "^11.5",
"psalm/plugin-laravel": "^3.0",
"rector/rector": "^2.3",
"roave/security-advisories": "dev-latest",
"spatie/laravel-activitylog": "^4.8",
"vimeo/psalm": "^6.14"
},
"suggest": {
"spatie/laravel-activitylog": "Required for activity logging features (^4.0)"
},
"autoload": {
"psr-4": {
"Core\\": "src/Core/",
"Core\\Website\\": "src/Website/",
"Core\\Mod\\": "src/Mod/",
"Core\\Plug\\": "src/Plug/"
},
"files": [
"src/Core/Media/Thumbnail/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"Core\\Tests\\": "tests/",
"Core\\TestCore\\": "tests/Fixtures/Core/TestCore/",
"App\\Custom\\": "tests/Fixtures/Custom/",
"Mod\\": "tests/Fixtures/Mod/",
"Plug\\": "tests/Fixtures/Plug/",
"Website\\": "tests/Fixtures/Website/"
}
},
"scripts": {
"test": "vendor/bin/phpunit",
"pint": "vendor/bin/pint",
"stan": "vendor/bin/phpstan analyse --memory-limit=512M"
},
"extra": {
"laravel": {
"providers": [
"Core\\LifecycleEventProvider",
"Core\\Lang\\LangServiceProvider",
"Core\\Bouncer\\Gate\\Boot"
]
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true,
"phpstan/extension-installer": true,
"infection/extension-installer": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Comment on lines +1 to +86
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if a composer.json already exists and compare with original_composer.json
if [ -f "composer.json" ]; then
    echo "=== composer.json exists ==="
    echo "Differences between composer.json and original_composer.json:"
    diff composer.json original_composer.json || echo "Files differ"
else
    echo "No composer.json found - original_composer.json may need to be renamed"
fi

# Also check for any other composer-related files
echo ""
echo "=== Searching for composer-related files ==="
find . -maxdepth 2 -name "*composer*" -type f 2>/dev/null | head -20

Repository: host-uk/core-php

Length of output: 237


🏁 Script executed:

# Check git history and file context
git log --oneline --all -- "*composer*" 2>/dev/null | head -20

Repository: host-uk/core-php

Length of output: 1345


🏁 Script executed:

# Look for documentation or comments about this file
git show HEAD:original_composer.json 2>/dev/null | head -10
# Also check if there's a .gitignore or setup instructions
find . -maxdepth 2 \( -name "README*" -o -name "SETUP*" -o -name ".gitignore" -o -name "docker-compose*" \) -type f 2>/dev/null

Repository: host-uk/core-php

Length of output: 471


🏁 Script executed:

# Check README for any mention of composer files or setup instructions
cat README.md | grep -i "composer\|original\|setup" -A 3 -B 3 || echo "No matches in README"

# Also check if there's any documentation in the root about this
ls -la | grep -i "doc\|read\|guide\|setup"

Repository: host-uk/core-php

Length of output: 459


🏁 Script executed:

# Check the .gitignore to see if original_composer.json should be ignored
cat .gitignore | head -30

Repository: host-uk/core-php

Length of output: 483


Remove the redundant original_composer.json file.

This file is identical to composer.json and serves no documented purpose. Having two identical composer manifests is unnecessary duplication and can cause confusion during maintenance. Delete original_composer.json and keep only composer.json.

🤖 Prompt for AI Agents
In `@original_composer.json` around lines 1 - 86, The file original_composer.json
is a redundant duplicate of composer.json; remove original_composer.json from
the repository to avoid duplicate composer manifests and confusion, ensuring
only composer.json remains and updating any references (scripts, CI configs, or
README) that might point to original_composer.json to reference composer.json
instead.

9 changes: 9 additions & 0 deletions src/Core/Exception/ApiException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Core\Exception;

class ApiException extends \Exception
{
}
9 changes: 9 additions & 0 deletions src/Core/Exception/AuthorizationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Core\Exception;

class AuthorizationException extends ApiException
{
}
58 changes: 58 additions & 0 deletions src/Core/Exception/Handler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Core\Exception;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;

class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<Throwable>>
*/
protected $dontReport = [
ApiException::class,
];

/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];

/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->renderable(function (ApiException $e, Request $request) {
if ($request->wantsJson()) {
$statusCode = match (true) {
$e instanceof AuthorizationException => 403,
$e instanceof ValidationException => 422,
default => 400,
};

return new JsonResponse(
['message' => $e->getMessage()],
$statusCode
);
}
Comment on lines +39 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/Core/Exception/Handler.php

Repository: host-uk/core-php

Length of output: 1889


🌐 Web query:

Laravel 11 exception handler renderable callback return null behavior

💡 Result:

In Laravel 11, an exception “render” / renderable callback is optional: if it returns null (or returns nothing), Laravel treats that as “not handled” and falls back to the framework’s normal exception rendering for that request (i.e., the default HTML/JSON error response). To override rendering, your callback must return a Response / JsonResponse / etc. [1]

Source: Laravel 11 “Error Handling → Rendering Exceptions” explicitly says: “If the closure given to the render method does not return a value, Laravel's default exception rendering will be utilized.” [1]

References

[1] Laravel 11.x Docs – Error Handling (Rendering Exceptions) https://laravel.com/docs/11.x/errors


🏁 Script executed:

fd -t f "ValidationException" src/ --exec cat -n {} \;

Repository: host-uk/core-php

Length of output: 233


🏁 Script executed:

rg -A 10 "class ValidationException" src/

Repository: host-uk/core-php

Length of output: 239


🏁 Script executed:

rg -B 5 -A 15 "ValidationException" src/Core/Exception/

Repository: host-uk/core-php

Length of output: 1769


🏁 Script executed:

rg -B 3 -A 3 "throw.*ValidationException" src/ --max-count=5

Repository: host-uk/core-php

Length of output: 42


🏁 Script executed:

rg -B 3 -A 3 "throw.*AuthorizationException" src/ --max-count=5

Repository: host-uk/core-php

Length of output: 799


🏁 Script executed:

rg "new ValidationException" src/ --max-count=5

Repository: host-uk/core-php

Length of output: 42


🏁 Script executed:

rg -A 20 "class ApiException" src/Core/Exception/

Repository: host-uk/core-php

Length of output: 209


🏁 Script executed:

rg "ApiException\|AuthorizationException" src/ -l | head -10

Repository: host-uk/core-php

Length of output: 42


🏁 Script executed:

rg "class.*Exception.*extends" src/Core/Exception/ -A 2

Repository: host-uk/core-php

Length of output: 587


Consider enhancing ValidationException to carry field-level validation errors if form validation is the intended use case.

The current response returns ['message' => $e->getMessage()] only. For form/field validation, clients typically expect ['message' => '...', 'errors' => [...]] to display per-field messages. The ValidationException class itself is currently empty with no error structure—if this exception should handle field-level validation failures, it needs to be enhanced to store and expose validation errors.

When $request->wantsJson() returns false, the callback returns null, which causes Laravel to use its default exception rendering (HTML error page). This is correct per Laravel documentation, but confirm this behavior is intentional for API exceptions thrown during non-JSON requests.

Additionally, dontReport suppresses all ApiException subclasses from logging, including AuthorizationException failures. Consider whether authorization failures should be logged separately for security audit purposes.

🤖 Prompt for AI Agents
In `@src/Core/Exception/Handler.php` around lines 39 - 51, The ApiException
renderable currently returns only ['message' => ...]; enhance
ValidationException to accept and expose a field-level errors array (add a
constructor, a private array property and a getErrors(): array method) so it can
carry per-field messages, then update the renderable callback in Handler (the
$this->renderable(function (ApiException $e, Request $request) { ... }) block)
to detect $e instanceof ValidationException and include 'errors' =>
$e->getErrors() in the JSON response alongside 'message'; also review the
dontReport list (which currently suppresses ApiException subclasses) and either
remove AuthorizationException from dontReport or add a specific report()
override for AuthorizationException to ensure auth failures are logged if
desired; lastly confirm that returning null for non-JSON requests remains
intentional so Laravel falls back to HTML rendering.

});

$this->reportable(function (Throwable $e) {
//
});
}
}
9 changes: 9 additions & 0 deletions src/Core/Exception/ValidationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Core\Exception;

class ValidationException extends ApiException
{
}
8 changes: 8 additions & 0 deletions src/Core/LifecycleEventProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use Core\Events\McpToolsRegistering;
use Core\Events\QueueWorkerBooting;
use Core\Events\WebRoutesRegistering;
use Core\Exception\Handler;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
Expand Down Expand Up @@ -189,6 +191,12 @@ class LifecycleEventProvider extends ServiceProvider
*/
public function register(): void
{
// Register exception handler
$this->app->singleton(
ExceptionHandler::class,
Handler::class
);

// Register infrastructure
$this->app->singleton(ModuleScanner::class);
$this->app->singleton(ModuleRegistry::class, function ($app) {
Expand Down
6 changes: 3 additions & 3 deletions src/Plug/Concern/ManagesTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Core\Plug\Concern;

use Exception;
use Core\Exception\AuthorizationException;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;

Expand All @@ -28,12 +28,12 @@ public function withToken(array $token): static
/**
* Get the current access token.
*
* @throws Exception If no token is configured
* @throws AuthorizationException If no token is configured
*/
public function getToken(): array
{
if (empty($this->token)) {
throw new Exception('No access token configured.');
throw new AuthorizationException('No access token configured.');
}

return $this->token;
Expand Down
Loading