Laravel + Inertia.js (Vue 3)로 작업 관리자 구축하기: CRUD, 태그, 필터, 그리고 칸반 보드
Source: Dev.to
Features
- ✅ 전체 CRUD (생성, 목록, 편집, 작업 삭제)
- ✅ 필터 (상태, 우선순위, 태그, 연체)
- ✅ 검색 (제목 + 세부 내용)
- ✅ 정렬 (생성 날짜 또는 마감 날짜,
null은 마지막에) - ✅ 태그 (다대다, 사용자 정의 색상, 즉시 생성)
- ✅ 칸반 보드 (열 간 드래그 & 드롭)
- ✅ 드롭 시 작은 애니메이션 (Vue
TransitionGroup) - ✅ 플래시 메시지 (성공) – Inertia를 통해 공유
이 예제는 Laravel + Inertia의 훌륭한 “실제 앱” 사례입니다. 단순 CRUD를 넘어 제품 같은 느낌을 줍니다.
왜 Inertia인가?
Laravel를 좋아하지만 별도의 API + SPA를 유지하고 싶지 않다면, Inertia가 딱 맞는 선택입니다:
- Laravel 라우팅 / 컨트롤러 / 검증을 그대로 유지
- Vue 페이지를 만들고 전체 새로고침 없이 탐색
- “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,
]);
});
데이터베이스 스키마
| 테이블 | 컬럼 |
|---|---|
| tasks | id, title (필수), details (null 허용), status (todo |
| tags | id, 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
카드가 다른 컬럼으로 이동될 때:
- UI를 즉시 업데이트 (낙관적 업데이트)
- 전송
PATCH /tasks/{id}/status - 오류 발생 시 롤백
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