Laravel + Inertia.js (Vue 3)로 작업 관리자 구축하기: CRUD, 태그, 필터, 그리고 칸반 보드

발행: (2026년 1월 3일 오전 05:05 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

Features

  • ✅ 전체 CRUD (생성, 목록, 편집, 작업 삭제)
  • ✅ 필터 (상태, 우선순위, 태그, 연체)
  • ✅ 검색 (제목 + 세부 내용)
  • ✅ 정렬 (생성 날짜 또는 마감 날짜, null은 마지막에)
  • ✅ 태그 (다대다, 사용자 정의 색상, 즉시 생성)
  • ✅ 칸반 보드 (열 간 드래그 & 드롭)
  • ✅ 드롭 시 작은 애니메이션 (Vue TransitionGroup)
  • ✅ 플래시 메시지 (성공) – Inertia를 통해 공유

이 예제는 Laravel + Inertia의 훌륭한 “실제 앱” 사례입니다. 단순 CRUD를 넘어 제품 같은 느낌을 줍니다.

왜 Inertia인가?

Laravel를 좋아하지만 별도의 API + SPA를 유지하고 싶지 않다면, Inertia가 딱 맞는 선택입니다:

  1. Laravel 라우팅 / 컨트롤러 / 검증을 그대로 유지
  2. Vue 페이지를 만들고 전체 새로고침 없이 탐색
  3. “API 중복”을 피하면서도 SPA와 같은 사용자 경험 제공

Inertia는 기본적으로 서버‑사이드 라우팅 + 클라이언트‑사이드 렌더링입니다.

설치

# 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 레이아웃 (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 진입점 (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);
  },
});

미들웨어 등록 (Laravel 11/12)

bootstrap/app.php ( Kernel.php 가 아니라) 에서:

use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        HandleInertiaRequests::class,
    ]);
});

데이터베이스 스키마

테이블컬럼
tasksid, title (필수), details (null 허용), status (todo
tagsid, name (고유), color
tag_task (pivot)task_id, tag_id (고유 복합)

작업 모델

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),
        };
    }
}

모델에 비즈니스 규칙을 유지하면 Vue UI가 매우 간단해집니다.

컨트롤러 예시

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)

라우트

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');
});

폼 요청 (유효성 검사)

// 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',
        ];
    }
}

Inertia 미들웨어를 통한 플래시 메시지

// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
    return array_merge(parent::share($request), [
        'flash' => [
            'success' => fn () => $request->session()->get('success'),
        ],
    ]);
}

컨트롤러에서 작업이 성공한 후:

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

그런 다음 **AppLayout.vue**에서 플래시 메시지가 존재하면 표시할 수 있습니다.

프론트엔드 파일 구조

resources/
├─ js/
│  ├─ Components/
│  │   └─ TaskForm.vue
│  ├─ Layouts/
│  │   └─ AppLayout.vue
│  ├─ Pages/
│  │   └─ Tasks/
│  │       ├─ Index.vue
│  │       ├─ Create.vue
│  │       ├─ Edit.vue
│  │       └─ Board.vue
│  └─ app.ts

TaskForm.vue (생성 & 편집)

<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>

보드 컬럼

  • todo
  • doing
  • done

카드가 다른 컬럼으로 이동될 때:

  1. UI를 즉시 업데이트 (낙관적 업데이트)
  2. 전송 PATCH /tasks/{id}/status
  3. 오류 발생 시 롤백
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;   // 실패 시 UI 복구
    },
  }
);

부드러운 재정렬 및 컬럼 이동

리스트를 <TransitionGroup> 로 감싸면 움직임이 자연스럽게 느껴집니다:

<TransitionGroup name="task" tag="div" class="task-list">
  <TaskCard
    v-for="task in tasks"
    :key="task.id"
    :task="task"
  />
</TransitionGroup>

전환을 위한 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;
}

드롭 하이라이트

  • 드롭 핸들러에서 droppedId 를 설정합니다.
  • 드롭된 카드에 작은 CSS 애니메이션을 적용합니다.

이렇게 하면 아주 적은 코드만으로 UI에 “완성된 제품” 같은 느낌을 줄 수 있습니다.

Source:

백엔드: 태그 동기화 및 새 태그 생성

// 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

관련 글

더 보기 »