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
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>
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);
},
});
Tip: you can improve
resolve()using Vite'simport.meta.globfor 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,
]);
})
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)
-
tagstable:id,name (unique),color - pivot:
tag_tasktable 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;
}
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);
}
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();
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:
StoreTaskRequestUpdateTaskRequestUpdateTaskStatusRequest
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.');
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'),
],
]);
}
In my AppLayout.vue, I display it if present.
Front-end structure (Vue 3 + Inertia)
I kept a simple structure:
resources/js/Layouts/AppLayout.vueresources/js/Pages/Tasks/Index.vueresources/js/Pages/Tasks/Create.vueresources/js/Pages/Tasks/Edit.vueresources/js/Pages/Tasks/Board.vueresources/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) ?? [],
});
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:
- Update UI immediately (optimistic)
- Send
PATCH /tasks/{id}/status - 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;
},
});
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>
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;
}
Drop pulse feedback
I also added a tiny highlight when a task is dropped:
- set
droppedIdin 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());
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>
Filtering & sorting in Index
Filters I used:
- search
q statusprioritytag_idoverdue- sort
created_at/due_date - direction
asc/desc -
per_page10 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)