DEV Community

A0mineTV
A0mineTV

Posted on

Project Tracker with Laravel 12 + Inertia + Vue (Starter Kit Walkthrough)

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

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

Run migrations:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

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

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

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(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Note: enum:... is not a valid string validation rule here; use in: unless you validate against a real PHP enum via Rule::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>
Enter fullscreen mode Exit fullscreen mode

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

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

Running the project locally

Install dependencies:

composer install
npm install
Enter fullscreen mode Exit fullscreen mode

Env + key + migrations:

cp .env.example .env
php artisan key:generate
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Run dev servers:

composer run dev
Enter fullscreen mode Exit fullscreen mode

Or split terminals:

php artisan serve
npm run dev
Enter fullscreen mode Exit fullscreen mode

Creating an admin user for testing (Tinker)

php artisan tinker
Enter fullscreen mode Exit fullscreen mode
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]);
Enter fullscreen mode Exit fullscreen mode

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)