Why we built this project
We wanted a concrete, realistic starter project to validate a modern Laravel 12 workflow with a clean UI and a fast iteration loop.
The goal was to build a small app that:
- feels useful (not a toy),
- covers the core pieces of most apps (auth, CRUD, validation, pagination, filters),
- showcases Laravel + Inertia + Vue as a cohesive full‑stack approach without maintaining a separate API.
The result is a Project Tracker: create projects, add tasks, filter tasks by status, edit/delete tasks, all through a modern UI.
Tech stack
Backend
- Laravel 12 (PHP framework)
- PHP 8.3+
- Eloquent ORM
- Request validation
- Pagination
Frontend
- Vue 3 (Composition API)
- Inertia.js (server-driven SPA UX)
- TypeScript
- Tailwind CSS
- shadcn-vue UI components (Dialog, Table, Select, Button…)
Tooling
- Vite for dev/build
- Composer + npm/pnpm/bun
What we built
Data model
Two entities:
Project
- belongs to a
User - has many
Task
Task
- belongs to a
Project - fields:
title-
details(optional) -
status(todo | doing | done) -
priority(1..3) -
due_date(optional)
Backend implementation (with code)
1) Migrations
projects table
// database/migrations/xxxx_create_projects_table.php
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
tasks table
// database/migrations/xxxx_create_tasks_table.php
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('details')->nullable();
$table->string('status')->default('todo'); // todo|doing|done
$table->unsignedTinyInteger('priority')->default(2); // 1=high 2=med 3=low
$table->date('due_date')->nullable();
$table->timestamps();
$table->index(['project_id', 'status']);
});
Run migrations:
php artisan migrate
2) Eloquent models
// app/Models/Project.php
class Project extends Model
{
protected $fillable = ['user_id', 'name', 'description'];
public function tasks()
{
return $this->hasMany(Task::class);
}
}
// app/Models/Task.php
class Task extends Model
{
protected $fillable = ['project_id', 'title', 'details', 'status', 'priority', 'due_date'];
protected $casts = [
'due_date' => 'date',
];
public function project()
{
return $this->belongsTo(Project::class);
}
}
3) Routes
// routes/web.php
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\TaskController;
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::post('/projects/{project}/tasks', [TaskController::class, 'store'])->name('tasks.store');
Route::patch('/tasks/{task}', [TaskController::class, 'update'])->name('tasks.update');
Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('tasks.destroy');
});
4) Controllers
Project list + create + show
// app/Http/Controllers/ProjectController.php
namespace App\Http\Controllers;
use App\Models\Project;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ProjectController extends Controller
{
public function index(Request $request)
{
$projects = Project::query()
->where('user_id', $request->user()->id)
->latest()
->paginate(10)
->withQueryString();
return Inertia::render('Projects/Index', [
'projects' => $projects,
]);
}
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
]);
Project::create([
'user_id' => $request->user()->id,
...$data,
]);
return back();
}
public function show(Request $request, Project $project)
{
abort_unless($project->user_id === $request->user()->id, 403);
$status = $request->string('status')->toString(); // todo|doing|done|""(all)
$tasksQuery = $project->tasks()->latest();
if (in_array($status, ['todo', 'doing', 'done'], true)) {
$tasksQuery->where('status', $status);
}
return Inertia::render('Projects/Show', [
'project' => $project,
'filters' => ['status' => $status ?: 'all'],
'tasks' => $tasksQuery->paginate(15)->withQueryString(),
]);
}
}
Tasks CRUD
// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function store(Request $request, Project $project)
{
abort_unless($project->user_id === $request->user()->id, 403);
$data = $request->validate([
'title' => ['required', 'string', 'max:140'],
'details' => ['nullable', 'string', 'max:4000'],
'status' => ['required', 'in:todo,doing,done'],
'priority' => ['required', 'integer', 'min:1', 'max:3'],
'due_date' => ['nullable', 'date'],
]);
$project->tasks()->create($data);
return back();
}
public function update(Request $request, Task $task)
{
abort_unless($task->project->user_id === $request->user()->id, 403);
$data = $request->validate([
'title' => ['required', 'string', 'max:140'],
'details' => ['nullable', 'string', 'max:4000'],
'status' => ['required', 'in:todo,doing,done'],
'priority' => ['required', 'integer', 'min:1', 'max:3'],
'due_date' => ['nullable', 'date'],
]);
$task->update($data);
return back();
}
public function destroy(Request $request, Task $task)
{
abort_unless($task->project->user_id === $request->user()->id, 403);
$task->delete();
return back();
}
}
Note:
enum:...is not a valid string validation rule here; usein:unless you validate against a real PHP enum viaRule::enum().
Frontend implementation (with code)
1) Projects list page: create project via Dialog + useForm
<!-- resources/js/Pages/Projects/Index.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { Head, Link, useForm } from "@inertiajs/vue3";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/Components/ui/dialog";
type Project = { id: number; name: string; description?: string | null };
type Paginated<T> = { data: T[]; links: any[] };
defineProps<{ projects: Paginated<Project> }>();
const open = ref(false);
const form = useForm({ name: "", description: "" });
function submit() {
form.post(route("projects.store"), {
preserveScroll: true,
onSuccess: () => {
form.reset();
open.value = false;
},
});
}
</script>
<template>
<Head title="Projects" />
<div class="p-6 space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Projects</h1>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button>New project</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
</DialogHeader>
<form class="space-y-4" @submit.prevent="submit">
<div class="space-y-2">
<label class="text-sm font-medium">Name</label>
<Input v-model="form.name" placeholder="e.g. SEO Dashboard" />
<p v-if="form.errors.name" class="text-sm text-red-600">{{ form.errors.name }}</p>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">Description</label>
<Textarea v-model="form.description" rows="4" placeholder="Optional…" />
<p v-if="form.errors.description" class="text-sm text-red-600">{{ form.errors.description }}</p>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" @click="open = false">Cancel</Button>
<Button type="submit" :disabled="form.processing">Create</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
<div class="space-y-2">
<div v-for="p in projects.data" :key="p.id" class="border rounded p-3">
<div class="font-medium">
<Link :href="route('projects.show', p.id)" class="hover:underline">{{ p.name }}</Link>
</div>
<div v-if="p.description" class="text-sm opacity-80">{{ p.description }}</div>
</div>
</div>
</div>
</template>
2) Project page: filter + create/edit/delete tasks
Key pattern: with Inertia Vue useForm, you update form values by assigning fields (there is no setData()).
// Example: when opening "Edit Task" dialog
function openEdit(task: Task) {
editingTask.value = task;
editForm.title = task.title ?? "";
editForm.details = task.details ?? "";
editForm.status = task.status;
editForm.priority = task.priority;
editForm.due_date = task.due_date ?? null;
editOpen.value = true;
}
Filtering tasks via query string:
watch(selectedStatus, (val) => {
router.get(
route("projects.show", props.project.id),
{ status: val === "all" ? "" : val },
{ preserveScroll: true, preserveState: true, replace: true }
);
});
Running the project locally
Install dependencies:
composer install
npm install
Env + key + migrations:
cp .env.example .env
php artisan key:generate
php artisan migrate
Run dev servers:
composer run dev
Or split terminals:
php artisan serve
npm run dev
Creating an admin user for testing (Tinker)
php artisan tinker
use App\Models\User;
$user = User::create([
'name' => 'Admin',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
]);
// If you added an is_admin boolean column:
$user->update(['is_admin' => true]);
Conclusion
This Project Tracker is a practical showcase of building a modern app with Laravel 12 + Inertia + Vue + TypeScript:
strong backend foundations, smooth frontend experience, and a professional UI from day one.
Top comments (0)