Laravelでpackageを切るときの自作の例外ハンドラ
2025.10.31 09:00
2025.10.31 09:56
正直、例外ハンドラはフレームワーク側に任せてきた派です。
でもパッケージ分割を始めると、「このモジュールの NotFound だけ、404+専用ビューで返したい」「APIの時はJSONにしたい」みたいなモジュール固有の流儀が出てきました。
というわけで、アプリ全体の Handler を置き換えずに、既存 Handler に“後付け”で機能を生やす方式を試してみました。
目次
ねらい(ざっくり)
- モジュール固有のドメイン例外(例:
SpotNotFoundException)を、404+専用文言に変換したい - HTML と API(JSON) をどちらもサポート(
expectsJson()判定) - アプリの
App\Exceptions\Handlerはそのまま。パッケージ側で登録するだけ
仕上がりイメージ
- Generalモジュール内で
SpotNotFoundExceptionを投げると…- ブラウザ→ 404/専用ビュー
general::errors.not-found - API→
{"message":"Spot not found: 103"}的なJSONを404で返す
- ブラウザ→ 404/専用ビュー
- ログには URL / UA / IP など運用に必要なメタ情報を記録
実装
1) ドメイン例外(基底&個別)
<?php declare(strict_types=1);
namespace Tool\General\Domain\Exceptions;
use RuntimeException;
interface DomainExceptionInterface {}
/**
* NotFoundException(基底)
*/
class NotFoundException extends RuntimeException implements DomainExceptionInterface {}
/**
* SpotNotFoundException(個別)
*/
class SpotNotFoundException extends NotFoundException
{
public function __construct(int $spotId)
{
parent::__construct("Spot not found: {$spotId}");
}
}2) モジュール専用「例外ハンドラ登録クラス」
ここがキモ。アプリ本体の Handler を差し替えず、renderable() コールバックを後付けします。
<?php declare(strict_types=1);
namespace Tool\General\Domain\Exceptions;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
final class GeneralExceptionHandler
{
// ==================================================
// エントリ
// ==================================================
public static function register(object $handler): void
{
// ここにモジュール固有の登録を追加していく
self::registerNewsNotFound($handler);
self::registerSpotNotFound($handler);
}
// ==================================================
// NewsNotFound → 404
// ==================================================
private static function registerNewsNotFound(object $handler): void
{
if (!method_exists($handler, 'renderable')) {
return; // 念のため防御
}
$handler->renderable(function (NewsNotFoundException $e, Request $request) {
self::logNotFound('News not found', $e, $request);
if ($request->expectsJson()) {
return response()->json([
'message' => $e->getMessage(),
], 404);
}
return response()->view('general::errors.not-found', [
'title' => 'お知らせ記事が見つかりません',
'message' => "申し訳ありません、お探しのお知らせ記事が見つかりませんでした。\nページが変更になったか、アドレスが違っている可能性があります。\nお手数ですが、トップページから今一度お求めのページをお探しください。",
'backUrl' => '/news',
'backText' => 'お知らせ一覧に戻る',
], 404);
});
}
// ==================================================
// SpotNotFound → 404
// ==================================================
private static function registerSpotNotFound(object $handler): void
{
if (!method_exists($handler, 'renderable')) {
return;
}
$handler->renderable(function (SpotNotFoundException $e, Request $request) {
self::logNotFound('Spot not found', $e, $request);
if ($request->expectsJson()) {
return response()->json([
'message' => $e->getMessage(),
], 404);
}
return response()->view('general::errors.not-found', [
'title' => 'お探しの事業所が見つかりません',
'message' => "申し訳ありません、お探しの事業所が見つかりませんでした。\nページが変更になったか、アドレスが違っている可能性があります。\nお手数ですが、トップページから今一度お求めのページをお探しください。",
'backUrl' => '/spots',
'backText' => '事業所一覧に戻る',
], 404);
});
}
// ==================================================
// 共通: 運用向けログ
// ==================================================
private static function logNotFound(string $label, \Throwable $e, Request $request): void
{
Log::warning($label, [
'exception' => get_class($e),
'message' => $e->getMessage(),
'url' => $request->fullUrl(),
'user_agent' => $request->userAgent(),
'ip' => $request->ip(),
]);
}
}
3) ServiceProvider で「後付け登録」
ポイント:ExceptionHandler を自前バインドしない。afterResolving() で既存 Handler 解決後に登録します。
<?php declare(strict_types=1);
namespace Tool\General\Infrastructure\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Tool\General\Domain\Exceptions\GeneralExceptionHandler;
final class GeneralServiceProvider extends ServiceProvider
{
public function register(): void
{
// 既存の Handler がコンテナから解決されたタイミングで“後付け”
$this->app->afterResolving(ExceptionHandler::class, function ($handler) {
GeneralExceptionHandler::register($handler);
});
}
public function boot(): void
{
// ビューの名前空間(例:general::errors.not-found)
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'general');
}
}動作確認のためのテスト(Feature)
PHPUnit 11 アトリビュート & Laravel 11 前提。
HTML と JSON の両ケースを確認します。
<?php declare(strict_types=1);
namespace Tool\General\tests\Feature\Exceptions;
use Tests\TestCase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Small;
use Tool\General\Domain\Exceptions\SpotNotFoundException;
use Illuminate\Support\Facades\Route;
#[Group('exception')]
#[Small]
final class GeneralExceptionHandlerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// テスト用ルート:例外を投げるだけ
Route::get('/__throw-spot-not-found', function () {
throw new SpotNotFoundException(103);
})->name('throw.spot');
}
public function test_html_request_returns_404_view(): void
{
$res = $this->get('/__throw-spot-not-found');
$res->assertStatus(404);
$res->assertSee('お探しの事業所が見つかりません');
$res->assertSee('事業所一覧に戻る');
}
public function test_api_request_returns_404_json(): void
{
$res = $this->getJson('/__throw-spot-not-found');
$res->assertStatus(404);
$res->assertJsonFragment([
'message' => 'Spot not found: 103',
]);
}
}まとめ
「アプリの Handler を乗っ取らず、パッケージ側からやさしく拡張」が今回の肝でした。
モジュール固有の例外=固有の文言・導線で返せると、UXも運用もグッと良くなります。
まずは NotFound 系から最小構成で入れて、必要になったら順次足していくのがおすすめです。
今回は以上です!