DEV Community

A0mineTV
A0mineTV

Posted on

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

What we’re building

In this article, I’ll walk through how I built a Task Manager using:

  • Laravel 12 (back-end, routing, validation, Eloquent)
  • Inertia.js (no API layer, SPA-like UX)
  • Vue 3 + Vite (front-end)
  • TypeScript (typed UI components)

Features included:

  • ✅ 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 colors, 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 Laravel + Inertia?

If you like Laravel but don’t want to maintain a separate API + a separate SPA, Inertia is a sweet spot:

  • You keep Laravel routing/controllers/validation
  • You build Vue pages and navigate without full reloads
  • You avoid the “API duplication” and still get SPA-like UX

Inertia is basically: server-side routing + client-side rendering.


Project setup (Laravel + Inertia + Vue 3)

I installed Laravel normally, then added Inertia-Laravel + Vue.

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 deps
npm i vue @inertiajs/vue3 @vitejs/plugin-vue
npm i
Enter fullscreen mode Exit fullscreen mode

App shell (resources/views/app.blade.php)

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    @vite('resources/js/app.ts')
    @inertiaHead
  </head>
  <body class="antialiased">
    @inertia
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Inertia bootstrap (resources/js/app.ts)

import { createApp, h } from "vue";
import { createInertiaApp } from "@inertiajs/vue3";

createInertiaApp({
  resolve: (name) => import(`./Pages/${name}.vue`),
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .mount(el);
  },
});
Enter fullscreen mode Exit fullscreen mode

Tip: you can improve resolve() using Vite's import.meta.glob for better DX, but the simple version works.

Register the Inertia middleware (Laravel 11/12)

In Laravel 11/12, middleware registration happens 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,
    ]);
})
Enter fullscreen mode Exit fullscreen mode

Database models: tasks + tags

Task table

Fields I used:

  • title (required)
  • details (optional)
  • status (todo | doing | done)
  • priority (low | medium | high)
  • due_date (nullable date)

Tags (many-to-many)

  • tags table: id, name (unique), color
  • pivot: tag_task table with unique constraint (task_id, tag_id)

The Task model: computed flags (overdue / due today)

I wanted the UI to show badges like “Overdue” or “Due today”, so I added computed attributes:

use Carbon\Carbon;

protected $casts = [
  'due_date' => 'date',
];

protected $appends = [
  'is_overdue',
  'is_due_today',
];

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;
}
Enter fullscreen mode Exit fullscreen mode

This keeps “business rules” on the model, and makes the Vue UI very simple.


Refactoring the index query with model scopes

My tasks index needed search + filters + sorting. The controller can get messy fast, so I moved the logic into Eloquent scopes.

Example scopes:

use Illuminate\Database\Eloquent\Builder;

public function scopeSearch(Builder $query, ?string $term): Builder
{
  $term = trim((string) $term);
  if ($term === '') return $query;

  return $query->where(function (Builder $q) use ($term) {
    $q->where('title', 'like', "%{$term}%")
      ->orWhere('details', 'like', "%{$term}%");
  });
}

public function scopeStatus(Builder $query, ?string $status): Builder
{
  if (! $status || ! in_array($status, self::STATUSES, true)) return $query;
  return $query->where('status', $status);
}

public function scopeOrderByDueDateNullsLast(Builder $query, string $direction = 'asc'): Builder
{
  return $query
    ->orderByRaw('case when due_date is null then 1 else 0 end')
    ->orderBy('due_date', $direction);
}
Enter fullscreen mode Exit fullscreen mode

Now the controller becomes very readable:

$tasks = Task::query()
  ->with('tags')
  ->search($request->input('q'))
  ->status($request->input('status'))
  ->priority($request->input('priority'))
  ->tagged($tagId)
  ->overdue($overdue)
  ->sortBy($sort, $direction)
  ->paginate($perPage)
  ->withQueryString();
Enter fullscreen mode Exit fullscreen mode

TaskController: pages + actions

Routes:

  • /tasks – index
  • /tasks/create – create form
  • /tasks/{task}/edit – edit form
  • /tasks (POST) – store
  • /tasks/{task} (PUT/PATCH) – update
  • /tasks/{task} (DELETE) – delete
  • /tasks/board – kanban board
  • /tasks/{task}/status (PATCH) – update status from drag & drop

I used FormRequests to validate:

  • StoreTaskRequest
  • UpdateTaskRequest
  • UpdateTaskStatusRequest

This keeps validation rules out of controllers.


Sharing flash messages with Inertia

After create/update/delete, I set:

$request->session()->flash('success', 'Task updated successfully.');
Enter fullscreen mode Exit fullscreen mode

Then I shared it to all pages via the Inertia middleware:

public function share(Request $request): array
{
  return array_merge(parent::share($request), [
    'flash' => [
      'success' => fn () => $request->session()->get('success'),
    ],
  ]);
}
Enter fullscreen mode Exit fullscreen mode

In my AppLayout.vue, I display it if present.


Front-end structure (Vue 3 + Inertia)

I kept a simple structure:

  • resources/js/Layouts/AppLayout.vue
  • resources/js/Pages/Tasks/Index.vue
  • resources/js/Pages/Tasks/Create.vue
  • resources/js/Pages/Tasks/Edit.vue
  • resources/js/Pages/Tasks/Board.vue
  • resources/js/Components/TaskForm.vue

Reusable form with useForm

The TaskForm.vue handles both create and edit:

import { useForm } from "@inertiajs/vue3";

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,
  tag_ids: props.task?.tags?.map(t => t.id) ?? [],
});
Enter fullscreen mode Exit fullscreen mode

The Kanban board: drag & drop with optimistic UI

The board groups tasks into columns:

  • todo
  • doing
  • done

When a card is dropped into another column:

  1. Update UI immediately (optimistic)
  2. Send PATCH /tasks/{id}/status
  3. Roll back 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;
  },
});
Enter fullscreen mode Exit fullscreen mode

Adding animations (Vue TransitionGroup)

To make reordering and column moves feel good, I wrapped the list in a TransitionGroup:

<TransitionGroup name="task" tag="div" class="space-y-3" move-class="task-move">
  <article v-for="task in columns[status]" :key="task.id" class="task-card" draggable="true">
    ...
  </article>
</TransitionGroup>
Enter fullscreen mode Exit fullscreen mode

CSS:

.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;
}
Enter fullscreen mode Exit fullscreen mode

Drop pulse feedback

I also added a tiny highlight when a task is dropped:

  • set droppedId in the drop handler
  • apply a CSS animation on that card

This gives the UI a “finished product” feel with very little code.


Tags: create on the fly + attach to tasks

In the controller, I sync tag IDs and also create “new tags” if the user typed them:

$tagIds = collect($data['tag_ids'] ?? [])
  ->filter()
  ->map(fn ($id) => (int) $id)
  ->unique()
  ->values();

foreach (collect($data['new_tags'] ?? []) as $tagData) {
  $name = trim((string) ($tagData['name'] ?? ''));
  if ($name === '') continue;

  $tag = Tag::firstOrCreate(
    ['name' => $name],
    ['color' => $this->normalizeColor($tagData['color'] ?? null)]
  );

  $tagIds->push($tag->id);
}

$task->tags()->sync($tagIds->unique()->values()->all());
Enter fullscreen mode Exit fullscreen mode

On the UI, tags are simple colored badges:

<span
  v-for="tag in task.tags"
  :key="tag.id"
  class="rounded-full px-2 py-0.5 text-[11px] font-medium text-white"
  :style="{ backgroundColor: tag.color }"
>
  {{ tag.name }}
</span>
Enter fullscreen mode Exit fullscreen mode

Filtering & sorting in Index

Filters I used:

  • search q
  • status
  • priority
  • tag_id
  • overdue
  • sort created_at / due_date
  • direction asc / desc
  • per_page 10 or 15

On the Laravel side, the query is built using scopes and withQueryString() so filters persist across pagination.


What I’d build next

Once you have CRUD + board, the project can evolve quickly:

  • 📅 Calendar view (due dates as events)
  • ⌨️ Command palette (Ctrl+K)
  • 🧾 Activity log (task moved, status changed)
  • 🔁 Recurring tasks
  • 🧪 More tests + CI pipeline

Final thoughts

Laravel + Inertia is a great combo when you want:

  • a clean backend (Eloquent, requests, policies later)
  • a modern frontend (Vue 3, TypeScript)
  • without the overhead of maintaining a separate API

Source code: https://github.com/VincentCapek/task-board

Top comments (0)