Building a Task Manager with Laravel + Inertia.js (Vue 3): CRUD, Tags, Filters, and a Kanban Board
Source: Dev.to
Features
- ✅ Full CRUD (create, list, edit, delete tasks)
- ✅ Filters (status, priority, tag, overdue)
- ✅ Search (title + details)
- ✅ Sorting (created date or due date, with
nulls last) - ✅ Tags (many‑to‑many, custom colours, create‑on‑the‑fly)
- ✅ Kanban board (drag & drop between columns)
- ✅ Small animations on drop (Vue
TransitionGroup) - ✅ Flash messages (success) shared via Inertia
This is a great “real app” example of Laravel + Inertia because it’s not just a CRUD – it starts to feel like a product.
Why Inertia?
If you like Laravel but don’t want to maintain a separate API + SPA, Inertia is a sweet spot:
- You keep Laravel routing / controllers / validation
- You build Vue pages and navigate without full reloads
- You avoid “API duplication” and still get SPA‑like UX
Inertia is basically server‑side routing + client‑side rendering.
Installation
# Create a fresh Laravel project
composer create-project laravel/laravel task-manager
cd task-manager
# Install inertia‑laravel
composer require inertiajs/inertia-laravel
# Generate inertia middleware scaffold
php artisan inertia:middleware
# Front‑end dependencies
npm i vue @inertiajs/vue3 @vitejs/plugin-vue
npm i # (install remaining deps)
Blade Layout (resources/views/app.blade.php)
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@vite('resources/js/app.ts')
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
Inertia entry point (resources/js/app.ts)
import { createApp, h } from "vue";
import { createInertiaApp } from "@inertiajs/vue3";
createInertiaApp({
resolve: (name) => import(`./Pages/${name}.vue`),
// Tip: you can improve resolve() using Vite's import.meta.glob for better DX
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});
Middleware registration (Laravel 11/12)
In bootstrap/app.php (not Kernel.php):
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
HandleInertiaRequests::class,
]);
});
Database Schema
| Table | Columns |
|---|---|
| tasks | id, title (required), details (nullable), status (todo |
| tags | id, name (unique), color |
| tag_task (pivot) | task_id, tag_id (unique composite) |
The Task Model
class Task extends Model
{
protected $dates = [
'due_date',
];
protected $appends = [
'is_overdue',
'is_due_today',
];
// ---------- Computed attributes ----------
public function getIsOverdueAttribute(): bool
{
return $this->due_date instanceof Carbon
? $this->due_date->isBefore(Carbon::today())
: false;
}
public function getIsDueTodayAttribute(): bool
{
return $this->due_date instanceof Carbon
? $this->due_date->isSameDay(Carbon::today())
: false;
}
// ---------- Relationships ----------
public function tags()
{
return $this->belongsToMany(Tag::class);
}
// ---------- Query scopes ----------
public function scopeSearch($query, ?string $term)
{
$term = trim((string) $term);
if ($term === '') {
return $query;
}
return $query->where(function ($q) use ($term) {
$q->where('title', 'like', "%{$term}%")
->orWhere('details', 'like', "%{$term}%");
});
}
public function scopeStatus($query, ?string $status)
{
if (! $status || ! in_array($status, self::STATUSES, true)) {
return $query;
}
return $query->where('status', $status);
}
public function scopePriority($query, ?string $priority)
{
if (! $priority) {
return $query;
}
return $query->where('priority', $priority);
}
public function scopeTagged($query, $tagId)
{
if (! $tagId) {
return $query;
}
return $query->whereHas('tags', fn($q) => $q->where('tags.id', $tagId));
}
public function scopeOverdue($query, $overdue)
{
if ($overdue === null) {
return $query;
}
return $overdue
? $query->whereDate('due_date', 'toDateString())
: $query->whereDate('due_date', '>=', now()->toDateString())
->orWhereNull('due_date');
}
public function scopeOrderByDueDateNullsLast($query, $direction = 'asc')
{
return $query
->orderByRaw('case when due_date is null then 1 else 0 end')
->orderBy('due_date', $direction);
}
public function scopeSortBy($query, $field, $direction = 'asc')
{
return match ($field) {
'due_date' => $query->orderByDueDateNullsLast($direction),
default => $query->orderBy($field, $direction),
};
}
}
Keeping business rules on the model makes the Vue UI very simple.
Controller Example
public function index(Request $request)
{
$tasks = Task::with('tags')
->search($request->input('q'))
->status($request->input('status'))
->priority($request->input('priority'))
->tagged($request->input('tag_id'))
->overdue($request->boolean('overdue'))
->sortBy(
$request->input('sort', 'created_at'),
$request->input('direction', 'desc')
)
->paginate($request->input('per_page', 15))
->withQueryString();
return inertia('Tasks/Index', [
'tasks' => $tasks,
// other props like filters, sorts, etc.
]);
}
// store, update, destroy, etc. use FormRequests (see below)
Routes
use App\Http\Controllers\TaskController;
Route::middleware(['web', 'auth'])->group(function () {
Route::get('/tasks', [TaskController::class, 'index'])->name('tasks.index');
Route::get('/tasks/create', [TaskController::class, 'create'])->name('tasks.create');
Route::post('/tasks', [TaskController::class, 'store'])->name('tasks.store');
Route::get('/tasks/{task}/edit', [TaskController::class, 'edit'])->name('tasks.edit');
Route::put('/tasks/{task}', [TaskController::class, 'update'])->name('tasks.update');
Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('tasks.destroy');
// Kanban board
Route::get('/tasks/board', [TaskController::class, 'board'])->name('tasks.board');
Route::patch('/tasks/{task}/status', [TaskController::class, 'updateStatus'])
->name('tasks.updateStatus');
});
Form Requests (validation)
// app/Http/Requests/StoreTaskRequest.php
class StoreTaskRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'details' => 'nullable|string',
'status' => 'required|in:todo,doing,done',
'priority' => 'required|in:low,medium,high',
'due_date' => 'nullable|date',
'tags' => 'array',
'tags.*' => 'exists:tags,id',
];
}
}
// app/Http/Requests/UpdateTaskRequest.php
class UpdateTaskRequest extends StoreTaskRequest
{
// same rules; you could add "sometimes" if needed
}
// app/Http/Requests/UpdateTaskStatusRequest.php
class UpdateTaskStatusRequest extends FormRequest
{
public function rules(): array
{
return [
'status' => 'required|in:todo,doing,done',
];
}
}
Flash Messages via Inertia Middleware
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'flash' => [
'success' => fn () => $request->session()->get('success'),
],
]);
}
In controllers after a successful operation:
$request->session()->flash('success', 'Task updated successfully.');
Then in AppLayout.vue you can display the flash message if it exists.
Front‑End File Structure
resources/
├─ js/
│ ├─ Components/
│ │ └─ TaskForm.vue
│ ├─ Layouts/
│ │ └─ AppLayout.vue
│ ├─ Pages/
│ │ └─ Tasks/
│ │ ├─ Index.vue
│ │ ├─ Create.vue
│ │ ├─ Edit.vue
│ │ └─ Board.vue
│ └─ app.ts
TaskForm.vue (create & edit)
<script setup lang="ts">
import { useForm } from "@inertiajs/vue3";
import { computed, defineProps } from "vue";
const props = defineProps<{
task?: {
id: number;
title: string;
details: string;
status: string;
priority: string;
due_date: string | null;
tags: Array<{ id: number; name: string }>;
};
}>();
const form = useForm({
title: props.task?.title ?? "",
details: props.task?.details ?? "",
status: props.task?.status ?? "todo",
priority: props.task?.priority ?? "medium",
due_date: props.task?.due_date ?? null,
tags: props.task?.tags?.map(t => t.id) ?? [],
});
function submit() {
if (props.task) {
form.put(route("tasks.update", props.task.id));
} else {
form.post(route("tasks.store"));
}
}
</script>
<template>
<form @submit.prevent="submit">
<!-- title -->
<input v-model="form.title" placeholder="Title" required />
<!-- details -->
<textarea v-model="form.details" placeholder="Details"></textarea>
<!-- status -->
<select v-model="form.status">
<option value="todo">Todo</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
</select>
<!-- priority -->
<select v-model="form.priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<!-- tags (chip input) -->
<div v-for="tag in form.tags" :key="tag">
{{ tag.name }}
</div>
<button type="submit">
{{ props.task ? "Update" : "Create" }} Task
</button>
</form>
</template>
Board Columns
- todo
- doing
- done
When a card is dropped into another column:
- Update UI immediately (optimistic update)
- Send
PATCH /tasks/{id}/status - Rollback on error
const previous = cloneColumns(columns.value);
const [task] = source.splice(index, 1);
task.status = targetStatus;
target.unshift(task);
router.patch(
`/tasks/${taskId}/status`,
{ status: targetStatus },
{
preserveState: true,
preserveScroll: true,
onError: () => {
columns.value = previous; // revert UI on failure
},
}
);
Smooth Reordering & Column Moves
Wrap the list in a <TransitionGroup> so moves feel fluid:
<TransitionGroup name="task" tag="div" class="task-list">
<TaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
/>
</TransitionGroup>
CSS for Transitions
.task-enter-active,
.task-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
}
.task-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
.task-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
.task-move {
transition: transform 180ms ease;
}
Drop Highlight
- Set
droppedIdin the drop handler. - Apply a tiny CSS animation to the dropped card.
This gives the UI a “finished product” feel with very little code.
Backend: Syncing Tags & Creating New Ones
// Normalise incoming tag IDs
$tagIds = collect($data['tag_ids'] ?? [])
->filter()
->map(fn ($id) => (int) $id)
->unique()
->values();
// Create any new tags the user typed
foreach (collect($data['new_tags'] ?? []) as $tagData) {
$name = trim((string) ($tagData['name'] ?? ''));
if ($name