Skip to content

Commit 0d9a44b

Browse files
authored
fix(laravel): allow custom error handler for non-api operations (#7622)
fixes #7466
1 parent 40ad568 commit 0d9a44b

8 files changed

Lines changed: 247 additions & 6 deletions

File tree

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ private function isColumnPrimaryKey(array $indexes, string $column): bool
120120
/**
121121
* Get the virtual (non-column) attributes for the given model.
122122
*
123-
* @param array<string, mixed> $columns
123+
* @param list<array<string, mixed>> $columns
124124
*
125125
* @return array<string, mixed>
126126
*/

src/Laravel/Exception/ErrorHandler.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ public function render($request, \Throwable $exception)
6767
$apiOperation = $this->initializeOperation($request);
6868

6969
if (!$apiOperation) {
70+
// For non-API operations, first check if any renderable callbacks on this
71+
// ErrorHandler instance can handle the exception (issue #7466).
72+
$response = $this->renderViaCallbacks($request, $exception);
73+
74+
if ($response) {
75+
return $response;
76+
}
77+
78+
// If no callbacks handled it, delegate to the decorated handler if available
79+
// to preserve custom exception handler classes (issue #7058).
7080
return $this->decorated ? $this->decorated->render($request, $exception) : parent::render($request, $exception);
7181
}
7282

@@ -160,7 +170,6 @@ public function render($request, \Throwable $exception)
160170

161171
try {
162172
$response = $this->apiPlatformController->__invoke($dup);
163-
$this->decorated->render($dup, $exception);
164173

165174
return $response;
166175
} catch (\Throwable $e) {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Tests;
15+
16+
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
17+
use Illuminate\Contracts\Config\Repository;
18+
use Illuminate\Contracts\Debug\ExceptionHandler;
19+
use Illuminate\Foundation\Application;
20+
use Illuminate\Foundation\Testing\RefreshDatabase;
21+
use Illuminate\Http\Request;
22+
use Illuminate\Http\Response;
23+
use Illuminate\Support\Facades\Route;
24+
use Orchestra\Testbench\Concerns\WithWorkbench;
25+
use Orchestra\Testbench\TestCase;
26+
use Workbench\App\Exceptions\CustomHandler;
27+
use Workbench\App\Exceptions\CustomHandlerException;
28+
use Workbench\App\Exceptions\CustomTestException;
29+
use Workbench\Database\Factories\BookFactory;
30+
31+
/**
32+
* Tests for issues #7058 and #7466.
33+
*
34+
* Ensures both custom exception handler classes and callbacks registered via renderable()
35+
* work correctly for non-API routes while API Platform operations use their own error handling.
36+
*/
37+
class CustomExceptionHandlerTest extends TestCase
38+
{
39+
use ApiTestAssertionsTrait;
40+
use RefreshDatabase;
41+
use WithWorkbench;
42+
43+
protected static bool $customHandlerCalled = false;
44+
protected static bool $useCustomHandlerClass = false;
45+
46+
/**
47+
* @param Application $app
48+
*/
49+
protected function defineEnvironment($app): void
50+
{
51+
tap($app['config'], function (Repository $config): void {
52+
$config->set('app.debug', false);
53+
$config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
54+
});
55+
}
56+
57+
protected function resolveApplicationExceptionHandler($app): void
58+
{
59+
$handlerClass = self::$useCustomHandlerClass ? CustomHandler::class : \Illuminate\Foundation\Exceptions\Handler::class;
60+
$app->singleton(ExceptionHandler::class, $handlerClass);
61+
}
62+
63+
protected function setUp(): void
64+
{
65+
parent::setUp();
66+
self::$customHandlerCalled = false;
67+
68+
if (!self::$useCustomHandlerClass) {
69+
$this->app->make(ExceptionHandler::class)->renderable(function (\Throwable $exception, Request $request) {
70+
if ($exception instanceof CustomTestException) {
71+
self::$customHandlerCalled = true;
72+
73+
return new Response('Custom handler response', 418);
74+
}
75+
});
76+
}
77+
78+
Route::get('/non-api-route', function () {
79+
throw new CustomTestException('This should be handled by custom handler');
80+
});
81+
82+
Route::get('/non-api-route-regular', function () {
83+
throw new \RuntimeException('Regular exception on non-API route');
84+
});
85+
86+
Route::get('/non-api-custom-handler', function () {
87+
throw new CustomHandlerException('Should use custom handler class');
88+
});
89+
}
90+
91+
public function testCustomExceptionHandlerIsCalledForNonApiRoutes(): void
92+
{
93+
$response = $this->get('/non-api-route');
94+
95+
$this->assertTrue(self::$customHandlerCalled, 'Custom exception handler should be called for non-API routes');
96+
$response->assertStatus(418);
97+
$this->assertEquals('Custom handler response', $response->getContent());
98+
}
99+
100+
public function testCustomExceptionHandlerIsNotCalledForApiRoutes(): void
101+
{
102+
BookFactory::new()->count(1)->create();
103+
104+
$response = $this->get('/api/books/non-existent-id', ['accept' => 'application/ld+json']);
105+
106+
$this->assertFalse(self::$customHandlerCalled, 'Custom exception handler should NOT be called for API Platform operations');
107+
$response->assertStatus(404);
108+
}
109+
110+
public function testRegularExceptionOnNonApiRoute(): void
111+
{
112+
$response = $this->get('/non-api-route-regular');
113+
114+
$response->assertStatus(500);
115+
}
116+
117+
public function testApiPlatformExceptionHandlingStillWorks(): void
118+
{
119+
$response = $this->get('/api/books/invalid-id', ['accept' => 'application/ld+json']);
120+
121+
$response->assertStatus(404);
122+
$this->assertStringContainsString('application/', $response->headers->get('content-type'));
123+
}
124+
125+
public function testCustomHandlerClassWorksForNonApiRoutes(): void
126+
{
127+
self::$useCustomHandlerClass = true;
128+
$this->refreshApplication();
129+
$this->setUpTraits();
130+
CustomHandler::$customRenderCalled = false;
131+
132+
Route::get('/non-api-custom-handler-test', function () {
133+
throw new CustomHandlerException('Should use custom handler class');
134+
});
135+
136+
$response = $this->get('/non-api-custom-handler-test');
137+
138+
$this->assertTrue(CustomHandler::$customRenderCalled, 'Custom handler class render() should be called');
139+
$response->assertStatus(419);
140+
$this->assertEquals('Custom Handler Class Response', $response->getContent());
141+
142+
self::$useCustomHandlerClass = false;
143+
}
144+
145+
public function testCustomHandlerClassDoesNotInterceptApiRoutes(): void
146+
{
147+
self::$useCustomHandlerClass = true;
148+
$this->refreshApplication();
149+
$this->setUpTraits();
150+
CustomHandler::$customRenderCalled = false;
151+
152+
BookFactory::new()->count(1)->create();
153+
154+
$response = $this->get('/api/books/non-existent-id', ['accept' => 'application/ld+json']);
155+
156+
$this->assertFalse(CustomHandler::$customRenderCalled, 'Custom handler class should not be called for API operations');
157+
$response->assertStatus(404);
158+
159+
self::$useCustomHandlerClass = false;
160+
}
161+
}

src/Laravel/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"doctrine/dbal": "^4.0",
5959
"larastan/larastan": "^2.0 || ^3.0",
6060
"laravel/sanctum": "^4.0",
61-
"orchestra/testbench": "^9.1",
61+
"orchestra/testbench": "^10.1",
6262
"phpdocumentor/type-resolver": "^1.7",
6363
"phpstan/phpdoc-parser": "^1.29 || ^2.0",
6464
"phpunit/phpunit": "11.5.x-dev",

src/Laravel/phpunit.xml.dist

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@
1818
</testsuites>
1919
<coverage/>
2020
<source ignoreSuppressionOfDeprecations="true" ignoreIndirectDeprecations="false">
21-
<deprecationTrigger>
22-
<function>trigger_deprecation</function>
23-
</deprecationTrigger>
21+
<deprecationTrigger>
22+
<function>trigger_deprecation</function>
23+
</deprecationTrigger>
2424
<include>
2525
<directory>./</directory>
2626
</include>
2727
<exclude>
2828
<directory>./Tests</directory>
29+
<directory>./workbench</directory>
30+
<directory>./public</directory>
2931
<directory>./vendor</directory>
3032
</exclude>
3133
</source>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Exceptions;
15+
16+
use Illuminate\Foundation\Exceptions\Handler;
17+
use Illuminate\Http\Response;
18+
19+
class CustomHandler extends Handler
20+
{
21+
public static bool $customRenderCalled = false;
22+
23+
public function render($request, \Throwable $exception)
24+
{
25+
if ($exception instanceof CustomHandlerException) {
26+
self::$customRenderCalled = true;
27+
28+
return new Response('Custom Handler Class Response', 419);
29+
}
30+
31+
return parent::render($request, $exception);
32+
}
33+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Exceptions;
15+
16+
class CustomHandlerException extends \Exception
17+
{
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Exceptions;
15+
16+
class CustomTestException extends \Exception
17+
{
18+
}

0 commit comments

Comments
 (0)