University Course Manager in Laravel
Build a full-stack CRUD app with Laravel, React, and Inertia.js.
Introduction
30 Second Summary
Every university needs a way to track courses, from introductory seminars to advanced research labs. Managing that data is one of the most common challenges in web development.
In this project, you will build a full-stack course management system using Laravel and React. You will create, browse, edit, and delete courses through a polished web interface.
What You'll Build
A university course manager where authenticated users can perform full CRUD operations on courses, powered by Laravel on the backend and React on the frontend with Inertia.js connecting them.
By the end of this project, you'll have:
- A running Laravel 13 application with a React frontend, Tailwind CSS styling, and built-in authentication.
- A complete CRUD system for managing university courses with an Eloquent model, database migrations, and server-side validation.
- Interactive React page components with data tables, forms, and navigation built using shadcn/ui.
- Secret Mission: add a search and filter feature to find courses by department or keyword.
Are there any prerequisites?
This project assumes a basic understanding of PHP and JavaScript/React syntax. You do not need prior experience with Laravel or Inertia.
Before We Start
Before We Start...
Install Laravel Herd and Create the Project
To build a full-stack application with Laravel and React, you need a development environment that provides PHP, a web server, and Node.js. Setting these up individually on Windows can be tricky.
Laravel Herd solves this by bundling everything into a single installer. In this step, you will install Herd, create a new Laravel project with the React starter kit, and get your development server running.
In this step, get ready to:
- Install Laravel Herd and verify PHP, Composer, and Node.js are available.
- Create a new Laravel application with the React starter kit.
- Start the development server and confirm the app loads in your browser.
Download and install Laravel Herd
Laravel Herd is a native development environment for Windows that ships with PHP, nginx, Node.js, Composer, and the Laravel installer. One download gives you everything you need.
- Open your web browser and navigate to herd.laravel.com.
- Click the download button to get the latest Windows installer.
- Open the downloaded installer from your Downloads folder.
- Run the installer with administrator permissions when prompted.
Why does it need admin permissions?
The installer adds a HerdHelper service that manages your local domains. This service needs admin rights to update your system's hosts file and map your projects to .test domains.
- Once installation completes, launch Herd from your Start menu or system tray.
Now verify that Herd installed correctly by checking the tools it provides.
- Open PowerShell by pressing the Windows key, typing PowerShell, and pressing Enter.
- Verify your tools are available by running these commands:
php --version
composer --version
node --version
✔️ I see version numbers for all three
Herd is installed and working. You should see PHP 8.5 (or higher), a Composer version, and a Node.js version. You are ready to create your Laravel project.
ⓧ One or more commands say 'not recognized'
Herd may not have added its tools to your system PATH yet. Try these fixes:
- Close PowerShell completely and reopen it. Herd registers its PATH entries during installation, but an already-open terminal does not pick them up.
- Check that Herd is running. Look for the Herd icon in your system tray (bottom-right corner of your taskbar). If it is not there, open Herd from the Start menu.
- If the commands still fail after restarting PowerShell, restart your computer to ensure all PATH changes take effect.
Still not working after a restart?
Confirm you are running Windows 10 or higher. Herd requires Windows 10 as a minimum. If your system meets the requirement but commands still fail, try reinstalling Herd from herd.laravel.com.
Help me fix my Laravel Herd PATH issue
Create a new Laravel application with the React starter kit
Herd parks a directory at C:\Users\YourName\Herd by default. Any Laravel project you create inside this folder is automatically available at a local .test domain.
- Navigate to your Herd directory by running this command:
cd $env:USERPROFILE\Herd
- Create a new Laravel application by running:
laravel new university-app
What will the installer ask me?
The Laravel installer prompts you to choose a starter kit, an authentication provider, and a testing framework. Here is what to select for this project:
- Starter kit: select React.
- Authentication provider: select Laravel's built-in authentication.
- Testing framework: accept the default (Pest is fine).
- Database: select SQLite when prompted.
- Wait for the installer to finish. It will download dependencies and set up the project structure.
What did the installer create?
Your new university-app folder contains a full Laravel 13 application with Inertia.js bridging the backend to a React 19 frontend. It includes Tailwind CSS for styling, shadcn/ui components, and a working authentication system out of the box.
Seeing a network or download error?
The installer downloads packages from the internet. If it fails partway through, check your internet connection and run laravel new university-app again. Laravel will offer to overwrite the existing folder.
Help me fix my Laravel installer error
Install dependencies and start the development server
The Laravel installer set up the backend, but the frontend still needs its JavaScript dependencies installed and compiled before the app can run.
- Move into your new project folder by running:
cd university-app
- Install the frontend dependencies and build the assets by running:
npm install && npm run build
What does this do?
The npm install command downloads React, Inertia, Tailwind, and all other JavaScript packages the project needs. The npm run build command compiles these into optimized files the browser can load.
- Start the development server by running:
composer run dev
What does composer run dev start?
This command starts two processes: the PHP development server (which serves your Laravel backend) and the Vite development server (which hot-reloads your React frontend). Both run together so you can develop with live feedback.
- Open your browser and navigate to http://localhost:8000.
- You should see the Laravel welcome page with a login and register link in the top-right corner.
Page not loading at localhost:8000?
- Make sure the composer run dev command is still running in your PowerShell window. Do not close that terminal.
- Check that no other application is using port 8000. If another service occupies that port, stop it first and rerun composer run dev.
- Confirm you ran npm install && npm run build before starting the server. Without built assets, the page may appear broken.
Help me troubleshoot my Laravel dev server
Your development environment is ready and the application is running. Next up, you will create the Course model and database table that will store your university courses.
Create the Course Model, Migration, and Controller
Your Laravel application is running and the development environment is ready. Now you need to create the backend structure that will store and manage course data.
Laravel organizes backend code using the MVC pattern: a Model represents your data, a Migration defines the database table structure, and a Controller handles incoming requests. In this step, you'll generate all three for courses using a single command, then configure them.
In this step, get ready to:
- Generate the Course model, migration, factory, seeder, and controller with a single Artisan command.
- Define the database schema for the courses table and run the migration.
- Configure the Course model for mass assignment using the #[Fillable] attribute.
Generate the Course model and supporting files
Laravel's Artisan CLI can scaffold an entire set of related files with one command. You'll generate a model, migration, factory, seeder, resource controller, and form request classes all at once.
- Open a new PowerShell window. Your dev server is already running in the other terminal, so you need a separate one for Artisan commands.
- Navigate into your project folder by running this command:
cd ~\Herd\university-app
- Generate the Course model with all supporting files by running this command:
php artisan make:model Course -mfsc --resource --requests
What does this command do?
The make:model command generates an Eloquent model class. The flags add related files:
- -m creates a database migration file.
- -f creates a factory for generating test data.
- -s creates a seeder for populating the database.
- -c --resource creates a resource controller with methods for all CRUD operations.
- --requests creates form request classes for validation.
- You should see output confirming that several files were created, including app/Models/Course.php, a migration file in database/migrations/, and app/Http/Controllers/CourseController.php.
Command not found?
Make sure you're inside the university-app folder. The php artisan command only works from the root of a Laravel project where the artisan file exists.
Help me troubleshoot the artisan command
Define the migration schema for the courses table
The generated migration file has an empty table definition. You need to add columns that describe what a course looks like: its title, code, description, credits, and department.
- In your code editor, open the migration file inside database/migrations/. The file name starts with a timestamp and ends with _create_courses_table.php.
- Find the $table->id(); line inside the up() method. Replace the entire contents of the up() method with the following code:
Schema::create('courses', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('code')->unique();
$table->text('description')->nullable();
$table->integer('credits');
$table->string('department');
$table->timestamps();
});
What does this schema define?
- string('title') stores the course name as a varchar column.
- string('code')->unique() stores the course code (like CS101) and enforces uniqueness so no two courses share a code.
- text('description')->nullable() allows a longer text field that can be left empty.
- integer('credits') stores how many credit hours the course is worth.
- timestamps() adds created_at and updated_at columns automatically.
- Save the migration file.
✔️ Awesome, I've got everything!
Great. Double check you've saved the migration file before running the next command.
ⓧ I'd like to double check the full 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('courses', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('code')->unique();
$table->text('description')->nullable();
$table->integer('credits');
$table->string('department');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('courses');
}
};
- Run the migration to create the courses table in your SQLite database by running this command:
php artisan migrate
- You should see output that includes a line about creating the courses table. This confirms the table now exists in your database.
Migration failed?
If you see a syntax error, open the migration file and check that every line inside the Schema::create closure ends with a semicolon. Also confirm that ->unique() and ->nullable() are chained before the semicolon, not after it.
Help me fix my migration error
Configure the Course model with mass assignment protection
Laravel protects your models from unintended data changes through mass assignment protection. You need to explicitly declare which fields can be filled when creating or updating a course.
- Open app/Models/Course.php in your code editor.
- Replace the entire contents of the file with the following code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
// Declare which fields can be mass-assigned when creating or updating courses
#[Fillable(['title', 'code', 'description', 'credits', 'department'])]
class Course extends Model
{
// Enable factory support for testing and seeding
use HasFactory;
}
What is the #[Fillable] attribute?
In Laravel 13, the #[Fillable] PHP attribute replaces the older $fillable property. It tells Eloquent which fields are safe to accept from user input.
Without this, calling Course::create([...]) would throw an error. The attribute acts as an allowlist, preventing malicious users from injecting unexpected fields into your database.
- Save Course.php. You won't see a visible change yet. This configuration takes effect when you create courses in the next step.
Seeing a namespace error later?
Make sure the use Illuminate\Database\Eloquent\Attributes\Fillable; import is present at the top of the file. Without it, PHP won't recognize the #[Fillable] attribute.
Help me fix my Course model
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['title', 'code', 'description', 'credits', 'department'])]
class Course extends Model
{
use HasFactory;
}
Your database is ready and your model knows which fields to accept. Next, you'll build the course index page so users can see their courses in the browser.
Build the Course Index Page
Your database is set up and your Course model is ready to store data. Now you need a way for users to actually see courses in the browser.
In this step, you will connect the backend to the frontend by writing a controller method that fetches courses, registering a route, and building a React page component that displays everything in a polished table.
In this step, get ready to:
- Define the index controller method and register the resource route.
- Create a React page component with a data table.
- Add a navigation link and verify the page loads.
Define the index method and register the route
The controller method is the bridge between your database and your React frontend. It fetches data using Eloquent and hands it to Inertia.js, which passes it as props to a React component.
- In your editor, open app/Http/Controllers/CourseController.php.
- Find the comment that says // Resource controller scaffold — methods to be filled in subsequent steps and replace it with the following index method:
public function index(): Response
{
// Fetch all courses, newest first
$courses = Course::orderBy('created_at', 'desc')->get();
// Pass the courses to the React page component
return Inertia::render('courses/index', [
'courses' => $courses,
]);
}
What does this code do?
Course::orderBy('created_at', 'desc')->get() uses Eloquent to fetch every course from the database, sorted so the newest appear first.
Inertia::render('courses/index', [...]) tells Inertia to load the React component located at resources/js/pages/courses/index.tsx and pass courses as a prop.
- Save CourseController.php.
Next, you need to register a route so Laravel knows to call this method when a user visits /courses.
- Open routes/web.php.
- Add the following code at the bottom of the file, after the existing route definitions:
use App\Http\Controllers\CourseController;
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('courses', CourseController::class)->except(['show']);
});
What does this code do?
Route::resource automatically generates routes for all CRUD operations (index, create, store, edit, update, destroy). The except(['show']) skips the single-course detail page since this app does not need one.
Wrapping it in middleware(['auth', 'verified']) means only logged-in, verified users can access these routes.
- Save web.php.
Seeing a duplicate use statement error?
If routes/web.php already has a use App\Http\Controllers\CourseController; line at the top, remove the duplicate. PHP only allows one import per class.
Help me debug my routes/web.php error
Create the React page component
The controller now returns data to a page called courses/index. You need to create that React component so Inertia has something to render.
- Install the shadcn/ui Table component by running this command in a new terminal:
npx shadcn@latest add table
What does this command do?
This downloads the Table component source code into resources/js/components/ui/table.tsx. Unlike traditional npm packages, shadcn/ui publishes editable component files directly into your project.
- Create a new folder called courses inside resources/js/pages/.
- Create a new file called index.tsx inside the resources/js/pages/courses/ folder.
- Paste the following code into index.tsx:
import AppLayout from '@/layouts/app-layout';
import { Head, Link, router } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
// Define the shape of a Course object from the backend
interface Course {
id: number;
title: string;
code: string;
description: string | null;
credits: number;
department: string;
}
// Props passed from the CourseController index method
interface Props {
courses: Course[];
}
export default function CoursesIndex({ courses }: Props) {
// Confirm before deleting a course
function handleDelete(id: number) {
if (confirm('Are you sure you want to delete this course?')) {
router.delete(route('courses.destroy', id));
}
}
return (
<AppLayout>
<Head title="Courses" />
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Courses</h1>
<Link href={route('courses.create')}>
<Button>New Course</Button>
</Link>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Title</TableHead>
<TableHead>Department</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course) => (
<TableRow key={course.id}>
<TableCell className="font-mono">{course.code}</TableCell>
<TableCell>{course.title}</TableCell>
<TableCell>{course.department}</TableCell>
<TableCell>{course.credits}</TableCell>
<TableCell className="space-x-2">
<Link href={route('courses.edit', course.id)}>
<Button variant="outline" size="sm">Edit</Button>
</Link>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(course.id)}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{courses.length === 0 && (
<p className="text-center text-muted-foreground mt-8">
No courses yet. Create your first course!
</p>
)}
</div>
</AppLayout>
);
}
What does this code do?
The Course interface defines the TypeScript shape of each course object. The Props interface tells React that this component receives a courses array from Inertia.
The component renders a Table from shadcn/ui, mapping over each course to create a row. Each row includes Edit and Delete buttons for later steps.
The empty-state message at the bottom shows when no courses exist yet.
- Save index.tsx.
Seeing a TypeScript error about route()?
The route() helper is provided by the Ziggy package, which the Laravel React starter kit includes by default. If your editor shows a type error, make sure you ran npm install in Step 1. The types should resolve after the dev server restarts.
Help me fix the route() TypeScript error
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full CourseController.php at this point:
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CourseController extends Controller
{
public function index(): Response
{
$courses = Course::orderBy('created_at', 'desc')->get();
return Inertia::render('courses/index', [
'courses' => $courses,
]);
}
}
Here is the route addition in routes/web.php:
use App\Http\Controllers\CourseController;
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('courses', CourseController::class)->except(['show']);
});
Here is the full resources/js/pages/courses/index.tsx:
import AppLayout from '@/layouts/app-layout';
import { Head, Link, router } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface Course {
id: number;
title: string;
code: string;
description: string | null;
credits: number;
department: string;
}
interface Props {
courses: Course[];
}
export default function CoursesIndex({ courses }: Props) {
function handleDelete(id: number) {
if (confirm('Are you sure you want to delete this course?')) {
router.delete(route('courses.destroy', id));
}
}
return (
<AppLayout>
<Head title="Courses" />
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Courses</h1>
<Link href={route('courses.create')}>
<Button>New Course</Button>
</Link>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Title</TableHead>
<TableHead>Department</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course) => (
<TableRow key={course.id}>
<TableCell className="font-mono">{course.code}</TableCell>
<TableCell>{course.title}</TableCell>
<TableCell>{course.department}</TableCell>
<TableCell>{course.credits}</TableCell>
<TableCell className="space-x-2">
<Link href={route('courses.edit', course.id)}>
<Button variant="outline" size="sm">Edit</Button>
</Link>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(course.id)}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{courses.length === 0 && (
<p className="text-center text-muted-foreground mt-8">
No courses yet. Create your first course!
</p>
)}
</div>
</AppLayout>
);
}
Add the navigation link and verify the page
The page component exists, but users have no way to reach it from the sidebar. The Laravel React starter kit stores sidebar navigation items in resources/js/components/app-sidebar.tsx.
- Open resources/js/components/app-sidebar.tsx.
- Find the array of navigation items (look for links like Dashboard).
- Add a new entry for Courses with the URL /courses. Follow the same pattern as the existing Dashboard link.
- Save the file.
Why /courses?
The Route::resource you registered earlier automatically maps /courses to the index method of CourseController. Laravel's resource routing convention handles the URL structure for you.
- Switch to your browser and navigate to http://localhost:8000/courses.
You should see a page with a "Courses" heading, a "New Course" button, and a table with column headers (Code, Title, Department, Credits, Actions). The table body is empty with the message "No courses yet. Create your first course!" because no courses exist in the database yet.
Seeing a 404 or blank page?
- Make sure you are logged in. The route requires authentication. Visit http://localhost:8000/login first if needed.
- Confirm your dev server is still running. If you stopped it, run composer run dev again from your university-app folder.
- Check that resources/js/pages/courses/index.tsx exists at exactly that path. Inertia matches the folder and file name to the string in Inertia::render('courses/index').
Help me debug my /courses page not loading
Your courses page is live. Next up, you will build the form that lets users create new courses and see them appear in this table.
Build the Create Course Page
Your courses table is live and ready to display data. The problem is there's no way for users to actually add courses to it yet.
In this step, you'll build the "Create" part of CRUD. You'll add backend methods that handle form submission and validation, then create a React form page that lets users fill in course details.
In this step, get ready to:
- Add the create and store methods to the CourseController.
- Build a React form page for creating new courses.
- Test the full create flow end-to-end.
Add the create and store methods to CourseController
The resource route you registered earlier already maps GET /courses/create and POST /courses to controller methods. You just need to write those methods.
- In app/Http/Controllers/CourseController.php, find the closing bracket of the index method.
- Add the following two methods directly below it:
public function create(): Response
{
return Inertia::render('courses/create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'code' => 'required|string|max:20|unique:courses,code',
'description' => 'nullable|string',
'credits' => 'required|integer|min:1|max:12',
'department' => 'required|string|max:255',
]);
Course::create($validated);
return redirect()->route('courses.index')->with('success', 'Course created successfully.');
}
What does this code do?
The create method simply renders the React form page via Inertia. No data needs to be passed because the form starts empty.
The store method does the heavy lifting. It validates all five fields using Laravel's built-in validation rules, creates the course via Eloquent's mass assignment (protected by the #[Fillable] attribute you set earlier), and redirects back to the course list.
- Save CourseController.php.
Seeing a route error?
Make sure you placed the new methods inside the CourseController class, not outside the closing brace. The create and store methods must be siblings of the index method.
help me fix a route error in my CourseController
Create the course form page component
The controller renders courses/create, which Inertia maps to a React component at resources/js/pages/courses/create.tsx. You need to create this file with a form that posts data back to Laravel.
- The form needs a Textarea component from shadcn/ui. Add it to your project by running this command:
npx shadcn@latest add textarea
What is shadcn/ui?
Unlike a traditional component library installed from npm, shadcn/ui copies the component source code directly into your project. You own and can customize every component. The add command places the Textarea file at resources/js/components/ui/textarea.tsx.
- Create a new file at resources/js/pages/courses/create.tsx.
- Paste the following code into it:
import AppLayout from '@/layouts/app-layout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
export default function CoursesCreate() {
// useForm manages form state, submission, and validation errors
const { data, setData, post, processing, errors } = useForm({
title: '',
code: '',
description: '',
credits: 3,
department: '',
});
// Prevent default browser submit and POST via Inertia
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
post(route('courses.store'));
}
return (
<AppLayout>
<Head title="Create Course" />
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Create Course</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
/>
{errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
</div>
<div>
<Label htmlFor="code">Course Code</Label>
<Input
id="code"
value={data.code}
onChange={(e) => setData('code', e.target.value)}
placeholder="e.g. CS101"
/>
{errors.code && <p className="text-sm text-destructive mt-1">{errors.code}</p>}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={data.description}
onChange={(e) => setData('description', e.target.value)}
/>
{errors.description && <p className="text-sm text-destructive mt-1">{errors.description}</p>}
</div>
<div>
<Label htmlFor="credits">Credits</Label>
<Input
id="credits"
type="number"
min={1}
max={12}
value={data.credits}
onChange={(e) => setData('credits', parseInt(e.target.value))}
/>
{errors.credits && <p className="text-sm text-destructive mt-1">{errors.credits}</p>}
</div>
<div>
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={data.department}
onChange={(e) => setData('department', e.target.value)}
placeholder="e.g. Computer Science"
/>
{errors.department && <p className="text-sm text-destructive mt-1">{errors.department}</p>}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={processing}>
Create Course
</Button>
<Link href={route('courses.index')}>
<Button type="button" variant="outline">Cancel</Button>
</Link>
</div>
</form>
</div>
</AppLayout>
);
}
What does this code do?
- useForm from Inertia manages all form state in one place. It tracks field values, handles the POST request, exposes a processing flag to disable the button during submission, and automatically populates the errors object with any server-side validation failures.
- Each field wraps a Label, an Input (or Textarea), and a conditional error message. If Laravel's validation rejects a field, the error text appears below it instantly.
- The Cancel button uses Inertia's Link component to navigate back to the course list without a full page reload.
- Save create.tsx.
Form page not loading?
Check that your file is named create.tsx (not Create.tsx) and placed inside resources/js/pages/courses/. Inertia's page resolution is case-sensitive and must match the string passed to Inertia::render('courses/create') exactly.
help me debug why my create course page is not loading
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full CourseController.php at this point:
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CourseController extends Controller
{
public function index(): Response
{
$courses = Course::orderBy('created_at', 'desc')->get();
return Inertia::render('courses/index', [
'courses' => $courses,
]);
}
public function create(): Response
{
return Inertia::render('courses/create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'code' => 'required|string|max:20|unique:courses,code',
'description' => 'nullable|string',
'credits' => 'required|integer|min:1|max:12',
'department' => 'required|string|max:255',
]);
Course::create($validated);
return redirect()->route('courses.index')->with('success', 'Course created successfully.');
}
}
And here is the full create.tsx:
import AppLayout from '@/layouts/app-layout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
export default function CoursesCreate() {
const { data, setData, post, processing, errors } = useForm({
title: '',
code: '',
description: '',
credits: 3,
department: '',
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
post(route('courses.store'));
}
return (
<AppLayout>
<Head title="Create Course" />
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Create Course</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
/>
{errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
</div>
<div>
<Label htmlFor="code">Course Code</Label>
<Input
id="code"
value={data.code}
onChange={(e) => setData('code', e.target.value)}
placeholder="e.g. CS101"
/>
{errors.code && <p className="text-sm text-destructive mt-1">{errors.code}</p>}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={data.description}
onChange={(e) => setData('description', e.target.value)}
/>
{errors.description && <p className="text-sm text-destructive mt-1">{errors.description}</p>}
</div>
<div>
<Label htmlFor="credits">Credits</Label>
<Input
id="credits"
type="number"
min={1}
max={12}
value={data.credits}
onChange={(e) => setData('credits', parseInt(e.target.value))}
/>
{errors.credits && <p className="text-sm text-destructive mt-1">{errors.credits}</p>}
</div>
<div>
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={data.department}
onChange={(e) => setData('department', e.target.value)}
placeholder="e.g. Computer Science"
/>
{errors.department && <p className="text-sm text-destructive mt-1">{errors.department}</p>}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={processing}>
Create Course
</Button>
<Link href={route('courses.index')}>
<Button type="button" variant="outline">Cancel</Button>
</Link>
</div>
</form>
</div>
</AppLayout>
);
}
Test creating a course end-to-end
The New Course button already exists on your index page from the previous step. Now that the backend and form page are in place, the full flow should work.
- Open http://localhost:8000/courses in your browser.
- Click the New Course button in the top right.
- Fill in the form with sample data. For example, enter Introduction to Computer Science as the title, CS101 as the code, 3 credits, and Computer Science as the department.
- Click Create Course.
You should be redirected back to the courses index page. Your new course now appears in the table.
- Test validation by clicking New Course again, leaving all fields empty, and clicking Create Course.
You should see red error messages appear below each required field. This confirms that Laravel's server-side validation is working and Inertia is passing errors back to your React form.
Course not appearing in the table?
Make sure your dev server is still running (composer run dev in the terminal). If you restarted your terminal, you need to start the server again.
Also verify that the #[Fillable] attribute on your Course model includes all five fields. Without it, Course::create() silently ignores the data.
help me figure out why my course is not saving to the database
Your create flow is working. Users can now add courses to the database through a polished form with instant validation feedback. Next up, you'll build the edit and delete features to complete the full CRUD cycle.
Build the Edit and Delete Features
You can now create courses and see them in a table. The next challenge is letting users update existing courses and remove ones they no longer need.
In this step, you will complete the CRUD cycle by adding edit, update, and destroy methods to your controller, then building an edit form that comes pre-filled with the course's current data.
In this step, get ready to:
- Add the edit, update, and destroy methods to your controller.
- Create an edit page component with a pre-filled form.
- Test the full CRUD workflow from create to delete.
Define the edit, update, and destroy methods
Your controller already handles creating courses. Now it needs three more methods to complete the loop: one to show the edit form, one to save the changes, and one to delete a course entirely.
- In your editor, open app/Http/Controllers/CourseController.php.
- Find the closing } of the store method (the last method in the class).
- Add the following edit method below it:
public function edit(Course $course): Response
{
// Pass the existing course to the edit page
return Inertia::render('courses/edit', [
'course' => $course,
]);
}
What does this method do?
Laravel automatically finds the course by its ID from the URL (route model binding). The method then passes that course object to the React edit page as a prop.
- Below the edit method, add the update method:
public function update(Request $request, Course $course): RedirectResponse
{
// Validate fields, ignoring the current course's own code for uniqueness
$validated = $request->validate([
'title' => 'required|string|max:255',
'code' => 'required|string|max:20|unique:courses,code,' . $course->id,
'description' => 'nullable|string',
'credits' => 'required|integer|min:1|max:12',
'department' => 'required|string|max:255',
]);
$course->update($validated);
return redirect()->route('courses.index')->with('success', 'Course updated successfully.');
}
Why does the unique rule look different here?
When updating, the course already has a code stored in the database. Without the unique:courses,code,' . $course->id exception, Laravel would reject the form because it finds a matching code (the course's own current value).
Appending the course's ID tells the validator to ignore that specific row when checking uniqueness. This way, the code stays unique across all other courses but the current course can keep its own code unchanged.
- Below the update method, add the destroy method:
public function destroy(Course $course): RedirectResponse
{
// Delete the course from the database
$course->delete();
return redirect()->route('courses.index')->with('success', 'Course deleted successfully.');
}
What does this method do?
The destroy method calls $course->delete() to remove the record from the database permanently. It then redirects the user back to the courses list with a success flash message.
- Save CourseController.php.
You won't see a visible change yet because the edit page component does not exist. You will create it next.
Seeing a syntax error?
Make sure each new method sits between the class opening { and closing }. Methods should be siblings of store, not nested inside it.
Help me find the issue
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CourseController extends Controller
{
public function index(): Response
{
$courses = Course::orderBy('created_at', 'desc')->get();
return Inertia::render('courses/index', [
'courses' => $courses,
]);
}
public function create(): Response
{
return Inertia::render('courses/create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'code' => 'required|string|max:20|unique:courses,code',
'description' => 'nullable|string',
'credits' => 'required|integer|min:1|max:12',
'department' => 'required|string|max:255',
]);
Course::create($validated);
return redirect()->route('courses.index')->with('success', 'Course created successfully.');
}
public function edit(Course $course): Response
{
return Inertia::render('courses/edit', [
'course' => $course,
]);
}
public function update(Request $request, Course $course): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'code' => 'required|string|max:20|unique:courses,code,' . $course->id,
'description' => 'nullable|string',
'credits' => 'required|integer|min:1|max:12',
'department' => 'required|string|max:255',
]);
$course->update($validated);
return redirect()->route('courses.index')->with('success', 'Course updated successfully.');
}
public function destroy(Course $course): RedirectResponse
{
$course->delete();
return redirect()->route('courses.index')->with('success', 'Course deleted successfully.');
}
}
Create the edit page component
The edit page receives the existing course as a prop and pre-fills the form fields with its current values. The submit function uses put instead of post to send an update request.
- Create a new file at resources/js/pages/courses/edit.tsx and paste the following code:
import AppLayout from '@/layouts/app-layout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
// Define the shape of the course prop from the controller
interface Course {
id: number;
title: string;
code: string;
description: string | null;
credits: number;
department: string;
}
interface Props {
course: Course;
}
export default function CoursesEdit({ course }: Props) {
// Pre-fill the form with the course's current values
const { data, setData, put, processing, errors } = useForm({
title: course.title,
code: course.code,
description: course.description || '',
credits: course.credits,
department: course.department,
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Use PUT to send an update request to the controller
put(route('courses.update', course.id));
}
return (
<AppLayout>
<Head title="Edit Course" />
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Edit Course</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
/>
{errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
</div>
<div>
<Label htmlFor="code">Course Code</Label>
<Input
id="code"
value={data.code}
onChange={(e) => setData('code', e.target.value)}
/>
{errors.code && <p className="text-sm text-destructive mt-1">{errors.code}</p>}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={data.description}
onChange={(e) => setData('description', e.target.value)}
/>
{errors.description && <p className="text-sm text-destructive mt-1">{errors.description}</p>}
</div>
<div>
<Label htmlFor="credits">Credits</Label>
<Input
id="credits"
type="number"
min={1}
max={12}
value={data.credits}
onChange={(e) => setData('credits', parseInt(e.target.value))}
/>
{errors.credits && <p className="text-sm text-destructive mt-1">{errors.credits}</p>}
</div>
<div>
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={data.department}
onChange={(e) => setData('department', e.target.value)}
/>
{errors.department && <p className="text-sm text-destructive mt-1">{errors.department}</p>}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={processing}>
Update Course
</Button>
<Link href={route('courses.index')}>
<Button type="button" variant="outline">Cancel</Button>
</Link>
</div>
</form>
</div>
</AppLayout>
);
}
How is this different from the create page?
The useForm hook initializes each field with the course's existing data instead of empty strings. This means the form appears pre-filled when the page loads.
The submit function uses put instead of post. This sends a PUT request to the update method in your controller, which is the HTTP convention for modifying an existing resource.
- Save edit.tsx.
- Return to your browser at http://localhost:8000 and navigate to the courses page.
- Click the Edit button on any course row. You should see the edit form with all fields pre-filled with that course's data.
Seeing a 404 when clicking Edit?
Make sure the file is saved at exactly resources/js/pages/courses/edit.tsx. Inertia.js resolves page components by their path relative to the pages directory. A typo in the folder or filename causes a page-not-found error.
Also confirm that the edit method in your controller renders 'courses/edit' (lowercase, forward slash).
Help me debug this 404 error
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import AppLayout from '@/layouts/app-layout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
interface Course {
id: number;
title: string;
code: string;
description: string | null;
credits: number;
department: string;
}
interface Props {
course: Course;
}
export default function CoursesEdit({ course }: Props) {
const { data, setData, put, processing, errors } = useForm({
title: course.title,
code: course.code,
description: course.description || '',
credits: course.credits,
department: course.department,
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
put(route('courses.update', course.id));
}
return (
<AppLayout>
<Head title="Edit Course" />
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Edit Course</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
/>
{errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
</div>
<div>
<Label htmlFor="code">Course Code</Label>
<Input
id="code"
value={data.code}
onChange={(e) => setData('code', e.target.value)}
/>
{errors.code && <p className="text-sm text-destructive mt-1">{errors.code}</p>}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={data.description}
onChange={(e) => setData('description', e.target.value)}
/>
{errors.description && <p className="text-sm text-destructive mt-1">{errors.description}</p>}
</div>
<div>
<Label htmlFor="credits">Credits</Label>
<Input
id="credits"
type="number"
min={1}
max={12}
value={data.credits}
onChange={(e) => setData('credits', parseInt(e.target.value))}
/>
{errors.credits && <p className="text-sm text-destructive mt-1">{errors.credits}</p>}
</div>
<div>
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={data.department}
onChange={(e) => setData('department', e.target.value)}
/>
{errors.department && <p className="text-sm text-destructive mt-1">{errors.department}</p>}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={processing}>
Update Course
</Button>
<Link href={route('courses.index')}>
<Button type="button" variant="outline">Cancel</Button>
</Link>
</div>
</form>
</div>
</AppLayout>
);
}
Test the full CRUD workflow
All four CRUD operations are wired up. Time to run through the entire lifecycle of a course to confirm everything works end to end.
- Navigate to your courses page and click New Course.
- Fill in the form with test data (for example: title Introduction to AI, code CS201, credits 4, department Computer Science) and click Create Course.
- Verify the new course appears in the table.
- Click Edit on the course you just created.
- Change the title to Advanced AI and click Update Course.
- Verify the updated title shows in the table.
- Click Delete on that course. A browser confirmation dialog appears.
- Click OK to confirm. The course disappears from the list.
How do I test validation errors?
Go to the edit form and clear the Title field so it is empty. Click Update Course. You should see a red validation error message appear below the title input.
Laravel rejected the request because title is required, and Inertia.js automatically passed that error back to your form.
Edit works but Delete does nothing?
Make sure the handleDelete function in index.tsx uses router.delete (not router.post or router.get). The destroy route only responds to DELETE requests.
Help me debug the delete button
Your course manager now supports the full CRUD lifecycle. Users can create, read, update, and delete courses with proper validation at every step. Next up, you will tackle a secret mission to make the course list searchable and filterable.
Secret mission
Add Search and Filtering
Your course list works, but imagine a university with hundreds of courses. Users need a way to quickly find what they are looking for. In this secret mission, you will add a search bar and a department dropdown filter that update the table instantly without a full page reload.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The university-app project folder (includes all application code and the SQLite database).
- Laravel Herd (provides PHP, Composer, Node.js, and nginx).
- The dev server process started with composer run dev.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your course manager.
- Your dev server continues running at http://localhost:8000.
- Laravel Herd stays installed and provides PHP, Composer, Node.js, and nginx for any future Laravel projects.
- Your university-app folder and SQLite database remain intact with all your course data.
Pause - I'll come back to this later
Stop the running dev server to free up your terminal and system resources. Your project files stay intact so you can resume later.
- Switch to the terminal where composer run dev is running.
- Press Ctrl+C to stop the dev server.
- Your university-app folder and database remain on disk. When you are ready to resume, navigate back to the folder and run composer run dev again.
Delete - I don't want to use this again
Remove all project files and optionally uninstall Laravel Herd to start completely fresh.
- Switch to the terminal where composer run dev is running and press Ctrl+C to stop the server.
- Delete the university-app project folder by running this command in PowerShell:
Remove-Item -Recurse -Force $env:USERPROFILE\Herd\university-app
- To uninstall Laravel Herd completely, follow these steps from the official documentation:
- Right-click the Herd icon in the menu bar and select Quit.
- Open Settings, then Apps, then Installed apps. Find Herd and click Uninstall.
- Delete the Herd config directory by running:
Remove-Item -Recurse -Force $env:USERPROFILE\.config\herd
- Remove the NVM_HOME and NVM_SYMLINK environment variables from your system environment variables if present.
- Delete the shortcut at C:\Program Files\nodejs if it is still present.
Nice Work!
Nice Work!
You just built a full-stack university course manager with Laravel and React. You now have a working application that handles the complete lifecycle of course data, from creation to deletion, with a polished frontend and validated backend.
You've learned how to:
- Build a Laravel 13 application with a React 19 frontend connected via Inertia 3, using server-side routing with client-side interactivity.
- Create an Eloquent model with database migrations, server-side validation, and mass assignment protection using PHP attributes.
- Implement all four CRUD operations with interactive React page components, forms, tables, and navigation powered by shadcn/ui.
- Added search and filtering to the course list so users can find courses by department or keyword.
Ready to quiz yourself?