Test a Laravel API with Vue

Build a tested Laravel API and responsive Vue incident dashboard with SQLite.

Introduction

30 Second Summary

A dashboard can look fine until a small data change makes a status disappear or a button stop working. Those breakages are easy to miss when nobody checks the exact information passed to the page.

In this project, you will build Incident Desk, a local operations dashboard with a contract-tested Laravel API. A responsive Vue 3 interface turns that protected response into an incident queue you can update from the browser.

What You'll Build

Picture opening Incident Desk to a live incident queue where one click marks an issue resolved through the same tested API that keeps the update in SQLite.

By the end of this project, you'll have:

  • A controlled API response where visiting /api/incidents returns a data wrapper. Only id, title, priority, and status reach the client.
  • A database-backed feature suite that catches drift in the public response contract. It also proves that valid status updates persist.
  • A responsive incident dashboard that presents the queue as a table on wider screens. The same incidents become readable cards on a narrow screen.
  • Secret Mission: Add a typed client-side priority filter without changing the API contract.

Are there any prerequisites?

You should be comfortable with PHP, TypeScript, HTTP verbs, relational persistence, and terminal workflows.

Your Mac needs PHP 8.3 or later plus Node.js 20.19+ or 22.12+. Composer, npm, VS Code, and PHP SQLite support are also required.

Before We Start

This opening checkpoint clarifies the purpose behind Incident Desk before the hands-on work begins. The same-origin application uses database-backed contract tests, explicit API Resources, and validated updates to protect a stable boundary from Laravel persistence to a responsive Vue client.

Set Up the Laravel and Vue Workspace

Incident Desk joins Laravel and Vue in one local application. Version drift can hide the API architecture lesson behind unrelated runtime errors.

This step checks PHP, Composer, Node.js, npm, and SQLite support. You will then scaffold the project before pinning its Vite build dependencies.

In this step, get ready to:
  • Confirm that your local development tools meet the project requirements.
  • Scaffold the Laravel application inside an incident-desk directory.
  • Install the pinned frontend dependencies before producing a successful build.
Check your local toolchain

Laravel requires PHP 8.3 or later. Vite requires Node.js 20.19+ or 22.12+.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter to open it.
  • Check the required tools by running these commands:
php -v
composer --version
node -v
npm -v
php -m | grep -i sqlite

What Do These Checks Prove?

  • PHP must report version 8.3 or later.
  • Node.js must report version 20.19+ or 22.12+.
  • The Composer check must print an installed version.
  • The npm check must print an installed version.
  • The final check must list SQLite support from PHP.

🙋‍♀️ Did a Tool Check Fail?

  • Upgrade PHP through the installation method you used earlier if its version is below 8.3.
  • Upgrade Node.js through the installation method you used earlier if its version does not meet the Vite requirement.
  • Enable PHP PDO SQLite support if the final check lists no SQLite module.
  • Reinstall any tool whose command is missing.

Ask for help with the output: help me diagnose my Laravel toolchain checks.

Once every check passes, your machine is ready for the pinned project scaffold.

Scaffold the Laravel project

A Laravel scaffold provides the framework structure that the API and browser client will share. Pinning the application skeleton keeps that starting structure consistent.

  • Move to your Desktop before creating the pinned project by running these commands:
cd ~/Desktop
composer create-project laravel/laravel incident-desk v13.10.1

What Does This Scaffold Create?

The ~/Desktop path gives the project a predictable location. Composer creates the incident-desk directory from application skeleton v13.10.1.

The new directory includes Laravel's application files. It also includes the starting frontend configuration used by Vite.

  • Let Composer return control to the Terminal prompt before continuing.
  • Press Cmd+Space to open Spotlight.
  • Type VS Code and press Enter to open it.

You should see the VS Code editor window.

  • Click File in the top menu bar.
  • Select Open Folder....

You will see a folder picker for choosing the workspace.

  • Select the incident-desk directory on your Desktop.
  • Click Open.
  • Confirm that you trust the directory if VS Code displays a workspace trust prompt.

The Explorer sidebar should now show the files inside incident-desk.

Project Directory Missing?

Return to Terminal to find the first Composer error. Resolve that error before rerunning the scaffold command from your Desktop.

Ask for help with the failure: help me fix my Laravel scaffold command.

Pin dependencies and prove the build

The package.json file defines the frontend packages available to the project. The temporary resources/js/app.js entry lets you test the scaffold before building the Vue dashboard.

  • Select vite.config.js from the Explorer sidebar.
  • Read the configured input value.

You should see resources/js/app.js listed as the current Vite entry.

  • Select package.json from the Explorer sidebar.
  • Replace the contents of package.json by pasting this code:
{
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build",
    "dev": "vite"
  },
  "dependencies": {
    "vue": "3.5.43"
  },
  "devDependencies": {
    "@tailwindcss/vite": "4.3.3",
    "@vitejs/plugin-vue": "6.0.9",
    "concurrently": "10.0.3",
    "laravel-vite-plugin": "3.2.0",
    "tailwindcss": "4.3.3",
    "vite": "8.3.0"
  },
  "optionalDependencies": {
    "@laravel/multiplex": "0.4.4"
  }
}

What Does This File Control?

  • The dependencies section adds Vue as the browser runtime dependency.
  • The @tailwindcss/vite and tailwindcss packages satisfy the imports in Laravel's starting vite.config.js file.
  • The @vitejs/plugin-vue package prepares Vite to compile the dashboard component later.
  • The @laravel/multiplex optional dependency preserves the scaffold's local development process runner.
  • Exact version values keep package resolution consistent across machines.
  • Save package.json.
  • Confirm that VS Code shows no JSON syntax problems in the saved file.

Seeing a JSON Problem?

Check every comma and quotation mark against the reference below. A missing comma prevents npm from reading the dependency file.

Ask for help with the file: help me find the syntax problem in package.json.

✔️ Awesome, I've got everything!

Great. Double check that package.json is saved before installing the dependencies.

ⓧ I'd like to double check the full code

Compare your complete package.json file with this reference:

{
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build",
    "dev": "vite"
  },
  "dependencies": {
    "vue": "3.5.43"
  },
  "devDependencies": {
    "@tailwindcss/vite": "4.3.3",
    "@vitejs/plugin-vue": "6.0.9",
    "concurrently": "10.0.3",
    "laravel-vite-plugin": "3.2.0",
    "tailwindcss": "4.3.3",
    "vite": "8.3.0"
  },
  "optionalDependencies": {
    "@laravel/multiplex": "0.4.4"
  }
}

How to Use This Reference

Compare every key and version with your saved file. The structure must match before npm can install the pinned packages.

  • Click View in the VS Code menu bar.
  • Select Terminal.

The integrated terminal opens inside the incident-desk directory.

  • Install the pinned frontend dependencies by running:
npm install

What Does This Install?

npm reads package.json before downloading the listed runtime and development packages. The exact version values keep the installation aligned with this project.

  • Let the installation finish before continuing.

You should see an installation summary before the terminal returns to its prompt. No dependency error should remain in the output.

Dependency Installation Failed?

  • Confirm that the integrated terminal is inside incident-desk.
  • Set vite in package.json to 8.3.0 if the error reports vite@undefined.
  • Save package.json.
  • Retry the dependency installation by running:
npm install

Avoid --force and --legacy-peer-deps. The compatible Vite pin resolves the dependency tree without bypassing npm's checks.

Still blocked? Help me diagnose the remaining npm installation error.

Before you run the build, do you expect Vite to resolve the temporary JavaScript entry and every pinned package?

  • Build the production frontend bundle by running:
npm run build

What Does This Build Prove?

Vite reads the temporary resources/js/app.js entry from vite.config.js. A successful production build proves that the pinned frontend packages work together.

You should see Vite report a successful production build. The output should contain no missing-package or entry-point errors.

Your workspace is ready. Laravel now has a verified frontend toolchain for the dashboard you will connect later.

Build Did Not Complete?

The minimum-release-age warning does not stop this build. The unresolved Tailwind import is the blocking error.

  • Confirm that package.json lists @tailwindcss/vite and tailwindcss at version 4.3.3.
  • Save package.json.
  • Install the updated dependency tree by running:
npm install
  • Retry the production build by running:
npm run build

If another package or entry remains unresolved, help me trace the next Vite build error.

The pinned Laravel and Vue workspace is ready. Next, you will give Incident Desk its first working incident endpoint.

Ship the First Incident Endpoint

Your pinned Laravel workspace can already build its Vue assets. The next goal is a database-backed incident API that answers in the browser.

A direct Eloquent collection gives you a concrete contract to challenge. You will begin with the simplest response before inspecting its public JSON shape.

In this step, get ready to:
  • Create the incident model plus its database schema.
  • Seed three predictable incidents into SQLite.
  • Expose the raw incident collection through an API route.
Generate the incident model and schema

The Incident model represents one stored operations issue. Its migration defines how SQLite stores that issue.

  • Switch back to the Terminal window from earlier.
  • Generate the model plus controller by running these commands:
php artisan make:model Incident
php artisan make:controller IncidentController

What did Laravel create?

  • The model command creates app/Models/Incident.php for database interactions.
  • The controller command creates app/Http/Controllers/IncidentController.php for HTTP request handling.
  • Confirm both generated files are visible in the Explorer sidebar in VS Code.

You should see Incident.php under app/Models. You should also see IncidentController.php under app/Http/Controllers.

Missing a generated file?

Confirm the Terminal prompt is inside the incident-desk directory. Laravel writes generated classes relative to that directory.

Ask for help with the generator output: help me find my generated Laravel model or controller

A migration gives the incidents table a repeatable structure. The deterministic filename keeps this project artifact consistent.

  • Create database/migrations/2026_09_24_000000_create_incidents_table.php from the VS Code Explorer sidebar.
    • Alternatively, you can run php artisan make:migration create_incidents_table
    • Doing so will create the same file, but 00000 will be replaced with the exact timestamp you ran the command.
  • Paste this table definition into the new file:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('incidents', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('priority');
            $table->string('status')->default('open');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('incidents');
    }
};

What does this migration define?

  • The up() method creates the incidents table.
  • The table stores a title plus a priority.
  • The status column defaults to open.
  • The timestamp columns record when each row was created or updated.
  • The down() method removes the table during a rollback.
  • Save database/migrations/2026_09_24_000000_create_incidents_table.php.

This reset deletes tables inside only this new local database. There is no project data to preserve yet.

  • Build the local database schema by running:
php artisan migrate:fresh --seed

You should see the migrations complete without a failure. The local database now has an incidents table.

Migration did not complete?

Check that the migration filename ends in _create_incidents_table.php. Confirm every brace in the file matches the reference above.

Ask for help with the migration: help me debug this incident migration

The model needs an explicit list of fields that can receive values together. This lets the seeder create complete incident records through Incident::create().

  • Select app/Models/Incident.php in the VS Code Explorer sidebar.
  • Replace its contents with this model:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['title', 'priority', 'status'])]
class Incident extends Model
{
    //
}

What does this model allow?

  • The model connects the Incident class to the conventional incidents table.
  • The Fillable attribute allows grouped assignment for the title.
  • The same attribute allows grouped assignment for the priority.
  • The same attribute allows grouped assignment for the status.
  • Save app/Models/Incident.php.

The saved model should now show one Fillable attribute above the class. It should list the three incident fields from the migration.

Seeing a model syntax problem?

Check the namespace backslashes in app/Models/Incident.php. Confirm the Fillable attribute sits directly above the class declaration.

Ask for help with the model: help me compare my Incident model

Seed predictable incident data

Predictable seed data gives every endpoint request the same starting point. The three records cover critical plus high plus medium priorities.

  • Select database/seeders/DatabaseSeeder.php in the VS Code Explorer sidebar.
  • Replace its contents with these three incident records:
<?php

namespace Database\Seeders;

use App\Models\Incident;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        Incident::create([
            'title' => 'Checkout latency above threshold',
            'priority' => 'critical',
            'status' => 'investigating',
        ]);

        Incident::create([
            'title' => 'Delayed fulfillment webhook',
            'priority' => 'high',
            'status' => 'open',
        ]);

        Incident::create([
            'title' => 'Stale search index detected',
            'priority' => 'medium',
            'status' => 'open',
        ]);
    }
}

Why use fixed seed records?

Each Incident::create() call inserts one row through the model. Fixed values make the browser response repeatable after every database reset.

The status values also give the endpoint a mix of open plus investigating incidents. That variation makes the response useful for the dashboard later.

  • Save database/seeders/DatabaseSeeder.php.
  • Rebuild the database with the new seed records by running:
php artisan migrate:fresh --seed

You should see the migration plus seeding work complete without a failure. The rebuilt database now contains the same three incidents every time.

Seeder did not complete?

Confirm DatabaseSeeder.php imports App\Models\Incident. Check that every record contains a title plus priority plus status.

Ask for help with the seed data: help me debug my incident seeder

Expose and inspect the raw endpoint

Laravel loads API routes separately from browser page routes. The API route will map an incoming incidents request to the controller.

  • Create routes/api.php from the VS Code Explorer sidebar.
  • Paste this route into the new file:
<?php

use App\Http\Controllers\IncidentController;
use Illuminate\Support\Facades\Route;

Route::get('incidents', [IncidentController::class, 'index']);

What does this route connect?

The route accepts a GET request for incidents. Laravel applies the API prefix when it loads this file.

The request then reaches the controller's index method. That method will return the stored incident collection.

  • Save routes/api.php.

You should now see api.php beside the other route files in the VS Code Explorer sidebar.

The application bootstrap decides which route files Laravel loads. It also keeps API failures in JSON form when the request expects JSON.

  • Select bootstrap/app.php in the VS Code Explorer sidebar.
  • Replace its contents with this routing configuration:
<?php

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',
        api: __DIR__.'/../routes/api.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(),
        );
    })->create();

How does API routing become active?

  • The api entry loads routes/api.php with Laravel's API routing behavior.
  • The API route becomes available beneath the /api prefix.
  • The exception rule preserves JSON responses for API paths.
  • The same rule preserves JSON responses when a request explicitly expects JSON.
  • Save bootstrap/app.php.

The combined development command runs in the foreground. Keep that Terminal window active after the local processes start.

  • Start the local development processes by running:
composer run dev

You should see the local development processes remain active. Terminal also prints the Laravel URL you will use for the endpoint check.

Development processes stopped?

Read the first reported failure in Terminal. Confirm the dependency installation from the previous step completed inside incident-desk.

Ask for help with the local stack: help me debug composer run dev

The route now needs a controller method that reads every incident. Ordering by descending ID places the newest seeded row first.

  • Switch back to app/Http/Controllers/IncidentController.php in VS Code.
  • Replace its contents with this temporary controller:
<?php

namespace App\Http\Controllers;

use App\Models\Incident;

class IncidentController extends Controller
{
    public function index()
    {
        return Incident::query()->orderByDesc('id')->get();
    }
}

What does this controller return?

The index() method starts an incident query. orderByDesc('id') puts higher IDs first.

The get() call retrieves every matching model. Laravel serializes that collection for the HTTP response.

  • Save app/Http/Controllers/IncidentController.php.

✔️ Awesome, I've got everything!

Your schema plus seed data plus API route are ready. Double check that every changed file is saved before loading the endpoint.

ⓧ I'd like to double check the full code

Compare these complete files with your project:

<?php

namespace App\Http\Controllers;

use App\Models\Incident;

class IncidentController extends Controller
{
    public function index()
    {
        return Incident::query()->orderByDesc('id')->get();
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['title', 'priority', 'status'])]
class Incident extends Model
{
    //
}
<?php

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',
        api: __DIR__.'/../routes/api.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(),
        );
    })->create();
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('incidents', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('priority');
            $table->string('status')->default('open');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('incidents');
    }
};
<?php

namespace Database\Seeders;

use App\Models\Incident;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        Incident::create([
            'title' => 'Checkout latency above threshold',
            'priority' => 'critical',
            'status' => 'investigating',
        ]);

        Incident::create([
            'title' => 'Delayed fulfillment webhook',
            'priority' => 'high',
            'status' => 'open',
        ]);

        Incident::create([
            'title' => 'Stale search index detected',
            'priority' => 'medium',
            'status' => 'open',
        ]);
    }
}
{
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build",
    "dev": "vite"
  },
  "dependencies": {
    "vue": "3.5.43"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "6.0.9",
    "concurrently": "10.0.3",
    "laravel-vite-plugin": "3.2.0",
    "vite": "8.3.1"
  }
}
<?php

use App\Http\Controllers\IncidentController;
use Illuminate\Support\Facades\Route;

Route::get('incidents', [IncidentController::class, 'index']);

Before you load the route, predict whether the incidents will sit inside a named property or at the root.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type the name of your preferred browser.
  • Press Enter to open the browser.
  • Enter the Laravel URL printed in Terminal with /api/incidents appended.

What should I see?

  • The response is a root array containing three incidents.
  • The first incident is Stale search index detected because its ID is highest.
  • The second incident is Delayed fulfillment webhook.
  • The final incident is Checkout latency above threshold.
  • Each object exposes the public incident details plus raw persistence timestamps.
  • Those timestamps appear as created_at plus updated_at.

That persistence-shaped response is the intended baseline for this step. You can now see that the database model determines the endpoint's public shape.

That is a solid milestone. Your database-backed incident endpoint now responds with deterministic records in the browser.

Endpoint not loading?

Confirm composer run dev is still active in Terminal. Check that the browser path ends with /api/incidents.

Confirm routes/api.php plus IncidentController.php are saved. Help me debug the incident endpoint

Your first incident endpoint is live. Next, you will turn its persistence-shaped response into a contract failure you can inspect.

Make the API Contract Fail on Purpose

Your Laravel API now serves seeded incidents from SQLite. The endpoint works. You now have a real response to challenge.

The raw response couples public JSON to Eloquent persistence fields. A strict feature test turns that hidden architecture problem into a failure you can inspect.

In this step, get ready to:
  • Create an isolated database-backed feature test.
  • Define the exact public response contract for incidents.
  • Run the test suite to inspect the deliberate contract failure.
Create the isolated test shell

The RefreshDatabase trait resets database state around each test. This gives the contract test one known incident without depending on the three development records.

  • Return to VS Code.
  • Expand the tests folder in the Explorer sidebar.
  • Expand the Feature folder.
  • Right-click the Feature folder.
  • Select New File.
  • Enter IncidentApiTest.php as the file name.
  • Press Enter.
  • Build the test shell by pasting the code below into tests/Feature/IncidentApiTest.php:
<?php

namespace Tests\Feature;

use App\Models\Incident;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class IncidentApiTest extends TestCase
{
    use RefreshDatabase;
}

What Does This Test Shell Do?

  • The IncidentApiTest class inherits Laravel's application testing tools from TestCase.
  • The Incident import lets the test create its own database record.
  • The RefreshDatabase trait isolates the database state used by this test.
  • Save tests/Feature/IncidentApiTest.php.
  • Confirm the Explorer sidebar lists IncidentApiTest.php beneath the Feature folder.

File Missing From Explorer?

Confirm that IncidentApiTest.php sits inside tests/Feature. Move the file there if VS Code created it in a different folder.

Use this if the file still does not appear: help me locate my Laravel feature test file.

Define the public response contract

A response contract defines the exact shape that clients may depend on. This test requires one incident beneath a data wrapper with four public fields.

  • Place your cursor at the end of use RefreshDatabase; in IncidentApiTest.php.
  • Press Enter twice to leave one blank line between the trait and the method.
  • Add the contract test by pasting this method before the class's final closing brace:
    public function test_index_contract_hides_persistence_fields(): void
    {
        $incident = Incident::create([
            'title' => 'Payment latency',
            'priority' => 'critical',
            'status' => 'open',
        ]);

        $this->getJson('/api/incidents')
            ->assertOk()
            ->assertExactJson([
                'data' => [[
                    'id' => $incident->id,
                    'title' => 'Payment latency',
                    'priority' => 'critical',
                    'status' => 'open',
                ]],
            ]);
    }

What Does This Contract Require?

  • The Incident::create call inserts one predictable record into the isolated test database.
  • The getJson helper sends a JSON request to /api/incidents.
  • The assertOk assertion requires the request to succeed.
  • The assertExactJson assertion compares the complete response body against the public contract.
  • The expected incident exposes id, title, priority, and status beneath data.
  • Save tests/Feature/IncidentApiTest.php.
  • Confirm the method ends inside the IncidentApiTest class.

Seeing PHP Syntax Warnings?

Confirm that the method sits after use RefreshDatabase; and before the class's final closing brace. Check that both closing braces remain at the bottom of the file.

Use this for a closer comparison: help me fix the structure of IncidentApiTest.php.

✔️ Awesome, I've got everything!

Great. Double-check that you saved tests/Feature/IncidentApiTest.php before running the test suite.

ⓧ I'd like to double check the full code

Compare your complete tests/Feature/IncidentApiTest.php file with this reference.

<?php

namespace Tests\Feature;

use App\Models\Incident;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class IncidentApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_index_contract_hides_persistence_fields(): void
    {
        $incident = Incident::create([
            'title' => 'Payment latency',
            'priority' => 'critical',
            'status' => 'open',
        ]);

        $this->getJson('/api/incidents')
            ->assertOk()
            ->assertExactJson([
                'data' => [[
                    'id' => $incident->id,
                    'title' => 'Payment latency',
                    'priority' => 'critical',
                    'status' => 'open',
                ]],
            ]);
    }
}
Run the test and inspect the failure

The test now compares the intended public contract with the live endpoint behavior. The test runner makes any mismatch visible.

Before you run the suite, predict whether the raw endpoint will satisfy the exact response contract.

  • Select Terminal from the top menu in VS Code.
  • Select New Terminal.
  • Confirm the terminal prompt is inside the incident-desk folder.
  • Test the API contract by running this command:
php artisan test

What Does This Test Reveal?

  • The command runs the application's test suite.
  • The contract test creates one incident in a refreshed test database.
  • The exact JSON assertion compares the expected data wrapper with the endpoint response.
  • The comparison fails because the controller returns a root array.
  • The response also exposes created_at and updated_at from persistence.

This failure is intentional. Your test has exposed the unstable boundary hidden behind the working endpoint.

  • Read the JSON comparison in the failed test output.
  • Confirm the actual response begins with a root array.
  • Confirm the actual incident includes created_at and updated_at.
  • Keep the failed test exactly as written for the next step.

Test Not Failing?

Confirm that you saved IncidentApiTest.php before running the suite. Check that the temporary controller still returns the raw Eloquent collection from the previous step.

Use this if the result still differs: help me diagnose why my incident contract test is not failing.

Your red test now protects the boundary that the raw endpoint violates. Next, you will stabilize that boundary and make the complete incident suite pass.

Stabilize and Test the API Boundary

Your failing contract test has exposed the cost of returning Eloquent models directly. The database shape currently controls the public JSON response.

You will use a Laravel API Resource to define the response contract. A Form Request will validate updates. Route model binding will handle incident lookup.

In this step, get ready to:
  • Define the public incident response with an API Resource.
  • Protect status updates with validation and route model binding.
  • Expand the feature suite to cover successful updates and failed requests.
Generate the boundary classes

An API Resource translates a database model into a deliberate public representation. This keeps persistence fields outside the response unless you explicitly expose them.

  • Generate IncidentResource from the terminal in your incident-desk project by running this command:
php artisan make:resource IncidentResource

What does this command create?

The generator creates app/Http/Resources/IncidentResource.php. This class becomes the translation layer between each Incident model and its public fields.

  • Confirm the Http/Resources folder in the VS Code Explorer now contains IncidentResource.php.
  • Generate UpdateIncidentRequest from the same terminal by running this command:
php artisan make:request UpdateIncidentRequest

Why generate a request class?

The generator creates app/Http/Requests/UpdateIncidentRequest.php. The request class gives status authorization and validation a dedicated home.

  • Confirm the Http/Requests folder in the VS Code Explorer now contains UpdateIncidentRequest.php.

Generator command failed?

Check that your terminal is inside the incident-desk folder. The folder should contain the artisan file.

Use help me troubleshoot the Laravel generator if the command still fails.

The resource controls the collection response one incident at a time. Its four-field array becomes the API's stable public shape.

  • Select app/Http/Resources/IncidentResource.php in the VS Code Explorer.
  • Replace the file contents with this resource:
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class IncidentResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'priority' => $this->priority,
            'status' => $this->status,
        ];
    }
}

What does this resource expose?

  • The toArray() method defines the exact fields that leave the application.
  • The id and title fields identify the incident.
  • The priority and status fields describe its operational state.
  • The timestamp fields stay outside the response because they are absent from this array.
  • Save app/Http/Resources/IncidentResource.php.
  • Confirm the saved resource lists exactly four response fields in the editor.

✔️ Awesome, I've got everything!

Your resource now defines the four-field incident contract.

ⓧ I'd like to double check the full code

Compare your complete IncidentResource.php file with this reference:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class IncidentResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'priority' => $this->priority,
            'status' => $this->status,
        ];
    }
}

What should match?

The namespace and imports should match the reference. The toArray() method should return only the four public fields.

Defining the resource establishes the contract. The controller must return that resource for the endpoint to use it.

  • Select app/Http/Controllers/IncidentController.php in the VS Code Explorer.
  • Replace the file contents with this controller:
<?php

namespace App\Http\Controllers;

use App\Http\Requests\UpdateIncidentRequest;
use App\Http\Resources\IncidentResource;
use App\Models\Incident;

class IncidentController extends Controller
{
    public function index()
    {
        return IncidentResource::collection(
            Incident::query()->orderByDesc('id')->get()
        );
    }

    public function update(UpdateIncidentRequest $request, Incident $incident)
    {
        $incident->update($request->validated());

        return new IncidentResource($incident);
    }
}

How does the controller protect the boundary?

  • The index() method passes the ordered models through IncidentResource::collection().
  • The resource collection adds the expected data wrapper.
  • The update() method receives the matched Incident through route model binding.
  • The controller persists only values returned by validated().
  • Save app/Http/Controllers/IncidentController.php.

Before you rerun the contract test, do you expect the resource collection to hide both persistence timestamps?

  • Test the corrected index contract by running this command:
php artisan test

What does this test prove?

The test sends a JSON request through the real route and controller. Its exact assertion confirms the response contains the data wrapper with only four public fields.

That contract test now passes. Your database timestamps no longer leak through the API boundary.

Index contract still failing?

Check that index() returns IncidentResource::collection(). A direct Eloquent collection still produces the raw root array.

Use help me compare the resource response to inspect the remaining difference.

✔️ Awesome, I've got everything!

Your controller now transforms collection responses and supports validated incident updates.

ⓧ I'd like to double check the full code

Compare your complete IncidentController.php file with this reference:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\UpdateIncidentRequest;
use App\Http\Resources\IncidentResource;
use App\Models\Incident;

class IncidentController extends Controller
{
    public function index()
    {
        return IncidentResource::collection(
            Incident::query()->orderByDesc('id')->get()
        );
    }

    public function update(UpdateIncidentRequest $request, Incident $incident)
    {
        $incident->update($request->validated());

        return new IncidentResource($incident);
    }
}

What should match?

The controller should import both boundary classes. Its two methods should return Incident resources for collection and item responses.

Wire validation and model binding

The update endpoint needs a strict set of accepted status values. Keeping those rules in the Form Request prevents validation logic from crowding the controller.

  • Select app/Http/Requests/UpdateIncidentRequest.php in the VS Code Explorer.
  • Replace the generated contents with this request class:
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateIncidentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'status' => ['required', Rule::in(['open', 'investigating', 'resolved'])],
        ];
    }
}

What does this request allow?

  • The authorize() method permits this local request to continue to validation.
  • The required rule rejects requests without a status.
  • The Rule::in() rule accepts only open, investigating, or resolved.
  • Invalid JSON requests receive a validation response before the controller updates the database.
  • Save app/Http/Requests/UpdateIncidentRequest.php.
  • Confirm the editor shows all three allowed status values inside Rule::in().

✔️ Awesome, I've got everything!

Your request now accepts only the three supported incident statuses.

ⓧ I'd like to double check the full code

Compare your complete UpdateIncidentRequest.php file with this reference:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateIncidentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'status' => ['required', Rule::in(['open', 'investigating', 'resolved'])],
        ];
    }
}

What should match?

The request should authorize the operation. Its rules should contain one required status field with three allowed values.

An API resource route connects HTTP verbs to controller methods. The incident route parameter also supplies the model name needed for implicit binding.

  • Select routes/api.php in the VS Code Explorer.
  • Replace the temporary route with this API resource route:
<?php

use App\Http\Controllers\IncidentController;
use Illuminate\Support\Facades\Route;

Route::apiResource('incidents', IncidentController::class)
    ->only(['index', 'update']);

What does this route register?

The route registers only the incident collection and update actions. Laravel resolves the {incident} route value into the bound Incident model used by the controller.

  • Save routes/api.php.

Before you check the route change, do you expect the existing index contract to remain green?

  • Check the resource route against the existing test by running this command:
php artisan test

What does this check confirm?

The passing index test confirms the new resource route still reaches index(). It also confirms the resource response still matches the exact public contract.

The collection contract stays green after the route change. The update path is now ready for its own tests.

Route change broke the index test?

Check that the resource name is exactly incidents. Confirm that index remains inside the only() list.

Use help me debug the incident API route if the endpoint no longer reaches the controller.

✔️ Awesome, I've got everything!

Your API route now exposes only incident listing and status updates.

ⓧ I'd like to double check the full code

Compare your complete routes/api.php file with this reference:

<?php

use App\Http\Controllers\IncidentController;
use Illuminate\Support\Facades\Route;

Route::apiResource('incidents', IncidentController::class)
    ->only(['index', 'update']);

What should match?

The route should point to IncidentController. The action list should contain only index and update.

Expand the feature test suite

Database-backed feature tests prove that routing and validation work together. They also confirm that a successful response represents a real persisted update.

  • Select tests/Feature/IncidentApiTest.php in the VS Code Explorer.
  • Select the existing test_index_contract_hides_persistence_fields() method.
  • Replace the selected method with these two contract tests:
    public function test_index_contract_hides_persistence_fields(): void
    {
        $incident = Incident::create(['title' => 'Payment latency', 'priority' => 'critical', 'status' => 'open']);
        $this->getJson('/api/incidents')->assertOk()->assertExactJson(['data' => [['id' => $incident->id, 'title' => 'Payment latency', 'priority' => 'critical', 'status' => 'open']]]);
    }

    public function test_status_can_be_updated(): void
    {
        $incident = Incident::create(['title' => 'Fulfillment delay', 'priority' => 'high', 'status' => 'investigating']);
        $this->patchJson("/api/incidents/{$incident->id}", ['status' => 'resolved'])->assertOk()->assertJsonPath('data.status', 'resolved');
        $this->assertDatabaseHas('incidents', ['id' => $incident->id, 'status' => 'resolved']);
    }

What does the update test prove?

  • The test creates an investigating incident inside the isolated test database.
  • The patchJson() request sends resolved through the real update route.
  • The response assertion checks the transformed resource payload.
  • The database assertion confirms the new status was persisted.
  • Save tests/Feature/IncidentApiTest.php.

Before you run the expanded suite, do you expect the response assertion and database assertion to agree?

  • Run the index and successful-update tests with this command:
php artisan test

What does this run exercise?

The suite now checks both reading and updating incidents. The update test crosses the route and Form Request before checking the SQLite database.

Both incident tests now pass. The successful response matches a persisted resolved status.

Update test failing?

Check that the controller passes $request->validated() into update(). Confirm that it returns a new IncidentResource.

Use help me debug the incident update test to trace the request.

Successful requests cover only half of the boundary. The final tests prove invalid data and missing incident IDs cannot reach a successful update.

  • Place your cursor after test_status_can_be_updated() inside the test class.
  • Add these two failure-path tests:
    public function test_unknown_status_is_rejected(): void
    {
        $incident = Incident::create(['title' => 'Search lag', 'priority' => 'medium', 'status' => 'open']);
        $this->patchJson("/api/incidents/{$incident->id}", ['status' => 'closed'])->assertUnprocessable()->assertJsonValidationErrors(['status']);
    }

    public function test_missing_incident_returns_not_found(): void
    {
        $this->patchJson('/api/incidents/999999', ['status' => 'resolved'])->assertNotFound();
    }

What do the failure tests protect?

  • The invalid-status test expects a 422 response with a status validation error.
  • The missing-incident test expects route model binding to return 404 for an unknown ID.
  • Together these tests protect validation and resource lookup behavior.
  • Save tests/Feature/IncidentApiTest.php.

Before the final run, do you expect all four incident scenarios to pass through the same API boundary?

  • Verify the complete incident API suite by running this command:
php artisan test

What does the green suite cover?

The suite checks the exact collection shape and a persisted status update. It also checks invalid status handling and missing incident behavior.

You should see every incident API test pass. The endpoint contract is now protected from storage leaks and invalid updates.

One test still failing?

Use the failing method name to isolate the boundary that needs attention. Compare the response fields for the index test or the request rules for the validation test.

Use help me diagnose the failing incident test with the terminal output.

✔️ Awesome, I've got everything!

Your test file now protects the collection contract and every required update outcome.

ⓧ I'd like to double check the full code

Compare your complete IncidentApiTest.php file with this reference:

<?php

namespace Tests\Feature;

use App\Models\Incident;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class IncidentApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_index_contract_hides_persistence_fields(): void
    {
        $incident = Incident::create(['title' => 'Payment latency', 'priority' => 'critical', 'status' => 'open']);
        $this->getJson('/api/incidents')->assertOk()->assertExactJson(['data' => [['id' => $incident->id, 'title' => 'Payment latency', 'priority' => 'critical', 'status' => 'open']]]);
    }

    public function test_status_can_be_updated(): void
    {
        $incident = Incident::create(['title' => 'Fulfillment delay', 'priority' => 'high', 'status' => 'investigating']);
        $this->patchJson("/api/incidents/{$incident->id}", ['status' => 'resolved'])->assertOk()->assertJsonPath('data.status', 'resolved');
        $this->assertDatabaseHas('incidents', ['id' => $incident->id, 'status' => 'resolved']);
    }

    public function test_unknown_status_is_rejected(): void
    {
        $incident = Incident::create(['title' => 'Search lag', 'priority' => 'medium', 'status' => 'open']);
        $this->patchJson("/api/incidents/{$incident->id}", ['status' => 'closed'])->assertUnprocessable()->assertJsonValidationErrors(['status']);
    }

    public function test_missing_incident_returns_not_found(): void
    {
        $this->patchJson('/api/incidents/999999', ['status' => 'resolved'])->assertNotFound();
    }
}

What should match?

The class should contain four test methods. Each method should cover one observable API contract or failure path.

Your API boundary now has a controlled response shape and tested update behavior. Next up, you will connect the Vue dashboard to this stable contract.

Connect the Responsive Vue Dashboard

Your Laravel API now protects a stable JSON contract with passing database-backed tests. The browser can rely on that boundary without knowing how incidents are stored.

This step turns the contract into a responsive Vue 3 dashboard. You will see loading, error, empty, list, and update states through the same application.

In this step, get ready to:
  • Connect the Laravel route to the Vue mount point.
  • Build the typed incident dashboard.
  • Verify the responsive layout plus persisted updates.
Wire the Vue entry into Laravel

A Blade view provides the browser page that contains the Vue mount point. Vite then compiles the client entry loaded by that page.

Why One Laravel Application?

Serving the Vue client from Laravel keeps browser requests on the same origin. Relative requests can reach /api/incidents without extra CORS configuration.

A separate frontend server would add coordination work without strengthening the tested API contract.

  • In the VS Code Explorer sidebar, expand resources/views.
  • Create app.blade.php inside resources/views.
  • Paste this mount page into app.blade.php:
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Incident Desk</title>
    @vite('resources/js/app.ts')
</head>
<body>
    <div id="app"></div>
</body>
</html>

What Does This View Do?

  • The @vite('resources/js/app.ts') directive loads the compiled frontend entry.
  • The app element gives Vue a specific place to mount the dashboard.
  • The viewport metadata lets the responsive layout follow the browser width on mobile devices.
  • Switch back to routes/web.php from earlier.
  • Replace its contents with this root route:
<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('app');
});

How Does the Root Route Work?

The root route returns the app view. A request to / now reaches the page containing the Vue mount point.

  • Save both files.
  • Confirm the Explorer sidebar lists resources/views/app.blade.php.
  • Check that the existing backend contract remains green by running:
php artisan test

What Does This Check Prove?

The test suite confirms that the new web route has not disturbed the incident API. The exact collection contract plus the update behaviors remain protected.

You should see the incident feature tests pass.

Did a Backend Test Fail?

Check that routes/web.php imports the Route facade. Confirm the incident route definitions still remain in routes/api.php.

If the failure persists, help me debug the Laravel test failure.

The frontend entry connects the Vue compiler to your component tree. It also imports the shared stylesheet before mounting the application.

  • Switch back to vite.config.js in the project folder.
  • Replace its contents with this Vue-enabled configuration:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/js/app.ts'], refresh: true }),
        vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false } } }),
    ],
});

What Does This Configuration Do?

  • The Laravel plugin treats resources/js/app.ts as the browser entry.
  • The Vue plugin compiles the component template in App.vue.
  • Asset URL transformation stays compatible with Laravel's Vite integration.
  • Save vite.config.js.
  • Create app.ts inside resources/js.
  • Paste this application entry into app.ts:
import '../css/app.css';
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

How Does the App Start?

  • The stylesheet import brings the dashboard styles into the frontend bundle.
  • The App import provides the root Vue component.
  • The mount call attaches that component to the app element in the Blade view.
  • Save resources/js/app.ts.
  • Confirm the Explorer sidebar lists resources/js/app.ts.

Can't Find the Frontend Entry?

Confirm app.ts sits directly inside resources/js. Check that its extension is .ts.

If the paths still look different, help me check the Laravel Vite entry files.

Build the typed incident dashboard

The TypeScript data shapes mirror the four public fields guaranteed by the API contract. Component state then tracks the request lifecycle plus the incident currently being updated.

  • Create App.vue inside resources/js.
  • Paste this typed component logic into App.vue:
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
type IncidentPriority = 'critical' | 'high' | 'medium';
type IncidentStatus = 'open' | 'investigating' | 'resolved';
interface Incident { id: number; title: string; priority: IncidentPriority; status: IncidentStatus; }
interface IncidentCollectionResponse { data: Incident[]; }
interface IncidentItemResponse { data: Incident; }
// Track the collection and each request state as reactive values.
const incidents = ref<Incident[]>([]);
const loading = ref(true); const error = ref(''); const updatingId = ref<number | null>(null);
// Derive dashboard totals from the current incident collection.
const openCount = computed(() => incidents.value.filter((incident) => incident.status !== 'resolved').length);
const criticalCount = computed(() => incidents.value.filter((incident) => incident.priority === 'critical').length);
// Load the API contract into the dashboard state.
async function loadIncidents(): Promise<void> {
    loading.value = true; error.value = '';
    try { const response = await fetch('/api/incidents', { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error('The incident API could not be loaded.'); const payload = (await response.json()) as IncidentCollectionResponse; incidents.value = payload.data; }
    catch (caught) { error.value = caught instanceof Error ? caught.message : 'An unexpected error occurred.'; }
    finally { loading.value = false; }
}
// Persist a resolved status and replace the matching local item.
async function resolveIncident(incident: Incident): Promise<void> {
    updatingId.value = incident.id; error.value = '';
    try { const response = await fetch(`/api/incidents/${incident.id}`, { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'resolved' }) }); if (!response.ok) throw new Error('The incident status could not be updated.'); const payload = (await response.json()) as IncidentItemResponse; incidents.value = incidents.value.map((item) => item.id === payload.data.id ? payload.data : item); }
    catch (caught) { error.value = caught instanceof Error ? caught.message : 'An unexpected error occurred.'; }
    finally { updatingId.value = null; }
}
// Fetch incidents after Vue mounts the component.
onMounted(() => { void loadIncidents(); });
</script>

How Does the Component Logic Work?

  • The Composition API keeps related state and behavior together inside <script setup>.
  • The ref() calls create typed reactive values for the incidents and request states. The template unwraps these refs automatically.
  • The computed() calls recalculate the unresolved and critical totals whenever the incident collection changes.
  • The loadIncidents() function loads the same-origin collection endpoint into incidents.value.
  • The resolveIncident() function persists the new status. A successful response replaces the matching item in the reactive collection.
  • The onMounted() hook starts the first request after Vue mounts the component.
  • Save resources/js/App.vue.
  • Add this dashboard template below the closing script tag:
<template><main class="page-shell"><header class="hero"><div><p class="eyebrow">Operations workspace</p><h1>Incident Desk</h1><p class="subtitle">A contract-tested view of active service incidents.</p></div><button class="secondary-button" type="button" :disabled="loading" @click="loadIncidents">Refresh</button></header><section class="metrics" aria-label="Incident summary"><article><span>Total</span><strong>{{ incidents.length }}</strong></article><article><span>Unresolved</span><strong>{{ openCount }}</strong></article><article><span>Critical</span><strong>{{ criticalCount }}</strong></article></section><p v-if="loading" class="state-message" role="status">Loading incidents...</p><p v-else-if="error" class="state-message error" role="alert">{{ error }}</p><section v-else class="incident-panel" aria-labelledby="incident-heading"><div class="panel-heading"><div><p class="eyebrow">Live queue</p><h2 id="incident-heading">Current incidents</h2></div></div><p v-if="incidents.length === 0" class="state-message">No incidents are active.</p><table v-else><thead><tr><th scope="col">Incident</th><th scope="col">Priority</th><th scope="col">Status</th><th scope="col"><span class="visually-hidden">Action</span></th></tr></thead><tbody><tr v-for="incident in incidents" :key="incident.id"><td data-label="Incident">{{ incident.title }}</td><td data-label="Priority"><span :class="['priority-badge', `priority-${incident.priority}`]">{{ incident.priority }}</span></td><td data-label="Status">{{ incident.status }}</td><td data-label="Action" class="action-cell"><button class="primary-button" type="button" :disabled="incident.status === 'resolved' || updatingId === incident.id" @click="resolveIncident(incident)">{{ updatingId === incident.id ? 'Saving...' : incident.status === 'resolved' ? 'Resolved' : 'Mark resolved' }}</button></td></tr></tbody></table></section></main></template>

How Does the Template Expose Each State?

  • Conditional rendering selects the loading, error, empty, or populated view.
  • The semantic table keeps incident titles, priorities, statuses, and actions in labeled columns.
  • Each data-label value gives the mobile layout a visible field label.
  • The update button shows saving progress. It becomes disabled after the incident reaches the resolved state.
  • Save resources/js/App.vue.

Before you build the component, do you expect the frontend compiler to accept both the typed logic and the template?

  • Compile the dashboard by running:
npm run build

What Does This Build Check?

The production build checks the Vue component syntax plus the frontend imports. It also confirms that Vite can create the browser assets from resources/js/app.ts.

You should see a successful production build with no missing-package or entry-point errors.

Did the Vue Build Fail?

Check that App.vue uses the same capitalization as its import in app.ts. Confirm the template sits after the closing script tag.

If Vite reports a component or entry problem, help me debug the Vue production build.

Style and verify the responsive workflow

A semantic table works well on a wide screen. The responsive rules preserve the same markup while presenting each row as a labeled card below 720px.

  • Switch back to resources/css/app.css in the VS Code Explorer sidebar.
  • Replace its contents with these dashboard styles:
:root { color: #172033; background: #f3f6fb; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } body { margin: 0; min-width: 320px; min-height: 100vh; background: #f3f6fb; } button, select { font: inherit; } button { cursor: pointer; } button:disabled { cursor: not-allowed; opacity: 0.58; } .page-shell { width: min(1120px, calc(100% - 2rem)); margin: 0 auto; padding: 3rem 0 5rem; } .hero, .panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h1, h2, p { margin-top: 0; } .metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin: 2rem 0; } .metrics article, .incident-panel { border: 1px solid #dfe5ef; border-radius: 1rem; background: white; } .metrics article { padding: 1rem 1.2rem; } .incident-panel { overflow: hidden; } .panel-heading { padding: 1.25rem 1.4rem; border-bottom: 1px solid #e7ebf2; } table { width: 100%; border-collapse: collapse; } th, td { padding: 1rem 1.4rem; border-bottom: 1px solid #edf0f5; text-align: left; } .priority-badge { display: inline-flex; border-radius: 999px; padding: 0.3rem 0.65rem; font-size: 0.76rem; font-weight: 800; text-transform: capitalize; } .priority-critical { color: #991b1b; background: #fee2e2; } .priority-high { color: #9a3412; background: #ffedd5; } .priority-medium { color: #1d4ed8; background: #dbeafe; } .primary-button, .secondary-button { border-radius: 0.65rem; padding: 0.65rem 0.85rem; font-weight: 750; } .primary-button { border: 0; color: white; background: #2563eb; } .secondary-button { border: 1px solid #cbd5e1; color: #172033; background: white; } .action-cell { text-align: right; } .state-message { padding: 1.25rem; border: 1px solid #dfe5ef; border-radius: 0.9rem; color: #475467; background: white; } .state-message.error { border-color: #fecaca; color: #991b1b; background: #fff1f2; } .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } @media (max-width: 720px) { .page-shell { width: min(100% - 1rem, 42rem); padding-top: 1.5rem; } .hero { align-items: flex-start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } .incident-panel { overflow: visible; border: 0; background: transparent; } table, thead, tbody, tr, th, td { display: block; } thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } tbody { display: grid; gap: 0.8rem; } tbody tr { padding: 0.35rem 1rem; border: 1px solid #dfe5ef; border-radius: 1rem; background: white; } td { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: 0.75rem; padding: 0.75rem 0; } td::before { content: attr(data-label); } .action-cell { text-align: left; } }

How Does the Responsive Layout Work?

  • The desktop rules arrange three summary metrics above a full-width incident table.
  • Priority classes give critical, high, and medium incidents distinct badges.
  • The media query changes table elements into blocks on narrow screens.
  • Each table cell uses its data-label value as the card field label.
  • Save resources/css/app.css.

Styles Missing From the Bundle?

Confirm app.ts imports ../css/app.css. Check that the stylesheet remains inside resources/css.

If the stylesheet still does not load, help me trace the Vue stylesheet import.

✔️ Awesome, I've got everything!

Your Laravel view, Vue entry, component, route, Vite configuration, and responsive stylesheet are ready for the final checks.

ⓧ I'd like to double check the full code

  • Compare your vite.config.js with this complete file:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/js/app.ts'], refresh: true }),
        vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false } } }),
    ],
});

Vite Configuration Check

This file must point Laravel at resources/js/app.ts while enabling Vue component compilation.

  • Compare your routes/web.php with this complete file:
<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('app');
});

Web Route Check

This file sends the root browser request to the app view.

  • Compare your resources/views/app.blade.php with this complete file:
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Incident Desk</title>
    @vite('resources/js/app.ts')
</head>
<body>
    <div id="app"></div>
</body>
</html>

Blade View Check

This file loads the frontend entry plus the app mount element.

  • Compare your resources/js/app.ts with this complete file:
import '../css/app.css';
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

Vue Entry Check

This file imports the stylesheet plus the root component before mounting the application.

  • Compare your resources/js/App.vue with this complete file:
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
type IncidentPriority = 'critical' | 'high' | 'medium';
type IncidentStatus = 'open' | 'investigating' | 'resolved';
interface Incident { id: number; title: string; priority: IncidentPriority; status: IncidentStatus; }
interface IncidentCollectionResponse { data: Incident[]; }
interface IncidentItemResponse { data: Incident; }
// Track the collection and each request state as reactive values.
const incidents = ref<Incident[]>([]);
const loading = ref(true); const error = ref(''); const updatingId = ref<number | null>(null);
// Derive dashboard totals from the current incident collection.
const openCount = computed(() => incidents.value.filter((incident) => incident.status !== 'resolved').length);
const criticalCount = computed(() => incidents.value.filter((incident) => incident.priority === 'critical').length);
// Load the API contract into the dashboard state.
async function loadIncidents(): Promise<void> {
    loading.value = true; error.value = '';
    try { const response = await fetch('/api/incidents', { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error('The incident API could not be loaded.'); const payload = (await response.json()) as IncidentCollectionResponse; incidents.value = payload.data; }
    catch (caught) { error.value = caught instanceof Error ? caught.message : 'An unexpected error occurred.'; }
    finally { loading.value = false; }
}
// Persist a resolved status and replace the matching local item.
async function resolveIncident(incident: Incident): Promise<void> {
    updatingId.value = incident.id; error.value = '';
    try { const response = await fetch(`/api/incidents/${incident.id}`, { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'resolved' }) }); if (!response.ok) throw new Error('The incident status could not be updated.'); const payload = (await response.json()) as IncidentItemResponse; incidents.value = incidents.value.map((item) => item.id === payload.data.id ? payload.data : item); }
    catch (caught) { error.value = caught instanceof Error ? caught.message : 'An unexpected error occurred.'; }
    finally { updatingId.value = null; }
}
// Fetch incidents after Vue mounts the component.
onMounted(() => { void loadIncidents(); });
</script>
<template><main class="page-shell"><header class="hero"><div><p class="eyebrow">Operations workspace</p><h1>Incident Desk</h1><p class="subtitle">A contract-tested view of active service incidents.</p></div><button class="secondary-button" type="button" :disabled="loading" @click="loadIncidents">Refresh</button></header><section class="metrics" aria-label="Incident summary"><article><span>Total</span><strong>{{ incidents.length }}</strong></article><article><span>Unresolved</span><strong>{{ openCount }}</strong></article><article><span>Critical</span><strong>{{ criticalCount }}</strong></article></section><p v-if="loading" class="state-message" role="status">Loading incidents...</p><p v-else-if="error" class="state-message error" role="alert">{{ error }}</p><section v-else class="incident-panel" aria-labelledby="incident-heading"><div class="panel-heading"><div><p class="eyebrow">Live queue</p><h2 id="incident-heading">Current incidents</h2></div></div><p v-if="incidents.length === 0" class="state-message">No incidents are active.</p><table v-else><thead><tr><th scope="col">Incident</th><th scope="col">Priority</th><th scope="col">Status</th><th scope="col"><span class="visually-hidden">Action</span></th></tr></thead><tbody><tr v-for="incident in incidents" :key="incident.id"><td data-label="Incident">{{ incident.title }}</td><td data-label="Priority"><span :class="['priority-badge', `priority-${incident.priority}`]">{{ incident.priority }}</span></td><td data-label="Status">{{ incident.status }}</td><td data-label="Action" class="action-cell"><button class="primary-button" type="button" :disabled="incident.status === 'resolved' || updatingId === incident.id" @click="resolveIncident(incident)">{{ updatingId === incident.id ? 'Saving...' : incident.status === 'resolved' ? 'Resolved' : 'Mark resolved' }}</button></td></tr></tbody></table></section></main></template>

Dashboard Component Check

This component uses Vue 3's Composition API with typed refs, computed totals, and an onMounted() lifecycle hook. It contains the complete loading flow, status-update interaction, and rendered table.

  • Compare your resources/css/app.css with this complete file:
:root { color: #172033; background: #f3f6fb; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } body { margin: 0; min-width: 320px; min-height: 100vh; background: #f3f6fb; } button, select { font: inherit; } button { cursor: pointer; } button:disabled { cursor: not-allowed; opacity: 0.58; } .page-shell { width: min(1120px, calc(100% - 2rem)); margin: 0 auto; padding: 3rem 0 5rem; } .hero, .panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } h1, h2, p { margin-top: 0; } .metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin: 2rem 0; } .metrics article, .incident-panel { border: 1px solid #dfe5ef; border-radius: 1rem; background: white; } .metrics article { padding: 1rem 1.2rem; } .incident-panel { overflow: hidden; } .panel-heading { padding: 1.25rem 1.4rem; border-bottom: 1px solid #e7ebf2; } table { width: 100%; border-collapse: collapse; } th, td { padding: 1rem 1.4rem; border-bottom: 1px solid #edf0f5; text-align: left; } .priority-badge { display: inline-flex; border-radius: 999px; padding: 0.3rem 0.65rem; font-size: 0.76rem; font-weight: 800; text-transform: capitalize; } .priority-critical { color: #991b1b; background: #fee2e2; } .priority-high { color: #9a3412; background: #ffedd5; } .priority-medium { color: #1d4ed8; background: #dbeafe; } .primary-button, .secondary-button { border-radius: 0.65rem; padding: 0.65rem 0.85rem; font-weight: 750; } .primary-button { border: 0; color: white; background: #2563eb; } .secondary-button { border: 1px solid #cbd5e1; color: #172033; background: white; } .action-cell { text-align: right; } .state-message { padding: 1.25rem; border: 1px solid #dfe5ef; border-radius: 0.9rem; color: #475467; background: white; } .state-message.error { border-color: #fecaca; color: #991b1b; background: #fff1f2; } .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } @media (max-width: 720px) { .page-shell { width: min(100% - 1rem, 42rem); padding-top: 1.5rem; } .hero { align-items: flex-start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } .incident-panel { overflow: visible; border: 0; background: transparent; } table, thead, tbody, tr, th, td { display: block; } thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } tbody { display: grid; gap: 0.8rem; } tbody tr { padding: 0.35rem 1rem; border: 1px solid #dfe5ef; border-radius: 1rem; background: white; } td { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: 0.75rem; padding: 0.75rem 0; } td::before { content: attr(data-label); } .action-cell { text-align: left; } }

Responsive Stylesheet Check

This stylesheet contains the desktop table rules plus the narrow-screen card transformation.

Before you run the final checks, do you expect both the protected API contract and the frontend bundle to pass together?

  • Run the complete Laravel feature suite with:
php artisan test

What Does the Backend Check Cover?

The suite verifies the exact collection shape plus the successful status update. It also covers invalid status values and missing incidents.

You should see every incident test pass.

Are the Incident Tests Red?

Confirm the frontend work did not replace routes/api.php or change the tested controller. The web route belongs only in routes/web.php.

If a response assertion fails, help me restore the tested API contract.

  • Run the final frontend production build with:
npm run build

What Does the Frontend Check Cover?

The build compiles the Vue component plus its TypeScript entry and responsive stylesheet. It confirms the browser bundle is ready to serve.

You should see a successful production build with no missing-package or entry-point errors.

Is the Final Build Failing?

Check the three linked paths in order. vite.config.js points to resources/js/app.ts. The entry imports App.vue plus app.css.

If one path remains unresolved, help me trace the Vite entry chain.

The combined development command starts Laravel plus Vite in the foreground. It keeps this terminal busy while the dashboard is running.

  • Start the local application by running:
composer run dev

What Does the Development Command Start?

The command starts the local Laravel application plus the Vite development process. Use the Laravel URL printed in the terminal to reach the dashboard.

Before you open the page, do you expect the browser to show raw JSON or the Incident Desk interface?

  • Open the Laravel URL printed in the terminal.

You should see the Incident Desk heading plus three summary metrics. The table should contain the three seeded incidents.

  • Resize the browser window below 720px.

You should see each incident row presented as a card with Incident, Priority, Status, and Action labels.

  • Click Mark resolved for an unresolved incident.

You should briefly see Saving.... The button then reads Resolved without a page reload.

That completes the full loop. The tested API now drives a responsive client that can persist an incident update from the browser.

Dashboard Not Loading or Updating?

Confirm the development command is still running. Use the Laravel URL from that terminal instead of opening the Blade file directly.

If incidents load but updates fail, check the browser error state plus the terminal output. Help me debug the Incident Desk request.

Secret mission

Add a Priority Filter

Add a typed priority filter to the Incident Desk dashboard. The browser derives the filtered table from incidents it already loaded, so the tested API contract stays unchanged.

Clean Up Your Resources

Clean Up Your Resources

Incident Desk runs entirely on your Mac with no ongoing cloud costs. Choose whether to keep it active, pause the local processes, or delete the project entirely.

Resources you used:

  • Local Laravel and Vite development processes.
  • The incident-desk project folder with your tested API and Vue dashboard.
  • The local SQLite database at database/database.sqlite.
  • Local Composer dependencies stored inside the project folder.
  • Local npm dependencies stored inside the project folder.
  • Generated Vite production build assets stored inside the project folder.

Keep everything running

No action needed. Choose this if you are still testing the API contract or improving the dashboard.

  • Leave the local development processes active while you continue working.
  • Keep the incident-desk folder on your Mac.

Pause - I'll come back to this later

Shut down the running processes to free up memory. Your source files and SQLite data remain available for your next session.

  • Press Control-C in the terminal running the development processes.

The terminal returns to its prompt. Your incident-desk folder remains unchanged.

Delete - I don't want to use this again

Remove the local project resources if you want to start fresh. Choose Pause if you want to preserve your tested API and dashboard.

  • Press Control-C in the terminal if the development processes are still running.
  • Use Finder to locate the incident-desk project folder.
  • Move the incident-desk project folder to Trash.
  • Confirm Finder no longer shows incident-desk in its previous location.

Removing this folder also removes the SQLite database, Composer dependencies, npm dependencies, and generated Vite assets.

Nice Work!

Nice Work!

Mission complete! Incident Desk now connects a tested Laravel API to a responsive Vue 3 dashboard inside one same-origin application. Your deliberate contract failure became a stable JSON boundary that protects both persistence and the browser client.

You've learned how to:

  • Expose SQLite-backed incidents through a four-field API Resource contract. Keep raw Eloquent timestamps outside the public JSON payload.
  • Validate status changes through a Form Request. Resolve incident URLs through route model binding. Prove both paths with database-backed feature tests.
  • Resolve incidents from a responsive Vue 3 dashboard without a page reload. Present the same semantic table as mobile-friendly cards.
  • Secret Mission: Add a typed client-side priority filter that projects the loaded incidents without changing the API contract.

Ready to quiz yourself?