Skip to content
Closed
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 .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
.gitattributes export-ignore
.gitignore export-ignore
.php-cs-fixer.php export-ignore
phpcs.xml.dist export-ignore
phpunit.xml.dist export-ignore
phpcs.xml export-ignore
phpunit.xml export-ignore
.phpstan.neon export-ignore
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [3.2.0] - 2025-04-22
### Added
- Support for [PSR-3 Logger](https://github.com/php-fig/log). Example using [Monolog](https://github.com/Seldaek/monolog):

With default logging:
```php
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('path/to/your.log', Level::Warning));

$response = Dispatcher::run([
new ErrorHandler(null, $logger),
function ($request) {
throw new Exception('Something went wrong');
},
]);

```
With a custom log callback:
```php
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('path/to/your.log', Level::Warning));

$response = Dispatcher::run([
(new ErrorHandler(null, $logger))
->logCallback(function (
LoggerInterface $logger,
Throwable $error,
ServerRequestInterface $request
): void {
$logger->critical('Uncaught exception', [
'message' => $error->getMessage(),
'request' => [
'uri' => $request->getUri()->getPath(),
]
]);
}),
function ($request) {
throw new Exception('Something went wrong');
},
]);
```

## [3.1.0] - 2025-03-21
### Fixed
- Support for PHP typing.
Expand Down Expand Up @@ -117,6 +167,7 @@ First version

[#9]: https://github.com/middlewares/error-handler/issues/9

[3.2.0]: https://github.com/middlewares/error-handler/compare/v3.1.0...v3.2.0
[3.1.0]: https://github.com/middlewares/error-handler/compare/v3.0.2...v3.1.0
[3.0.2]: https://github.com/middlewares/error-handler/compare/v3.0.1...v3.0.2
[3.0.1]: https://github.com/middlewares/error-handler/compare/v3.0.0...v3.0.1
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ composer require middlewares/error-handler
use Middlewares\ErrorFormatter;
use Middlewares\ErrorHandler;
use Middlewares\Utils\Dispatcher;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

// Create a new ErrorHandler instance
// Any number of formatters can be added. One will be picked based on the Accept
Expand All @@ -41,14 +44,18 @@ $errorHandler = new ErrorHandler([
new ErrorFormatter\XmlFormatter(),
]);

// Create logger (optional)
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('path/to/your.log', Level::Warning));

// ErrorHandler should always be the first middleware in the stack!
$dispatcher = new Dispatcher([
$errorHandler,
// ...
function ($request) {
throw HttpErrorException::create(404);
}
]);
], $logger);

$request = $serverRequestFactory->createServerRequest('GET', '/');
$response = $dispatcher->dispatch($request);
Expand All @@ -67,6 +74,40 @@ $errorHandler = new ErrorHandler([

**Note:** If no formatter is found, the first value of the array will be used. In the example above, `HtmlFormatter`.

### How to setup a custom log callback

This allows you to fully customize how you log (level, message, context, etc.) with access to values such as `Throwable` and `ServerRequestInterface` instances.

Example using Monolog:

```php
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('path/to/your.log', Level::Warning));

$response = Dispatcher::run([
(new ErrorHandler(null, $logger))
->logCallback(function (
LoggerInterface $logger,
Throwable $error,
ServerRequestInterface $request
): void {
$logger->critical('Uncaught exception', [
'message' => $error->getMessage(),
'request' => [
'uri' => $request->getUri()->getPath(),
]
]);
}),
function ($request) {
throw new Exception('Something went wrong');
},
]);
```

### How to use a custom response for Production

```php
Expand Down
8 changes: 5 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,19 @@
},
"require": {
"php": "^7.2 || ^8.0",
"ext-gd": "*",
"middlewares/utils": "^2 || ^3 || ^4",
"psr/http-server-middleware": "^1",
"ext-gd": "*"
"psr/log": "^1 || ^2 || ^3"
},
"require-dev": {
"phpunit/phpunit": "^8 || ^9",
"friendsofphp/php-cs-fixer": "^3",
"squizlabs/php_codesniffer": "^3",
"oscarotero/php-cs-fixer-config": "^2",
"phpstan/phpstan": "^1 || ^2",
"laminas/laminas-diactoros": "^2 || ^3"
"laminas/laminas-diactoros": "^2 || ^3",
"filisko/fake-psr3-logger": "^1.0"
},
"autoload": {
"psr-4": {
Expand All @@ -50,4 +52,4 @@
"coverage": "phpunit --coverage-text",
"coverage-html": "phpunit --coverage-html=coverage"
}
}
}
33 changes: 32 additions & 1 deletion src/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,29 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Throwable;

class ErrorHandler implements MiddlewareInterface
{
/** @var FormatterInterface[] */
private $formatters = [];

/** @var LoggerInterface|null */
private $logger = null;

/** @var callable|null */
private $logCallback = null;

/**
* Configure the error formatters
*
* @param FormatterInterface[] $formatters
*/
public function __construct(?array $formatters = null)
public function __construct(?array $formatters = null, ?LoggerInterface $logger = null)
Copy link
Member

Choose a reason for hiding this comment

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

I think the logger shouldn't be passed in the constructor because it's an optional feature. It should be a method (for example, $middleware->logger(...)).

Copy link
Member

Choose a reason for hiding this comment

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

Dependencies belong in constructors... how else can we have dependency inversion?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I agree. But in this case it's an optional feature. People may want to log the errors or not, so I see this as a configuration, not a core dependency of the middleware.

That being said, maybe you're right in your comment about the premise of this feature and it would be better to simply create a specific middleware for this.

@filisko ?

Copy link
Member

@shadowhand shadowhand Apr 24, 2025

Choose a reason for hiding this comment

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

A constructor parameter with a default value of null has no cost. (With PHP named parameters, it doesn't even cost writing null to fill it in.) A method that has to accept a parameter to update the object means the object is no longer readonly. Thus, a method for an optional is far more expensive to create, maintain, and operate than a constructor parameter.

{
$this->logger = $logger;

if (empty($formatters)) {
$formatters = [
new PlainFormatter(),
Expand All @@ -54,11 +63,33 @@ public function addFormatters(FormatterInterface ...$formatters): self
return $this;
}

/**
* Set a custom log callback
*/
public function logCallback(callable $callback): self
Copy link
Member

Choose a reason for hiding this comment

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

I think logger() could accept a callable or a LoggerInterface, so we don't need two methods for the same purpose.
Other option is ->logger($logger, $callback), but to me is more elegant to have only one handler for logging (the callback can have access to the logger by itself).

Copy link
Member Author

@filisko filisko Apr 24, 2025

Choose a reason for hiding this comment

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

ok, but then it wouldn't be about logging anymore, but a hook for when an error happens, right? Because inside the callback you can put anything, there is no LoggerInterface enforcement anymore so we should change the name.

How does this look like? onError

$errorHandler->onError(function( Throwable $error, ServerRequestInterface $request) use($logger, $other): void {
          $logger->error('Whoops!');
         // $other stuff...
});

Copy link
Member

Choose a reason for hiding this comment

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

It looks good to me. Maybe an onError callback is enough to cover all casuistry and we don't need a specific method for logger.

{
$this->logCallback = $callback;

return $this;
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
return $handler->handle($request);
} catch (Throwable $error) {
if ($this->logger) {
if ($this->logCallback) {
($this->logCallback)($this->logger, $error, $request);
Copy link
Member

Choose a reason for hiding this comment

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

If the logger is callback, I'd do $this->logger($error, $request). The callback is a callable with access to a logger (or anything else).

} else {
$this->logger->critical('Uncaught exception', [
'message' => $error->getMessage(),
'file' => $error->getFile(),
'line' => $error->getLine(),
Copy link
Member

Choose a reason for hiding this comment

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

Probably I'd include some info about the request: url, method, and uuid (https://github.com/middlewares/uuid).

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, funny that I added it and then forgot to put it back

]);
}
}

foreach ($this->formatters as $formatter) {
if ($formatter->isValid($error, $request)) {
return $formatter->handle($error, $request);
Expand Down
63 changes: 63 additions & 0 deletions tests/ErrorHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Middlewares\Tests;

use Exception;
use Filisko\FakeLogger;
use Middlewares\ErrorFormatter\HtmlFormatter;
use Middlewares\ErrorFormatter\ImageFormatter;
use Middlewares\ErrorFormatter\JsonFormatter;
Expand All @@ -15,6 +16,9 @@
use Middlewares\Utils\Factory;
use Middlewares\Utils\HttpErrorException;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Throwable;

class ErrorHandlerTest extends TestCase
{
Expand Down Expand Up @@ -61,6 +65,65 @@ public function getStatusCode(): int
$this->assertEquals(418, $response->getStatusCode());
}

public function testLoggerException(): void
{
$logger = new FakeLogger();

$response = Dispatcher::run([
new ErrorHandler(null, $logger),
function ($request) {
throw new Exception('Something went wrong');
},
]);

$this->assertEquals(500, $response->getStatusCode());
$this->assertEquals([[
'level' => 'critical',
'message' => 'Uncaught exception',
'context' => [
'message' => 'Something went wrong',
'file' => __FILE__,
'line' => 75,
]
]], $logger->logs());
}

public function testLoggerExceptionWithCustomCallback(): void
{
$logger = new FakeLogger();

$response = Dispatcher::run([
(new ErrorHandler(null, $logger))
->logCallback(function (
LoggerInterface $logger,
Throwable $error,
ServerRequestInterface $request
): void {
$logger->critical('Uncaught exception', [
'message' => $error->getMessage(),
'request' => [
'uri' => $request->getUri()->getPath(),
]
]);
}),
function ($request) {
throw new Exception('Something went wrong');
},
]);

$this->assertEquals(500, $response->getStatusCode());
$this->assertEquals([[
'level' => 'critical',
'message' => 'Uncaught exception',
'context' => [
'message' => 'Something went wrong',
'request' => [
'uri' => '/',
]
]
]], $logger->logs());
}

public function testGifFormatter(): void
{
$request = Factory::createServerRequest('GET', '/');
Expand Down