optimus/heimdal
Laravel package for multi-database applications: run migrations, seeders, and artisan commands across multiple tenant or secondary database connections, keeping schemas in sync without duplicating configuration or wiring per-connection logic.
Heimdal is a small, focused package: it replaces Laravel's default HTML/JSON exception rendering with a consistent JSON error envelope for APIs. The core idea is an exception-to-response map in config, plus optional formatters when you need custom payloads.
composer require optimus/heimdal
php artisan vendor:publish --provider="Optimus\Heimdal\Providers\HeimdalServiceProvider"
If you're on Laravel 5.5+ the provider is auto-discovered; otherwise register it in config/app.php. After publishing, open config/heimdal.php first — this is where the package's behavior lives.
namespace App\Exceptions;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as BaseHandler;
use Optimus\Heimdal\ExceptionHandler as HeimdalHandler;
class Handler extends BaseHandler
{
use HeimdalHandler;
}
The trait takes over render() so exceptions become JSON. Your existing report() behavior is left alone, which is important: logging/Sentry still works while the response shape changes.
findOrFail call:Route::get('/api/orders/{order}', function ($id) {
return Order::findOrFail($id);
});
With Accept: application/json, you should get a 404 JSON body rather than an HTML error page:
{
"errors": [
{
"status": 404,
"code": "RESOURCE_NOT_FOUND",
"title": "Resource not found",
"detail": "..."
}
]
}
That's the day-one win: every API consumer gets the same error contract for validation, auth, authorization, missing models, and uncaught exceptions.
Treat the config map as your API error contract. Map every exception you intentionally throw, and map framework exceptions explicitly. A typical starting map:
'handlers' => [
Exception::class => [
'status_code' => 500,
'error_code' => 'INTERNAL_SERVER_ERROR',
'error_message' => 'An internal server error occurred',
],
Illuminate\Database\Eloquent\ModelNotFoundException::class => [
'status_code' => 404,
'error_code' => 'RESOURCE_NOT_FOUND',
'error_message' => 'Resource not found',
],
Illuminate\Auth\AuthenticationException::class => [
'status_code' => 401,
'error_code' => 'UNAUTHENTICATED',
'error_message' => 'Unauthenticated',
],
Illuminate\Auth\Access\AuthorizationException::class => [
'status_code' => 403,
'error_code' => 'FORBIDDEN',
'error_message' => 'This action is unauthorized',
],
Illuminate\Validation\ValidationException::class => [
'status_code' => 422,
'error_code' => 'VALIDATION_ERROR',
'error_message' => 'The given data was invalid',
],
],
Keep error_code values stable and machine-readable; clients should branch on codes, never on messages. Treat adding a new code as an API change.
Use domain exceptions instead of controller return response()->json(...) error paths. Define PaymentFailedException, InsufficientStockException, etc., map each to a status and code, and throw them from services. This keeps controllers thin and makes the error surface auditable in one file.
class PaymentFailedException extends Exception
{
protected $message = 'The payment was declined.';
}
// config/heimdal.php
App\Exceptions\PaymentFailedException::class => [
'status_code' => 402,
'error_code' => 'PAYMENT_FAILED',
'error_message' => 'Payment failed',
],
Custom formatters are the extension point for richer payloads. When the default {status, code, title, detail} shape isn't enough — field-level validation details, a meta object with a gateway reference, a source.pointer for JSON:API — create a formatter extending the package's base formatter and point the handler entry at it. Look in src/Formatters for the exact contract and copy the validation formatter as a starting point. Keep formatters dumb: extract data from the exception, don't query the database or call services.
Let validation and auth exceptions bubble. Don't catch ValidationException in controllers to reshape it; map it once. If you use Form Requests, $request->validate() already throws it. The only thing to verify is that your clients send Accept: application/json (or that you have middleware forcing JSON for /api/*), since content negotiation still drives whether the request is treated as an API call.
Test the contract, not the package. Feature tests should assert status and code:
$this->getJson('/api/orders/999')
->assertStatus(404)
->assertJsonPath('errors.0.code', 'RESOURCE_NOT_FOUND');
$this->postJson('/api/orders', [])
->assertStatus(422)
->assertJsonPath('errors.0.code', 'VALIDATION_ERROR');
This gives you a regression suite you can keep if you later swap Heimdal for a custom handler.
Reporting stays where it is. Keep report() (or Laravel's default reporting) doing logging and Sentry integration. Heimdal only changes rendering. If you need to suppress reporting for expected domain exceptions, do it in report() with $this->shouldntReport() semantics, not in Heimdal config.
Consider the package boundary. Heimdal is a thin convenience layer (122 stars, zero listed dependents, last release January 2025). That's not a reason to avoid it, but keep your error codes and JSON envelope defined in your own docs/tests. If the package becomes unmaintained, replacing the trait with ~50 lines in Handler::render() should be a mechanical change, not a rewrite.
Accept: application/json, requests may still render HTML. Add middleware that forces JSON for API routes, or ensure clients always send the header. This is the most common "Heimdal isn't working" cause.ModelNotFoundException) before broad ones (Exception, RuntimeException). A generic Exception entry that also matches a specific case will silently shadow it.APP_DEBUG leaks internals. Debug mode typically adds trace/previous-exception detail. Confirm production has APP_DEBUG=false and that unexpected 500 responses return only the generic message. Never put $e->getMessage() into the default 500 detail.config/heimdal.php, run php artisan config:cache (or clear it in dev). Avoid closures in config values — they break config caching.NotFoundHttpException (no route) and ModelNotFoundException (no record) are different exceptions. Map both if clients need to distinguish routing errors from missing resources.ThrottleRequestsException (or the framework equivalent for your version) to a 429 with a RATE_LIMITED code, otherwise it falls into the generic 500 bucket.render(), remove ad-hoc try/catch blocks that return error JSON from controllers; mixed envelopes are worse than either style alone. Also check that your Handler doesn't define its own render() that conflicts with the trait — trait methods lose to methods defined directly on the class.composer.json against your Laravel/PHP version before adopting, and pin a minor version. With low dependents and a recent release, you're effectively the QA.error_message values, in formatters using trans(), or in the client. Mixing all three produces inconsistent responses.How can I help you explore Laravel packages today?