AI-Powered Notes Dashboard with Laravel
Build a Laravel 13 notes app with full CRUD and AI categorization.
Introduction
30 Second Summary
We all take notes, but finding them again is the hard part. What if your notes could organize themselves the moment you write them?
In this project, you will build a notes dashboard with Laravel where an AI agent automatically categorizes and summarizes every note you create.
What You'll Build
A full-stack Laravel 13 web application that performs CRUD operations on notes stored in PostgreSQL, with an AI agent powered by Mistral that generates categories and summaries.
By the end of this project, you'll have:
- A Laravel 13 application connected to PostgreSQL with a working database migration and Eloquent ORM model.
- A complete CRUD dashboard styled with Blade templating and Tailwind CSS, including form validation and flash messages.
- A NoteCategorizer agent built with the Laravel AI SDK that automatically assigns a category and one-line summary to every note.
- Secret Mission: Add category filtering and a search bar to create an interactive dashboard.
Are there any prerequisites?
You need basic HTML/CSS knowledge and access to a Linux server with PHP 8.3+, Composer, PostgreSQL, and Apache already installed.
You will also need VS Code connected to your server for editing files.
Before We Start
Before We Start...
Set Up Laravel and the AI SDK
Every Laravel application starts with a project scaffold and a database connection. Before you can build routes, controllers, or views, you need a working Laravel installation that can talk to PostgreSQL.
In this step, you will install the Laravel installer, create your application, connect it to a PostgreSQL database, and then add the Laravel AI SDK so your app can call Mistral for AI features later.
In this step, get ready to:
- Install the Laravel installer and create a new project.
- Install and configure the Laravel AI SDK.
- Verify the setup works.
Install the Laravel installer and create a new project
The Laravel installer is a small CLI tool that scaffolds new Laravel applications. You will install it globally with Composer, then use it to create your project.
- Open your terminal.
- Install the Laravel installer globally by running this command:
composer global require laravel/installer
What does this command do?
This installs the laravel CLI tool globally so you can run it from any directory on your server.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your new Laravel application by running this command:
laravel new notes-dashboard
What will the installer ask?
The Laravel installer will prompt you to choose a starter kit, testing framework, and database. Select these options:
- Starter Kit: select None.
- Testing framework: accept the default.
- Database: select PostgreSQL.
- Run npm install: select yes.
Once you see "Application ready!" in your terminal, the project has been created successfully.
- Move into your new project folder by running:
cd notes-dashboard
Now you need to configure the database connection. The installer created a .env file with defaults, but you need to set the correct database name and credentials for your server.
- Open .env in VS Code from the project root.
- Find the database configuration section and update the following lines to match your PostgreSQL setup:
APP_NAME="Notes Dashboard"
APP_URL=http://localhost:8000
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=notes_dashboard
DB_USERNAME=your-username-here
DB_PASSWORD=your-password-here
What does each line do?
- DB_CONNECTION=pgsql tells Laravel to use the PostgreSQL driver.
- DB_HOST=127.0.0.1 points to your local PostgreSQL server.
- DB_PORT=5432 is the default PostgreSQL port.
- DB_DATABASE=notes_dashboard is the name of the database you will create next.
- DB_USERNAME and DB_PASSWORD are your PostgreSQL credentials.
Replace your-username-here and your-password-here with your actual PostgreSQL username and password.
- Save .env.
Next, you need to create the actual database in PostgreSQL so Laravel has somewhere to store data.
- Create the notes_dashboard database by running this command:
createdb notes_dashboard
What is createdb?
The createdb utility is a PostgreSQL command-line tool that creates a new database. It is a wrapper around the SQL CREATE DATABASE command.
- Run the default Laravel migrations to verify the database connection:
php artisan migrate
- You should see output listing several migration files being run, ending with a success message.
Seeing a connection refused error?
- Confirm PostgreSQL is running on your server with sudo systemctl status postgresql.
- Double-check that DB_USERNAME and DB_PASSWORD in .env match your PostgreSQL credentials exactly.
- Make sure you created the database with createdb notes_dashboard before running migrations.
Why can't I connect to my database?
Install and configure the Laravel AI SDK
The Laravel AI SDK is a first-party package that provides a unified interface for AI providers like Mistral, OpenAI, and Anthropic. You will install it and configure it to use Mistral's free tier.
- Install the AI SDK package by running:
composer require laravel/ai
- Publish the AI SDK configuration and migration files by running:
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
- Run migrations to create the agent conversation tables:
php artisan migrate
What tables does this create?
The AI SDK creates agent_conversations and agent_conversation_messages tables. These store conversation history if you use multi-turn agent interactions. Your project only uses single prompts, but the tables are required for the SDK to function.
Now you need a Mistral API key. Mistral offers a free tier with limited rate limits that is perfect for learning projects.
- Open your browser and go to console.mistral.ai.
- Sign up for a free account. No credit card is required for the free tier.
- Navigate to the API Keys section in your Mistral console.
- Create a new API key and copy it.
Store your key safely
Copy your API key immediately. Most providers only show the full key once at creation time. Store it somewhere safe before closing the modal.
- Back in VS Code, open .env and add your Mistral API key at the bottom of the file:
MISTRAL_API_KEY=your-api-key-here
- Replace your-api-key-here with the key you copied from the Mistral console.
- Save .env.
Why Mistral?
Mistral provides a free tier with limited rate limits designed for evaluation and prototyping. The model you will use later (mistral-small-latest) is cost-efficient and supports structured outputs, which is exactly what your categorization agent needs.
✔️ Awesome, I've got everything!
Great. Double check you have saved your .env file.
ⓧ I'd like to double check the full code
Here is what the relevant lines in your .env file should look like:
APP_NAME="Notes Dashboard"
APP_URL=http://localhost:8000
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=notes_dashboard
DB_USERNAME=your-username-here
DB_PASSWORD=your-password-here
MISTRAL_API_KEY=your-api-key-here
Verify the setup
Before moving on, confirm that both your Laravel application and database connection are working correctly.
- Start the Laravel development server by running:
php artisan serve
- Open your browser and go to http://localhost:8000.
- You should see the Laravel welcome page.
Not seeing the welcome page?
- Make sure no other process is using port 8000. You can try php artisan serve --port=8080 as an alternative.
- Check that APP_URL in your .env is set to http://localhost:8000.
Help me get my Laravel server running
- Open a new terminal tab (keep the server running in the first one).
- Navigate back to your project folder and confirm the database connection by running:
cd ~/Desktop/notes-dashboard
php artisan migrate:status
- You should see a table listing all migrations with a "Ran" status next to each one.
What does migrate:status show?
This command confirms that Laravel can reach your PostgreSQL database and that all migrations have run successfully. If you see "Pending" next to any migration, run php artisan migrate again.
Your Laravel application is running and connected to PostgreSQL. Next up, you will create the Note model and migration to define how your notes are stored in the database.
Create the Note Model and Migration
Your Laravel app is running and connected to PostgreSQL. Now it's time to define the data structure for your notes.
In Laravel, every database table has a corresponding Model class that represents it and a migration file that defines its columns. You will scaffold both at once using a single Artisan command.
In this step, get ready to:
- Generate the Note model, migration, and controller with one command.
- Define the database columns for the notes table.
- Configure the model for mass assignment and run the migration.
Scaffold the model, migration, and controller
Laravel's Eloquent ORM connects your PHP classes to database tables. Instead of writing raw SQL, you interact with a Model class that handles queries for you.
- Open a new terminal tab or window. Your php artisan serve process is still running in the original terminal, so you need a separate one for commands.
- Navigate to your project folder in the new terminal.
- Generate the Note model, its migration file, and a controller by running this command:
php artisan make:model Note -mc
What does this command do?
The -mc flags tell Artisan to create three files at once:
- -m creates a database migration file in database/migrations/.
- -c creates a controller file at app/Http/Controllers/NoteController.php.
- The model itself is created at app/Models/Note.php.
- You should see output confirming all three files were created. Verify with:
ls app/Models/Note.php app/Http/Controllers/NoteController.php database/migrations/*create_notes_table*
You should see all three file paths listed in the output.
Command not found or model already exists?
- Make sure you are inside your Laravel project folder (the one containing artisan).
- If you see a message that the model already exists, delete the existing files and rerun the command.
Help me troubleshoot the make:model command
Define the migration schema
The generated migration file has placeholder columns. You need to define the actual columns your notes table requires.
- Open the migration file inside the database/migrations/ folder. Its name ends with _create_notes_table.php (the timestamp prefix will vary).
- Replace the entire contents of the file with the following code:
<?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('notes', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->string('category')->nullable();
$table->string('summary')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notes');
}
};
What does this code do?
- id() creates an auto-incrementing primary key.
- string('title') creates a VARCHAR column for the note title.
- text('body') creates a TEXT column for longer note content.
- category and summary are nullable() because the AI agent fills them in after the note is created. They start as empty.
- timestamps() adds created_at and updated_at columns automatically.
- Save the migration file.
Not sure which file to open?
The database/migrations/ folder contains multiple files. Look for the one whose name ends with _create_notes_table.php. The date prefix at the start of the filename will match when you ran the command.
Help me find my migration file
Configure the model and run the migration
Laravel protects against accidental mass assignment by default. You need to tell the model which fields are safe to fill from user input.
- Open app/Models/Note.php in VS Code.
- Replace the entire contents of the file with the following code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Note extends Model
{
protected $fillable = [
'title',
'body',
'category',
'summary',
];
}
What is $fillable?
The $fillable array lists the columns that can be set using Note::create() or $note->update(). Without it, Laravel throws a mass assignment exception to protect you from accidentally saving unexpected data.
- Save app/Models/Note.php.
- Run the migration to create the notes table in your database:
php artisan migrate
- You should see output confirming the create_notes_table migration ran successfully.
- Verify the table exists by running:
php artisan migrate:status
You should see the create_notes_table migration listed with a Ran status.
Migration failed with a connection error?
- Confirm your PostgreSQL service is running on your server.
- Double check that DB_DATABASE, DB_USERNAME, and DB_PASSWORD in your .env file match your actual PostgreSQL credentials.
- Make sure the notes_dashboard database exists. You created it in the previous step.
Help me fix my database migration error
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete migration 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('notes', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->string('category')->nullable();
$table->string('summary')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notes');
}
};
Here is the complete model file:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Note extends Model
{
protected $fillable = [
'title',
'body',
'category',
'summary',
];
}
Here is the scaffolded controller (empty for now):
<?php
namespace App\Http\Controllers;
use App\Models\Note;
use Illuminate\Http\Request;
class NoteController extends Controller
{
}
Your data layer is ready. Next up, you will build the Blade views that display your notes in a styled dashboard.
Build the Dashboard Layout and Views
Your Note model and database table are ready to store data. Now you need a way for users to actually see and interact with their notes.
In this step, you'll build the front-end views using Blade, Laravel's templating engine. Blade lets you define a shared layout once and then inject page-specific content into it from child views.
In this step, get ready to:
- Create a shared Blade layout with Tailwind CSS styling.
- Build the notes dashboard view with a data table.
- Build the create and edit forms for notes.
Create the Blade layout file
Most web applications share the same header, navigation, and footer across every page. Blade solves this with template inheritance.
You define a layout file with a placeholder (@yield), and each page fills that placeholder using @section. This keeps your HTML DRY.
- Create the required directories by running these commands in your terminal:
mkdir -p resources/views/layouts resources/views/notes
- Confirm the directories exist by running:
ls resources/views/
You should see layouts and notes listed alongside any existing folders.
- Create a new file at resources/views/layouts/app.blade.php and paste the following layout code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 py-4">
<a href="{{ route('notes.index') }}" class="text-xl font-bold text-gray-800">Notes Dashboard</a>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-8">
@if (session('success'))
<div class="mb-4 p-4 bg-green-100 border border-green-300 text-green-800 rounded">
{{ session('success') }}
</div>
@endif
@yield('content')
</main>
</body>
</html>
What does this layout do?
- The <script src="https://cdn.tailwindcss.com"> tag loads Tailwind CSS from a CDN so you can use utility classes without a build step.
- The <nav> section gives every page a consistent navigation header with a link back to the dashboard.
- The @if (session('success')) block checks for flash messages and displays a green success banner when one exists.
- The @yield('content') directive is the placeholder where child views inject their page-specific HTML.
- Save resources/views/layouts/app.blade.php.
File not saving?
Make sure the layouts directory exists inside resources/views/. If you see an error about the path not existing, re-run the mkdir -p command from above.
Help me fix my Blade layout file path
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 py-4">
<a href="{{ route('notes.index') }}" class="text-xl font-bold text-gray-800">Notes Dashboard</a>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-8">
@if (session('success'))
<div class="mb-4 p-4 bg-green-100 border border-green-300 text-green-800 rounded">
{{ session('success') }}
</div>
@endif
@yield('content')
</main>
</body>
</html>
Create the notes index view
The index view is your dashboard. It displays all notes in a table with columns for the title, AI-generated category, AI-generated summary, date, and action links.
- Create a new file at resources/views/notes/index.blade.php and paste the following code:
@extends('layouts.app')
@section('content')
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">My Notes</h1>
<a href="{{ route('notes.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
New Note
</a>
</div>
@if ($notes->isEmpty())
<div class="bg-white rounded shadow p-8 text-center text-gray-500">
No notes yet. Create your first note!
</div>
@else
<div class="bg-white rounded shadow overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Summary</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($notes as $note)
<tr>
<td class="px-6 py-4 font-medium text-gray-900">{{ $note->title }}</td>
<td class="px-6 py-4">
@if ($note->category)
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{{ $note->category }}
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 text-sm text-gray-600">{{ $note->summary ?? '-' }}</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $note->created_at->format('M d, Y') }}</td>
<td class="px-6 py-4 text-sm">
<a href="{{ route('notes.edit', $note) }}" class="text-blue-600 hover:underline mr-3">Edit</a>
<form action="{{ route('notes.destroy', $note) }}" method="POST" class="inline" onsubmit="return confirm('Delete this note?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@endsection
What does this view do?
- The @extends('layouts.app') line tells Blade to wrap this page inside your layout file.
- The @if ($notes->isEmpty()) block shows a friendly empty state when no notes exist yet.
- Each table row displays the note's title, a styled category badge, the AI summary, the creation date, and Edit/Delete action links.
- The Delete button lives inside its own <form> with @method('DELETE') so Laravel can route it correctly.
- Save resources/views/notes/index.blade.php.
You won't see this page in the browser yet. The next step connects routes to these views.
Worried about the route calls?
The route('notes.index') and route('notes.create') calls reference routes you have not defined yet. That is expected. These views will work once you add the resource routes in the next step.
Help me understand why my Blade routes are undefined
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
@extends('layouts.app')
@section('content')
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">My Notes</h1>
<a href="{{ route('notes.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
New Note
</a>
</div>
@if ($notes->isEmpty())
<div class="bg-white rounded shadow p-8 text-center text-gray-500">
No notes yet. Create your first note!
</div>
@else
<div class="bg-white rounded shadow overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Summary</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($notes as $note)
<tr>
<td class="px-6 py-4 font-medium text-gray-900">{{ $note->title }}</td>
<td class="px-6 py-4">
@if ($note->category)
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{{ $note->category }}
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 text-sm text-gray-600">{{ $note->summary ?? '-' }}</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $note->created_at->format('M d, Y') }}</td>
<td class="px-6 py-4 text-sm">
<a href="{{ route('notes.edit', $note) }}" class="text-blue-600 hover:underline mr-3">Edit</a>
<form action="{{ route('notes.destroy', $note) }}" method="POST" class="inline" onsubmit="return confirm('Delete this note?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@endsection
Create the notes create and edit views
Both the create and edit pages use a similar form with a title input and a content textarea. The difference is that the edit form pre-fills the fields with existing data and sends a PUT request.
- Create a new file at resources/views/notes/create.blade.php and paste the following form code:
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Create Note</h1>
<form action="{{ route('notes.store') }}" method="POST" class="bg-white rounded shadow p-6 space-y-4">
@csrf
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" id="title" value="{{ old('title') }}"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
@error('title')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="body" class="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea name="body" id="body" rows="6"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('body') }}</textarea>
@error('body')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center gap-4">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
Save Note
</button>
<a href="{{ route('notes.index') }}" class="text-gray-600 hover:underline">Cancel</a>
</div>
</form>
</div>
@endsection
What does this form do?
- The @csrf directive adds a hidden token field that protects against cross-site request forgery attacks.
- The old('title') helper re-populates the field with previously entered data if validation fails.
- The @error('title') directive conditionally shows a red error message below the field when validation catches a problem.
- Save resources/views/notes/create.blade.php.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Create Note</h1>
<form action="{{ route('notes.store') }}" method="POST" class="bg-white rounded shadow p-6 space-y-4">
@csrf
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" id="title" value="{{ old('title') }}"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
@error('title')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="body" class="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea name="body" id="body" rows="6"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('body') }}</textarea>
@error('body')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center gap-4">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
Save Note
</button>
<a href="{{ route('notes.index') }}" class="text-gray-600 hover:underline">Cancel</a>
</div>
</form>
</div>
@endsection
Now create the edit view. It is nearly identical but pre-fills the fields and sends a PUT request.
- Create a new file at resources/views/notes/edit.blade.php and paste the following code:
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Edit Note</h1>
<form action="{{ route('notes.update', $note) }}" method="POST" class="bg-white rounded shadow p-6 space-y-4">
@csrf
@method('PUT')
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" id="title" value="{{ old('title', $note->title) }}"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
@error('title')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="body" class="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea name="body" id="body" rows="6"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('body', $note->body) }}</textarea>
@error('body')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center gap-4">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
Update Note
</button>
<a href="{{ route('notes.index') }}" class="text-gray-600 hover:underline">Cancel</a>
</div>
</form>
</div>
@endsection
How does the edit form differ from create?
- The @method('PUT') directive adds a hidden field that tells Laravel to treat this as a PUT request instead of POST.
- The old('title', $note->title) helper shows the existing note data as the default, falling back to any previously entered value if validation fails.
- The form action points to route('notes.update', $note) which targets a specific note's update endpoint.
- Save resources/views/notes/edit.blade.php.
- Confirm all four view files exist by running:
find resources/views -name '*.blade.php'
You should see resources/views/layouts/app.blade.php, resources/views/notes/index.blade.php, resources/views/notes/create.blade.php, and resources/views/notes/edit.blade.php in the output.
Missing one of the files?
Double-check that you created each file inside the correct subdirectory. The layout goes in resources/views/layouts/ and the three note views go in resources/views/notes/.
Help me fix my missing Blade view files
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files.
ⓧ I'd like to double check the full code
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Edit Note</h1>
<form action="{{ route('notes.update', $note) }}" method="POST" class="bg-white rounded shadow p-6 space-y-4">
@csrf
@method('PUT')
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" id="title" value="{{ old('title', $note->title) }}"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
@error('title')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="body" class="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea name="body" id="body" rows="6"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('body', $note->body) }}</textarea>
@error('body')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center gap-4">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
Update Note
</button>
<a href="{{ route('notes.index') }}" class="text-gray-600 hover:underline">Cancel</a>
</div>
</form>
</div>
@endsection
Your views are ready and waiting. Next up, you'll wire them to routes and controller methods so they actually load in the browser.
Implement CRUD Operations
You have a Note model, a database table, and a set of Blade views ready to display data. The missing piece is the logic that connects them all together.
In this step, you will define routes that map URLs to controller actions and implement the full CRUD cycle. By the end, your dashboard will let you create, view, edit, and delete notes.
In this step, get ready to:
- Define resource routes that map URLs to controller methods.
- Implement all six controller methods for full CRUD.
- Test the complete create, read, update, and delete flow in the browser.
Define resource routes
Laravel's resource routing generates all seven standard CRUD routes from a single line of code. It maps HTTP verbs like GET, POST, PUT, and DELETE to the matching controller methods automatically.
- Open routes/web.php in VS Code.
- Replace the entire file contents with the following code:
<?php
use App\Http\Controllers\NoteController;
use Illuminate\Support\Facades\Route;
// Redirect the homepage to the notes dashboard
Route::redirect('/', '/notes');
// Register all CRUD routes for notes
Route::resource('notes', NoteController::class);
What does this code do?
Route::redirect('/', '/notes') sends anyone who visits the root URL straight to the notes dashboard.
Route::resource('notes', NoteController::class) generates routes for index, create, store, show, edit, update, and destroy. Each maps to a URL pattern and HTTP verb that Laravel handles for you.
- Save routes/web.php.
Seeing a class not found error?
Make sure the use statement at the top imports App\Http\Controllers\NoteController with the correct namespace. The backslashes must match exactly.
Help me fix a class not found error in my Laravel routes file.
Implement the index and create methods
The controller is currently an empty class. You need to add methods that fetch data and return views.
- Open app/Http/Controllers/NoteController.php in VS Code.
- Replace the entire file contents with the following code:
<?php
namespace App\Http\Controllers;
use App\Models\Note;
use Illuminate\Http\Request;
class NoteController extends Controller
{
public function index(Request $request)
{
// Fetch all notes, newest first
$notes = Note::query()
->latest()
->get();
return view('notes.index', compact('notes'));
}
public function create()
{
return view('notes.create');
}
}
What does this code do?
- index() uses Eloquent to fetch every note from the database, ordered by newest first, then passes them to the notes.index view.
- create() simply returns the empty form view. No data needed.
- Save NoteController.php.
- Refresh your browser at http://localhost:8000. You should see the "My Notes" heading with the empty state message "No notes yet. Create your first note!" displayed.
Seeing a 500 error?
Check that php artisan serve is still running in your terminal. If you stopped it, restart it before refreshing the page.
Help me debug a 500 error on my Laravel notes dashboard.
Add the store method
The store method handles form submissions. It validates the input, saves the note to the database, and redirects the user back to the dashboard with a success message.
- In NoteController.php, add the following method inside the class, below the create() method:
public function store(Request $request)
{
// Validate that title and body are provided
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
// Create the note with validated data
$note = Note::create($validated);
return redirect()->route('notes.index')
->with('success', 'Note created successfully.');
}
What does this code do?
- $request->validate() checks that title is present and under 255 characters, and that body is present. If validation fails, Laravel automatically redirects back with error messages.
- Note::create($validated) inserts a new row into the notes table using only the validated fields.
- ->with('success', ...) flashes a message to the session. The layout template you built earlier already displays these green flash messages.
What is form validation?
Validation ensures that user-submitted data meets your rules before it touches the database. Without it, a user could submit an empty form or inject unexpected data. Laravel's built-in validation returns helpful error messages to the form automatically.
- Save NoteController.php.
- In your browser, click the New Note button.
- Enter My first note as the title and This is a test note for my dashboard. as the content.
- Click Save Note. You should be redirected to the dashboard with a green "Note created successfully." message, and your note appears in the table.
Note not appearing in the table?
Make sure your Note model has title and body in its $fillable array. Without mass assignment protection, Laravel silently ignores the fields.
Help me figure out why my note is not saving to the database.
Add the edit, update, and destroy methods
The remaining three methods let users modify existing notes and remove ones they no longer need.
- In NoteController.php, add the following three methods inside the class, below the store() method:
public function edit(Note $note)
{
return view('notes.edit', compact('note'));
}
public function update(Request $request, Note $note)
{
// Validate the updated fields
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
// Update the note with new data
$note->update($validated);
return redirect()->route('notes.index')
->with('success', 'Note updated successfully.');
}
public function destroy(Note $note)
{
// Remove the note from the database
$note->delete();
return redirect()->route('notes.index')
->with('success', 'Note deleted successfully.');
}
What does this code do?
- edit(Note $note) uses route model binding. Laravel automatically finds the note by its ID from the URL and passes it to the view.
- update() validates the input the same way as store(), then calls $note->update() to save the changes.
- destroy() removes the note from the database permanently and redirects to the dashboard.
- Save NoteController.php.
- In your browser, click Edit next to the note you created earlier.
- Change the title to My updated note and click Update Note.
- You should see the green "Note updated successfully." message and the new title in the table.
- Click Delete next to the note. A confirmation dialog appears.
- Click OK to confirm. The note disappears and you see the "Note deleted successfully." message.
Edit page showing a 404?
Make sure the method signature is edit(Note $note) with the type-hinted Note parameter. Laravel uses this to look up the note by its ID. Without the type hint, route model binding does not work.
Help me fix a 404 on my Laravel edit route.
Test validation errors
- Click New Note to open the create form.
- Leave both fields empty and click Save Note.
- You should see red validation error messages below each field, telling you the title and body are required.
Why do errors appear without extra code?
Your Blade views already include @error directives that display validation messages. When $request->validate() fails, Laravel redirects back and flashes the errors to the session. The @error blocks pick them up automatically.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
<?php
namespace App\Http\Controllers;
use App\Models\Note;
use Illuminate\Http\Request;
class NoteController extends Controller
{
public function index(Request $request)
{
$notes = Note::query()
->latest()
->get();
return view('notes.index', compact('notes'));
}
public function create()
{
return view('notes.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
$note = Note::create($validated);
return redirect()->route('notes.index')
->with('success', 'Note created successfully.');
}
public function edit(Note $note)
{
return view('notes.edit', compact('note'));
}
public function update(Request $request, Note $note)
{
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
$note->update($validated);
return redirect()->route('notes.index')
->with('success', 'Note updated successfully.');
}
public function destroy(Note $note)
{
$note->delete();
return redirect()->route('notes.index')
->with('success', 'Note deleted successfully.');
}
}
Your notes dashboard now supports the full CRUD lifecycle. You can create notes, see them listed, edit their content, and delete them. Next up, you will add the AI-powered categorization that makes this dashboard truly smart.
Add AI Categorization with Mistral
Your dashboard handles CRUD perfectly, but the Category and Summary columns still show dashes for every note. The missing piece is an AI agent that reads each note and fills in those columns automatically.
The Laravel AI SDK provides a structured way to build AI agents. In this step, you will create a NoteCategorizer agent that sends each note to Mistral and receives a category label and a one-sentence summary back.
In this step, get ready to:
- Create a NoteCategorizer agent with structured output.
- Integrate the agent into the store and update controller methods.
- Test AI categorization on new and edited notes.
Create the NoteCategorizer agent
An agent in the Laravel AI SDK is a PHP class that defines how to communicate with an AI provider. It specifies which provider and model to use, what instructions to send, and what shape the response should take.
- Generate the NoteCategorizer agent by running this command:
php artisan make:agent NoteCategorizer --structured
What does this command do?
The --structured flag tells Laravel to scaffold an agent class that implements HasStructuredOutput. This means the agent returns data in a predictable JSON shape (category and summary) instead of free-form text.
The generated file lives at app/Ai/Agents/NoteCategorizer.php. It contains placeholder code that you will now replace with the full agent configuration.
- Open app/Ai/Agents/NoteCategorizer.php in VS Code.
- Replace the entire file contents with the following code:
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;
#[Provider(Lab::Mistral)]
#[Model('mistral-small-latest')]
#[Temperature(0.3)]
class NoteCategorizer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return 'You are a note categorizer. Given a note title and content, return a short category label (one or two words, like "Work", "Personal", "Ideas", "Learning", "Finance", "Health", "Travel", or "Other") and a one-sentence summary of the note. Be concise.';
}
public function schema(JsonSchema $schema): array
{
return [
'category' => $schema->string()->required(),
'summary' => $schema->string()->required(),
];
}
}
What does this code do?
The PHP attributes at the top of the class configure how the agent connects to Mistral:
- #[Provider(Lab::Mistral)] tells the SDK to route requests to the Mistral API.
- #[Model('mistral-small-latest')] selects the Mistral Small model, which is fast and cost-efficient.
- #[Temperature(0.3)] keeps responses consistent and predictable. Lower temperatures reduce randomness.
The instructions() method is the system prompt that tells the AI how to behave. The schema() method defines the exact JSON structure the AI must return.
- Save app/Ai/Agents/NoteCategorizer.php.
Seeing a namespace error?
Make sure the namespace at the top of the file is exactly App\Ai\Agents. The artisan command creates the folder structure, but if you created the file manually, ensure the app/Ai/Agents/ directory exists.
Help me debug my NoteCategorizer agent
Integrate the agent into the store method
The agent is ready, but nothing calls it yet. You need to invoke the NoteCategorizer after a note is created, then save the AI-generated category and summary back to the database.
- Open app/Http/Controllers/NoteController.php in VS Code.
- Find this line near the top of the file:
use App\Models\Note;
- Add the NoteCategorizer import directly above it, so both lines look like this:
use App\Ai\Agents\NoteCategorizer;
use App\Models\Note;
- Now find the store method. Locate this line inside it:
$note = Note::create($validated);
return redirect()->route('notes.index')
->with('success', 'Note created successfully.');
- Replace those lines with the following code, which adds a try/catch block between the note creation and the redirect:
$note = Note::create($validated);
try {
$response = (new NoteCategorizer)->prompt(
"Title: {$note->title}\n\nContent: {$note->body}"
);
$note->update([
'category' => $response['category'],
'summary' => $response['summary'],
]);
} catch (\Exception $e) {
// Note is saved even if AI categorization fails
}
return redirect()->route('notes.index')
->with('success', 'Note created successfully.');
What does this code do?
- (new NoteCategorizer)->prompt(...) sends the note title and body to Mistral and waits for a structured response.
- $response['category'] and $response['summary'] access the AI-generated values from the structured output.
- The try/catch block ensures the note is always saved to the database. If the Mistral API is down or the key is invalid, the note still exists with empty category and summary columns.
Why wrap AI calls in try/catch?
External API calls can fail for many reasons: network timeouts, rate limits, invalid credentials, or service outages. Wrapping the call in a try/catch means your application stays functional even when the AI provider is unavailable. The note saves without a category rather than crashing entirely.
- Now find the update method. Locate this line inside it:
$note->update($validated);
return redirect()->route('notes.index')
->with('success', 'Note updated successfully.');
- Replace those lines with the following code to re-categorize the note whenever it is edited:
$note->update($validated);
try {
$response = (new NoteCategorizer)->prompt(
"Title: {$note->title}\n\nContent: {$note->body}"
);
$note->update([
'category' => $response['category'],
'summary' => $response['summary'],
]);
} catch (\Exception $e) {
// Note is updated even if AI categorization fails
}
return redirect()->route('notes.index')
->with('success', 'Note updated successfully.');
- Save app/Http/Controllers/NoteController.php.
Seeing a class not found error?
Double check that the import line at the top of the file reads use App\Ai\Agents\NoteCategorizer; exactly. The namespace must match the file path. Also confirm you saved the NoteCategorizer.php file in the previous substep.
Help me fix my NoteCategorizer import
✔️ Awesome, I've got everything!
Great. Make sure you have saved both NoteCategorizer.php and NoteController.php.
ⓧ I'd like to double check the full code
Here is the complete NoteCategorizer.php file:
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;
#[Provider(Lab::Mistral)]
#[Model('mistral-small-latest')]
#[Temperature(0.3)]
class NoteCategorizer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return 'You are a note categorizer. Given a note title and content, return a short category label (one or two words, like "Work", "Personal", "Ideas", "Learning", "Finance", "Health", "Travel", or "Other") and a one-sentence summary of the note. Be concise.';
}
public function schema(JsonSchema $schema): array
{
return [
'category' => $schema->string()->required(),
'summary' => $schema->string()->required(),
];
}
}
Here is the complete NoteController.php file:
<?php
namespace App\Http\Controllers;
use App\Ai\Agents\NoteCategorizer;
use App\Models\Note;
use Illuminate\Http\Request;
class NoteController extends Controller
{
public function index(Request $request)
{
$notes = Note::query()
->latest()
->get();
return view('notes.index', compact('notes'));
}
public function create()
{
return view('notes.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
$note = Note::create($validated);
try {
$response = (new NoteCategorizer)->prompt(
"Title: {$note->title}\n\nContent: {$note->body}"
);
$note->update([
'category' => $response['category'],
'summary' => $response['summary'],
]);
} catch (\Exception $e) {
// Note is saved even if AI categorization fails
}
return redirect()->route('notes.index')
->with('success', 'Note created successfully.');
}
public function edit(Note $note)
{
return view('notes.edit', compact('note'));
}
public function update(Request $request, Note $note)
{
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
$note->update($validated);
try {
$response = (new NoteCategorizer)->prompt(
"Title: {$note->title}\n\nContent: {$note->body}"
);
$note->update([
'category' => $response['category'],
'summary' => $response['summary'],
]);
} catch (\Exception $e) {
// Note is updated even if AI categorization fails
}
return redirect()->route('notes.index')
->with('success', 'Note updated successfully.');
}
public function destroy(Note $note)
{
$note->delete();
return redirect()->route('notes.index')
->with('success', 'Note deleted successfully.');
}
}
Test the AI integration
Everything is wired up. Time to confirm that Mistral categorizes and summarizes your notes when you create or edit them.
- Switch back to your browser at http://localhost:8000.
- Click New Note.
- Enter Grocery list for the week in the Title field.
- Enter Need to buy eggs, milk, bread, and vegetables for meal prep on Sunday. in the Content field.
- Click Save Note.
You should be redirected to the dashboard. The new note should now have a category (like "Personal" or "Health") and a one-sentence summary populated in the table.
- Create a second note with a different topic to see varied categorization. Try a title like Sprint planning notes with content about a work meeting.
- Edit one of your existing notes and change its content significantly. After saving, confirm the category and summary update to reflect the new content.
What should I see?
Each note on the dashboard should now display a blue category badge (like "Personal", "Work", or "Ideas") and a short summary sentence. Different note topics should produce different categories.
- Test the error handling by temporarily changing MISTRAL_API_KEY in your .env file to invalid-key-test and creating a note.
- Confirm the note is still saved to the database with empty category and summary columns.
- Restore your real API key in .env after testing.
Category and summary still showing dashes?
Check that your MISTRAL_API_KEY in .env is set to a valid key from console.mistral.ai. Also make sure you restarted the dev server after editing .env (stop the server with Ctrl+C and run php artisan serve again).
If the note saves but has no category, check your Laravel log at storage/logs/laravel.log for error details from the AI call.
Help me debug why my AI categorization is not working
Your notes dashboard now thinks for itself. Every note gets an automatic category and summary powered by Mistral. Next up, you will take this further in the Secret Mission by adding search and filtering to your growing collection of categorized notes.
Secret mission
Add Category Filtering and Search
Your dashboard works, but finding a specific note means scrolling through the whole table. What if you could filter by category or search by title? In this mission, you will add interactive filtering that makes your dashboard feel like a real productivity tool.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local server with a free-tier API key. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The notes_dashboard PostgreSQL database running on your server.
- Your Laravel project folder containing all application code, views, and migrations.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your notes dashboard.
- Your Laravel app stays on your server and costs nothing to keep.
- Your notes_dashboard PostgreSQL database uses minimal disk space.
- Your Mistral account uses the free experiment tier with no charges.
Pause - I'll come back to this later
Stop the running development server to free up your terminal. Everything else stays in place for when you return.
- In the terminal where php artisan serve is running, press Ctrl+C to stop the server.
- Your database and project files remain untouched. Restart anytime by running php artisan serve from your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Stop the development server by pressing Ctrl+C in the terminal if it is still running.
- Drop the PostgreSQL database by running:
dropdb notes_dashboard
- Remove your Laravel project folder by running:
rm -rf [[PROJECT_FOLDER="path/to/your/laravel/project"]]
What if dropdb fails?
If you see an error about active connections, make sure php artisan serve is stopped first. The server holds an open connection to the database.
Nice Work!
Nice Work!
Nice work! You just built a full-stack notes dashboard powered by AI, from scratch, using Laravel 13.
You've learned how to:
- Built a complete Laravel 13 application with full CRUD operations, form validation, and flash messages.
- Connected Laravel to PostgreSQL using Eloquent ORM and database migrations.
- Integrated the Laravel AI SDK with Mistral to automatically categorize and summarize notes on creation.
- Secret Mission: Added category filtering and a search bar to create an interactive dashboard.
Ready to quiz yourself?