Let's try to create a simple CRUD for Tasks with two fields (for now): name
and is_completed
.
In this lesson, we'll manage the Model/Migration, Routes, and Controllers and add a navigation link in the top menu.
Preparing the Database
First, we create the DB structure with factories to create some fake records:
php artisan make:model Task -mf
The table structure is in Migration.
database/migrations/xxxx_create_tasks_table.php:
public function up(): void{ Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->string('name'); $table->boolean('is_completed')->default(false); $table->timestamps(); });}
In the Model, we just make the fields fillable and cast is_completed
to boolean.
use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model; class Task extends Model{ use HasFactory; protected $fillable = [ 'name', 'is_completed' ]; protected function casts(): array { return [ 'is_completed' => 'boolean' ]; }}
Then, the Factory with the rules.
database/factories/TaskFactory.php:
class TaskFactory extends Factory{ public function definition(): array { return [ 'name' => fake()->name(), 'is_completed' => fake()->boolean(), ]; }}
Finally, that Factory should be used in the main seeder to create 10 fake task records.
database/seeders/DatabaseSeeder.php:
use App\Models\Task;use App\Models\User;// use Illuminate\Database\Console\Seeds\WithoutModelEvents;use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder{ public function run(): void { User::factory()->create([ 'name' => 'Test User', ]); Task::factory()->count(10)->create(); }}
And then we run the command:
php artisan migrate --seed
As a result, we have 10 records in the DB.
Controller and Routes
We will create a Resource Controller to manage the tasks with this command:
php artisan make:controller TaskController --resource --model=Task
Then, we assign that Controller to the Routes.
routes/web.php:
Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', function () { return Inertia::render('dashboard'); })->name('dashboard'); Route::resource('tasks', TaskController::class); });
Now, what's inside that Controller?
We will fill it with the CRUD actions, with Inertia referencing the new React.js components that don't yet exist. We will actually create them in the next lesson.
We will also need validation rules. I prefer to use Form Request classes, so I will generate them right away.
php artisan make:request StoreTaskRequestphp artisan make:request UpdateTaskRequest
And here are the contents.
app/Http/Requests/StoreTaskRequest.php:
class StoreTaskRequest extends FormRequest{ public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], ]; }}
app/Http/Requests/UpdateTaskRequest.php:
class StoreTaskRequest extends FormRequest{ public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], 'is_completed' => ['boolean'], ]; }}
Almost identical, with one difference: the create form will not contain the is_completed
field, so it's not present in the rules()
list.
Now, here's our Controller.
app/Http/Controllers/TaskController.php:
namespace App\Http\Controllers; use App\Http\Requests\StoreTaskRequest;use App\Http\Requests\UpdateTaskRequest;use App\Models\Task;use Inertia\Inertia; class TaskController extends Controller{ public function index() { return Inertia::render('Tasks/Index', [ 'tasks' => Task::all(), ]); } public function create() { return Inertia::render('Tasks/Create'); } public function store(StoreTaskRequest $request) { Task::create($request->validated() + ['is_completed' => false]); return redirect()->route('tasks.index'); } public function edit(Task $task) { return Inertia::render('Tasks/Edit', [ 'task' => $task, ]); } public function update(UpdateTaskRequest $request, Task $task) { $task->update($request->validated()); return redirect()->route('tasks.index'); } public function destroy(Task $task) { $task->delete(); return redirect()->route('tasks.index'); }}
Great, now how do we test if it works?
Let's add a menu item leading to the tasks.index
route.
Index React Component and Navigation Item
In the index()
method of the Controller, we return this React component:
return Inertia::render('Tasks/Index');
By default, Inertia is configured to return React components from the resources/js/pages
folder.
So, let's create that file, which is almost empty for now.
resources/js/pages/Tasks/Index.tsx:
import AppLayout from '@/layouts/app-layout';import { Head } from '@inertiajs/react'; export default function Index() { return ( <AppLayout> <Head title="Tasks List" /> <div> The list will be here. </div> </AppLayout> );}
Notice: the default file extension for React is .jsx
, but we use .tsx
for TypeScript. Even if we don't use Types from TypeScript now, we plan to add types in the later lesson, where I will explain TypeScript in more details.
As you can see, the structure of this React component is very simple:
- Import the Layout and the Head
- Use both of them in the HTML of the main function to export
Now, let's add the new menu in the navigation.
resources/js/components/app-header.tsx:
import { Breadcrumbs } from '@/components/breadcrumbs'; // ... many more imports import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react'; import { BookOpen, BriefcaseIcon, Folder, LayoutGrid, Menu, Search } from 'lucide-react'; const mainNavItems: NavItem[] = [ { title: 'Dashboard', href: '/dashboard', icon: LayoutGrid, }, { title: 'Tasks', href: '/tasks', icon: BriefcaseIcon, }, ]; // ... the rest of the long file
As you can see, we're importing the BriefcaseIcon
and adding a new item to the array of mainNavItems
. As a result, we have a menu on top, and if we click it, we see our component, with static text for now:
Great, we've created our first route and page outside the default starter kit!
Here are the GitHub commits: first and second for this lesson.
In the next lesson, we will build the actual CRUD with all the React.js components for it.
Hi, not sure if they've already changed this, but now type NavItem requires href: not url: - a small thing but I thought worth a mention :)
Thanks for the notice! I'm sure a lot of things will change in the starter kits in the first months... But that's the benefit of TEXT-BASED courses: we will check and update the course and the repo if there are changes.
Hey, we have updated the lesson to reflect the changes :)