Lesson 18 — Exceptions and API Errors: TaskFlow needs a meaningful response when someone completes an already completed task. We add a state-transition Action, a dedicated exception and HTTP tests that distinguish business conflicts from unexpected failures.
1. Choose the contract before catching exceptions
This example permits todo or doing to transition to done. Repeating completion returns 409 instead of silently succeeding. That is this lesson's contract, not a universal API requirement: an idempotent API could return the current state. Clients need a documented choice protected by tests.
Invalid input remains 422 with field errors, missing resources remain 404 and unexpected failures remain 500. Do not catch Throwable and convert every failure to 200 or 422. Editing a title cannot repair a database outage, and monitoring needs to recognize server failures.
2. Keep the business exception independent of HTTP
app/Exceptions/TaskStateConflict.php
<?php
namespace App\Exceptions;
use RuntimeException;
final class TaskStateConflict extends RuntimeException
{
public function __construct()
{
parent::__construct('The task state changed. Refresh and try again.');
}
}app/Actions/CompleteTask.php
<?php
namespace App\Actions;
use App\Exceptions\TaskStateConflict;
use App\Models\Task;
final class CompleteTask
{
// Caller must authorize the task. No public write route is added here.
public function handle(Task $task): void
{
if (! $task->exists || Task::query()->whereKey($task->id)
->whereIn('status', ['todo', 'doing'])->update(['status' => 'done']) !== 1) {
throw new TaskStateConflict;
}
$task->refresh();
}
}CompleteTask performs one status-conditional UPDATE and refreshes the model only when exactly one row changes. It does not inspect stale in-memory status and then save unconditionally. Repeating completion on a done task throws TaskStateConflict. An unsaved model is also rejected.
The caller must authorize access; this milestone exposes no public write endpoint. Deletion after loading may produce a conflict inside the Action, while a future controller's initial missing-resource lookup returns 404. If reopening becomes possible, a status predicate does not detect every intermediate change; use a version when full optimistic concurrency checking is required.
A query UPDATE does not invoke per-model observers, as lesson 17 demonstrated. This Action does not yet emit TaskCompleted; notifications require an explicit event and corresponding tests. Refresh reads after the UPDATE rather than guaranteeing an immutable snapshot against all competing writes.
3. Render at the HTTP boundary
bootstrap/app.php
<?php
use App\Exceptions\TaskStateConflict;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
$exceptions->dontReport([TaskStateConflict::class]);
$exceptions->render(function (TaskStateConflict $exception, Request $request) {
if ($request->is('api/*') || $request->expectsJson()) {
return response()->json([
'message' => 'The task state changed. Refresh and try again.',
'code' => 'task_state_conflict',
], 409);
}
return response('The task state changed. Refresh and try again.', 409);
});
})->create();The renderer returns a fixed public message and task_state_conflict code, so clients need not parse English text. Paths under api/* receive JSON even without Accept. Other requests receive JSON when requested or a plain-text 409 response. The latter is not a custom-designed HTML error page.
Only the expected conflict is excluded from exception reporting to reduce error-log noise. Unexpected runtime and database failures remain reportable. Reporting serves operators; rendering serves clients. Hiding details in a response does not mean discarding useful internal diagnostics under appropriate access controls.
4. Preserve standard error information
We do not replace the entire exception handler. Validation retains message and errors, missing routes remain 404 and unexpected failures remain 500. The custom code field belongs only to TaskStateConflict, not every error response. Later authentication needs separate 401/403 tests; rate limiting needs 429 and Retry-After preserved.
Set APP_DEBUG=false in production and rebuild configuration caches during deployment. Never expose arbitrary exception messages, SQL, server paths or stack traces to clients. Review logs for sensitive content as well: library exception messages can contain details that must not become public.
5. Test the real handler
tests/Feature/TaskErrorHandlingTest.php
<?php
namespace Tests\Feature;
use App\Actions\CompleteTask;
use App\Exceptions\TaskStateConflict;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use RuntimeException;
use Tests\TestCase;
class TaskErrorHandlingTest extends TestCase
{
public function test_api_conflict_has_a_stable_code_without_accept_header(): void
{
Route::get('/api/_test/conflict', fn () => throw new TaskStateConflict);
$this->get('/api/_test/conflict')->assertStatus(409)->assertExactJson([
'message' => 'The task state changed. Refresh and try again.',
'code' => 'task_state_conflict',
]);
}
public function test_validation_and_missing_routes_keep_their_status(): void
{
Route::post('/api/_test/validate', fn (Request $request) => $request->validate([
'title' => ['required', 'string', 'max:120'],
]));
$this->postJson('/api/_test/validate', [])->assertUnprocessable()
->assertJsonValidationErrors('title');
$this->get('/api/_test/missing')->assertNotFound()->assertJsonStructure(['message']);
}
public function test_unexpected_failure_is_redacted_with_debug_disabled(): void
{
config(['app.debug' => false]);
Route::get('/api/_test/failure', fn () => throw new RuntimeException('private-database-detail'));
$this->get('/api/_test/failure')->assertStatus(500)
->assertExactJson(['message' => 'Server Error'])
->assertDontSee('private-database-detail');
}
public function test_html_conflict_is_not_a_success_or_redirect(): void
{
Route::get('/_test/conflict', fn () => throw new TaskStateConflict);
$this->get('/_test/conflict')->assertStatus(409)
->assertSeeText('The task state changed.');
}
public function test_completion_rejects_a_repeated_transition(): void
{
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
$task = Task::factory()->create();
app(CompleteTask::class)->handle($task);
$this->assertSame('done', $task->status);
$this->expectException(TaskStateConflict::class);
app(CompleteTask::class)->handle($task);
}
}php artisan test --filter=TaskErrorHandlingTest
php artisan test
The _test routes exist only inside tests, not in the application's route file. Do not deploy a public crash endpoint. The 500 test disables debug and asserts an exact Server Error response; validation checks the title error, while conflict tests cover its stable code and JSON/HTML status.
The Action test uses in-memory SQLite and rejects a repeated transition. It is a sequential test, not a concurrent two-connection test or a benchmark. The milestone suite passed 48 tests with 158 assertions. HTTP authorization is not covered yet because the real endpoint will follow the policy lesson.
6. Exercises
Test completing a doing task, calling the Action after deletion and requesting JSON outside api/* using Accept. Design a client that offers refresh for task_state_conflict without blindly retrying task-creation POST requests after 500. Explain why retry behavior depends on the endpoint's idempotency contract.
Reference: Laravel Error Handling. Navigation: Lesson 17 · Roadmap. Next: caching, rate limits and atomic locks.




No comments yet. Be the first to share your thoughts.