Building a Task Manager with Laravel + Inertia.js (Vue 3): CRUD, Tags, Filters, and a Kanban Board

Published: (January 2, 2026 at 03:05 PM EST)
6 min read
Source: Dev.to

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:

  1. You keep Laravel routing / controllers / validation
  2. You build Vue pages and navigate without full reloads
  3. 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

TableColumns
tasksid, title (required), details (nullable), status (todo
tagsid, 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:

  1. Update UI immediately (optimistic update)
  2. Send PATCH /tasks/{id}/status
  3. 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 droppedId in 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
Back to Blog

Related posts

Read more »