Courses

Vue Laravel 12 Starter Kit: CRUD Project

Practice Another CRUD: Task Categories

Summary of this lesson:
- Creating a Task Categories CRUD similar to Tasks CRUD
- Building model, migrations, controllers, and routes
- Implementing React components for category management
- Handling relationship between tasks and categories

Let's practice creating a CRUD with another one: Task Categories. It will be almost the same as Tasks CRUD, so for the most part, I will just show the code and specify the differences.


Task Categories: DB Model/Migration

First, we prepare the back end.

Create Model, Migration, and Pivot table for task categories:

php artisan make:model TaskCategory -m
php artisan make:migration create_task_task_category_table

Migration:

Schema::create('task_categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});

Pivot migration:

Schema::create('task_task_category', function (Blueprint $table) {
$table->foreignId('task_id')->constrained();
$table->foreignId('task_category_id')->constrained();
});

app/Models/Task.php:

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
// ...
 
public function taskCategories(): BelongsToMany
{
return $this->belongsToMany(TaskCategory::class);
}

app/Models/TaskCategory.php

namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class TaskCategory extends Model
{
protected $fillable = ['name'];
 
public function tasks(): BelongsToMany
{
return $this->belongsToMany(Task::class);
}
}

Controller and Routes

Create a Controller and Form Requests:

php artisan make:controller TaskCategoryController
php artisan make:request StoreTaskCategoryRequest
php artisan make:request UpdateTaskCategoryRequest

app/Http/Controllers/TaskCategoryController.php

use App\Http\Requests\StoreTaskCategoryRequest;
use App\Http\Requests\UpdateTaskCategoryRequest;
use App\Models\TaskCategory;
use Inertia\Inertia;
 
class TaskCategoryController extends Controller
{
public function index()
{
return Inertia::render('TaskCategories/Index', [
'taskCategories' => TaskCategory::query()
->withCount('tasks')
->paginate(10),
]);
}
 
public function create()
{
return Inertia::render('TaskCategories/Create');
}
 
public function store(StoreTaskCategoryRequest $request)
{
TaskCategory::create($request->validated());
 
return redirect()->route('task-categories.index');
}
 
public function edit(TaskCategory $taskCategory)
{
return Inertia::render('TaskCategories/Edit', [
'taskCategory' => $taskCategory,
]);
}
 
public function update(UpdateTaskCategoryRequest $request, TaskCategory $taskCategory)
{
$taskCategory->update($request->validated());
 
return redirect()->route('task-categories.index');
}
 
public function destroy(TaskCategory $taskCategory)
{
if ($taskCategory->tasks()->count() > 0) {
$taskCategory->tasks()->detach();
}
 
$taskCategory->delete();
 
return redirect()->route('task-categories.index');
}
}

As you can see, in the destroy() method, we check for the assigned tasks and detach them if they exist. We'll get back to this when we discuss the deleting action.

app/Http/Requests/StoreTaskCategoryRequest.php

use Illuminate\Foundation\Http\FormRequest;
 
class StoreTaskCategoryRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required'],
];
}
 
public function authorize(): bool
{
return true;
}
}

app/Http/Requests/UpdateTaskCategoryRequest.php

use Illuminate\Foundation\Http\FormRequest;
 
class UpdateTaskCategoryRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required'],
];
}
 
public function authorize(): bool
{
return true;
}
}

routes/web.php

use App\Http\Controllers\TaskCategoryController;
 
// ...
 
Route::middleware(['auth', 'verified'])->group(function () {
 
// ...
 
Route::resource('tasks', TaskController::class);
Route::resource('task-categories', TaskCategoryController::class);
});
 
// ...

TypeScript: Creating Types

Now, let's move to the front end. First, a new type for the categories.

The TypeScript layer can be considered similar to the Eloquent Model: you need to define the object and its properties.

Also, we need to update our PaginatedResponse to use the new TaskCategory type.

resources/js/types/index.d.ts

// ...
 
export interface PaginatedResponse<T = Task | null> {
export interface PaginatedResponse<T = Task | TaskCategory | null> {
 
// ...
 
export interface TaskCategory {
id: number;
name: string;
tasks_count: number | null;
tasks: Task[] | null;
created_at: string;
updated_at: string;
}

See that tasks_count? This will be a calculated number of assigned tasks, shown in the table.


Vue: Showing Categories Table

Here's what our table will look like. Again, it is very similar to the Task list.

resources/js/pages/TaskCategories/Index.vue

<script setup lang="ts">
import Pagination from '@/components/Pagination.vue';
import { Button, buttonVariants } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import AppLayout from '@/layouts/AppLayout.vue';
import { type BreadcrumbItem, PaginatedResponse, TaskCategory } from '@/types';
import { Head, Link, router } from '@inertiajs/vue3';
import { toast } from 'vue-sonner';
 
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: '/dashboard',
},
{
title: 'Tasks',
href: '/tasks',
},
{
title: 'Task Categories',
href: '/task-categories',
},
];
 
interface Props {
taskCategories: PaginatedResponse<TaskCategory>;
}
 
defineProps<Props>();
 
const deleteCategory = (taskCategory: TaskCategory) => {
// TODO
};
</script>
 
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Task Categories List" />
 
<div class="mt-4">
<Link :class="buttonVariants({ variant: 'outline' })" href="/task-categories/create"> Create Task Category </Link>
</div>
 
<Table class="mt-4">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead class="w-[200px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="category in taskCategories.data" :key="category.id">
<TableCell>{{ category.name }}</TableCell>
<TableCell class="flex gap-x-2 text-right">
<Link :class="buttonVariants({ variant: 'default' })" :href="`/task-categories/${category.id}/edit`">Edit </Link>
<Button variant="destructive" @click="deleteCategory(category)" class="mr-2">Delete</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
 
<Pagination :resource="taskCategories" />
</AppLayout>
</template>

For now, we skip the Delete method cause it would need to check the related tasks. We'll take care of it at the end of this lesson.

Also, you can see our reusable TablePagination component in action. Cool, right?

Okay, now how do we get to that route to test it? We need a link or button, right?


Adding a Button to Manage Categories

In this case, I won't add a link in the main navigation. Rather, I will add a button at the top of the Tasks List, near the "Create Task" button.

resources/js/pages/Tasks/Index.vue:

BEFORE:

<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Tasks List" />
 
<div class="mt-4 flex gap-4">
<Link :class="buttonVariants({ variant: 'outline' })" href="/tasks/create"> Create Task</Link>
</div>

AFTER:

<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Tasks List" />
 
<div class="mt-4 flex gap-4">
<Link :class="buttonVariants({ variant: 'outline' })" href="/tasks/create"> Create Task</Link>
<Link :class="buttonVariants({ variant: 'outline' })" href="/task-categories"> Manage Task Categories</Link>
</div>

The visual result:

If we click that button, we see the empty (but working!) table:


Create Category Form

I'm not even sure if I need to comment anything here. It has a totally identical structure to the Create Task form from the previous lesson, just TaskCategory everywhere.

resources/js/pages/TaskCategories/Create.vue:

<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { BreadcrumbItem } from '@/types';
import { Head, useForm } from '@inertiajs/vue3';
 
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Dashboard', href: '/dashboard' },
{ title: 'Tasks', href: '/tasks' },
{
title: 'Task Categories',
href: '/task-categories',
},
{ title: 'Create', href: '/task-categories/create' },
];
 
const form = useForm({
name: '',
});
 
const submitForm = () => {
form.post(route('task-categories.store'), {
preserveScroll: true,
});
};
</script>
 
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Create Task" />
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<form class="space-y-6" @submit.prevent="submitForm">
<div class="grid gap-2">
<Label htmlFor="name">Name *</Label>
 
<Input id="name" v-model="form.name" class="mt-1 block w-full" />
 
<InputError :message="form.errors.name" />
</div>
 
<div class="flex items-center gap-4">
<Button :disabled="form.processing" variant="default">Create Category</Button>
</div>
</form>
</div>
</AppLayout>
</template>

Here's the visual result:


Edit Category Form

Again, very similar to the Task Edit form. Not much more to say.

resources/js/pages/TaskCategories/Edit.vue:

<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { BreadcrumbItem, Task } from '@/types';
import { Head, useForm } from '@inertiajs/vue3';
 
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Dashboard', href: '/dashboard' },
{ title: 'Tasks', href: '/tasks' },
{
title: 'Task Categories',
href: '/task-categories',
},
{ title: 'Edit', href: '' },
];
 
interface Props {
taskCategory: Task;
}
 
const props = defineProps<Props>();
 
const taskCategory = props.taskCategory;
 
const form = useForm({
name: taskCategory.name,
});
 
const submitForm = () => {
form.put(route('task-categories.update', taskCategory.id), {
preserveScroll: true,
});
};
</script>
 
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Edit Category" />
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<form class="space-y-6" @submit.prevent="submitForm">
<div class="grid gap-2">
<Label htmlFor="name">Name *</Label>
 
<Input id="name" v-model="form.name" class="mt-1 block w-full" />
 
<InputError :message="form.errors.name" />
</div>
 
<div class="flex items-center gap-4">
<Button :disabled="form.processing" variant="default">Update Category</Button>
</div>
</form>
</div>
</AppLayout>
</template>

Here's the visual result if we click Edit:


Delete Category with Double-Check

Now, why did I postpone the Delete function until now? An additional check is required for the assigned tasks.

Here's the implementation of the deleteTaskCategory() method.

resources/js/pages/TaskCategories/Index.vue:

<script>
import { Head, Link } from '@inertiajs/vue3';
import { Head, Link, router } from '@inertiajs/vue3';
import { toast } from 'sonner';
 
// ...
 
const deleteCategory = (taskCategory: TaskCategory) => {
if (taskCategory.tasks_count === 0) {
if (confirm('Are you sure you want to delete this task category?')) {
router.delete(route('task-categories.destroy', taskCategory.id));
toast.success('Task Category deleted successfully');
}
} else {
if (
confirm(
'This category has tasks assigned to it. Are you sure you want to delete it? This will also delete all the tasks assigned to this category.',
)
) {
router.delete(route('task-categories.destroy', taskCategory.id));
toast.success('Task Category deleted successfully');
}
}
};
</script>

As you can see, it accepts the entire object of TaskCategory and then checks the tasks_count. Based on the situation, we delete the task category by its id.

Here's what happens when we click the Delete button:


Here are the GitHub commit for this lesson:

Now, we don't have category selection in the Task Create/Edit forms. We will add that in the next lesson.

Previous: File Upload with Spatie Media Library
avatar

When you have created the files for the database, dont forget: "php artisan migrate"

avatar
You can use Markdown
avatar
You can use Markdown