Skip to main content

πŸ› οΈ Weekend Project: Laravel

This is where everything from the module comes together. Over one weekend you'll build TaskMaster β€” a real Laravel 11 API for managing projects and tasks β€” from an empty folder to a secured, working set of endpoints. We'll move through it in milestones, using a systematic problem-solving process so you always know what to build next and how to tell when you're done.

🎯 Learning Objectives

By the end of this project, you will be able to:

  • Plan a small backend from requirements using Polya's four-step method before writing code
  • Model a relational schema in Laravel with migrations and Eloquent relationships
  • Build RESTful API controllers backed by API Resources for clean JSON output
  • Secure endpoints with Passport token auth and policy-based authorization
  • Judge your own work against a concrete "definition of done" checklist

Estimated Time: A weekend (6–10 focused hours)  β€’  Difficulty: Intermediate

Hands-on: Build and run the complete TaskMaster API yourself, then extend it with one feature of your own.

In This Lesson

How to Approach a Build

The biggest mistake beginners make on a project like this is opening the editor and immediately typing php artisan make:model. Code written before you understand the problem is code you usually rewrite. Before we build anything, we'll borrow a four-step method from the mathematician George PΓ³lya β€” it applies to software just as well as it applies to proofs.

flowchart LR A[1. Understand
the problem] --> B[2. Devise
a plan] B --> C[3. Execute
the plan] C --> D[4. Review
& reflect] D -.->|refine| A

Each milestone below maps onto this loop: we understand what a feature needs, plan the pieces, build them, then check the result before moving on. Treat the loop as a rhythm you return to for every feature, not a one-time ceremony at the start.

πŸ“– Key Terms

Milestone: a self-contained slice of work that leaves the app in a runnable, testable state.

Definition of done: the concrete, checkable conditions that decide whether a milestone is actually finished.

API Resource: a Laravel class that transforms an Eloquent model into the exact JSON shape your API promises to return.

The Brief: TaskMaster

You're building TaskMaster, a task-management backend. A signed-in user can create projects, add tasks to those projects, assign each task to a team member, and move tasks through a status workflow. Everything is exposed as a clean REST API so a web or mobile frontend could sit on top later.

πŸ’‘ Scope guardrails

Keep the weekend realistic. We are building the API layer end-to-end and securing it properly. A full Blade web UI, real-time notifications, and analytics are explicitly stretch goals β€” nice if you get there, but not required to call the project a success.

Core features

  • Register, log in, and log out via token authentication
  • Create, list, read, update, and delete projects (scoped to their owner)
  • Create tasks inside a project, assign them, and change their status
  • Consistent JSON output and validation errors
  • Authorization so users can only touch their own projects

Milestone 0 β€” Understand & Plan

No code yet. Spend the first 20 minutes turning the brief into a data model and an endpoint list. This is the cheapest place to catch a design mistake.

The entities and how they relate

erDiagram USER ||--o{ PROJECT : owns USER ||--o{ TASK : "is assigned" PROJECT ||--o{ TASK : contains

Read that in plain English: a user owns many projects; a project contains many tasks; a task belongs to one project and is optionally assigned to one user. Those relationships drive every migration and Eloquent method you'll write later, so it pays to get them right now.

The endpoints you're promising

Method & pathPurposeAuth
POST /api/registerCreate an account, return a tokenPublic
POST /api/loginExchange credentials for a tokenPublic
POST /api/logoutRevoke the current tokenToken
GET/POST /api/projectsList or create projectsToken
GET/PUT/DELETE /api/projects/{id}Read, update, delete a projectOwner
GET/POST /api/projects/{id}/tasksList or create tasks in a projectOwner
GET/PUT/DELETE /api/tasks/{id}Read, update, delete a taskOwner
PATCH /api/tasks/{id}/statusMove a task through its workflowOwner
πŸ’‘ Why write this table first? It becomes your build checklist and your test plan at the same time. When every row returns the right thing, the milestone is done β€” no guesswork.

Milestone 1 β€” Scaffold & Auth

Goal: a fresh Laravel 11 app that can register and authenticate a user with an API token.

Create the project

Laravel 11 ships without an api.php route file by default β€” you opt into API routing with install:api, which also wires up the token guard scaffolding. Then add Passport for OAuth2 tokens.

composer create-project laravel/laravel taskmaster
cd taskmaster

# Opt into API routes (creates routes/api.php)
php artisan install:api

# Add Passport for token-based auth
composer require laravel/passport
php artisan migrate
php artisan passport:install

⚠️ Laravel 11 gotcha: base Controller has no authorize()

Older Laravel bundled the AuthorizesRequests trait into the base controller, so $this->authorize(...) "just worked". In Laravel 11 the slimmed-down app/Http/Controllers/Controller.php is empty. Add the trait yourself or every policy call will throw an "undefined method" error:

<?php
// app/Http/Controllers/Controller.php
namespace App\Http\Controllers;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

abstract class Controller
{
    use AuthorizesRequests;
}

Wire Passport into the User model and guard

<?php
// app/Models/User.php
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = ['name', 'email', 'password', 'role'];
    protected $hidden = ['password', 'remember_token'];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}
<?php
// config/auth.php β€” point the api guard at Passport
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],

A minimal auth controller

<?php
// app/Http/Controllers/API/AuthController.php
namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $data = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'password' => 'required|string|min:8|confirmed',
        ]);

        $user = User::create($data); // password auto-hashed by the cast
        $token = $user->createToken('api')->accessToken;

        return response()->json(['user' => $user, 'token' => $token], 201);
    }

    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required|string',
        ]);

        $user = User::where('email', $credentials['email'])->first();

        if (! $user || ! Hash::check($credentials['password'], $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        return response()->json([
            'user' => $user,
            'token' => $user->createToken('api')->accessToken,
        ]);
    }

    public function logout(Request $request)
    {
        $request->user()->token()->revoke();
        return response()->json(['message' => 'Logged out']);
    }
}

βœ… Milestone 1 is done when…

POST /api/register returns a 201 with a token, and using that token as a Bearer header lets you reach a protected route without a 401.

Milestone 2 β€” Schema & Models

Goal: the database tables and Eloquent models that mirror the ER diagram from Milestone 0.

Migrations

Generate the migrations with php artisan make:migration create_projects_table and ...create_tasks_table, then fill in the schema. The users table already exists from the default install; we only add a role column to it.

<?php
// Add a role to the existing users table
Schema::table('users', function (Blueprint $table) {
    $table->enum('role', ['admin', 'user'])->default('user')->after('password');
});

// projects
Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->text('description')->nullable();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
});

// tasks
Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('description')->nullable();
    $table->foreignId('project_id')->constrained()->cascadeOnDelete();
    $table->foreignId('assigned_to')->nullable()
          ->constrained('users')->nullOnDelete();
    $table->enum('status', ['todo', 'in_progress', 'completed'])->default('todo');
    $table->date('due_date')->nullable();
    $table->timestamps();
});

πŸ’‘ Why cascadeOnDelete() and nullOnDelete()?

Deleting a project should take its tasks with it (cascade), but un-assigning a user by deleting their account should just clear the assigned_to field, not destroy the task (set null). Encoding these rules at the database level protects your data even if application code forgets to.

Models with relationships

<?php
// app/Models/Project.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Project extends Model
{
    protected $fillable = ['name', 'description', 'user_id'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}
<?php
// app/Models/Task.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Task extends Model
{
    protected $fillable = [
        'title', 'description', 'project_id',
        'assigned_to', 'status', 'due_date',
    ];

    protected function casts(): array
    {
        return ['due_date' => 'date'];
    }

    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    public function assignee(): BelongsTo
    {
        return $this->belongsTo(User::class, 'assigned_to');
    }
}

Run php artisan migrate and confirm the tables exist. To make manual testing pleasant, seed a user and a couple of projects with a factory β€” future-you will thank present-you.

βœ… Milestone 2 is done when…

php artisan migrate:fresh runs cleanly, and in php artisan tinker you can create a project and call $project->tasks and $task->assignee without errors.

Milestone 3 β€” API & Resources

Goal: controllers that return well-shaped JSON. We use API Resources so the model's database columns and the API's public shape stay decoupled β€” you can rename a column tomorrow without breaking clients.

Request flow through a Laravel API endpoint A request passes through the router, then the auth and policy checks, then the controller which loads Eloquent models, which are transformed by an API Resource into JSON. Route api.php Auth + Policy Controller validate Eloquent model Resource JSON
Figure 1 β€” Each request flows left to right; the Resource is the last stop that turns models into the exact JSON your clients receive.

Create the resources

<?php
// app/Http/Resources/ProjectResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProjectResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'description' => $this->description,
            'owner' => [
                'id' => $this->user->id,
                'name' => $this->user->name,
            ],
            'tasks_count' => $this->whenCounted('tasks'),
            'created_at' => $this->created_at,
        ];
    }
}
<?php
// app/Http/Resources/TaskResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class TaskResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'status' => $this->status,
            'due_date' => $this->due_date?->toDateString(),
            'project_id' => $this->project_id,
            'assignee' => $this->whenLoaded('assignee', fn () => [
                'id' => $this->assignee->id,
                'name' => $this->assignee->name,
            ]),
            'created_at' => $this->created_at,
        ];
    }
}

The project controller

Generate it with php artisan make:controller API/ProjectController --api. Notice how thin each method is β€” validation, an Eloquent call, and a Resource. That is exactly the shape you're aiming for.

<?php
// app/Http/Controllers/API/ProjectController.php
namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use Illuminate\Http\Request;

class ProjectController extends Controller
{
    public function index(Request $request)
    {
        return ProjectResource::collection(
            $request->user()->projects()->withCount('tasks')->latest()->get()
        );
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
        ]);

        $project = $request->user()->projects()->create($data);

        return new ProjectResource($project);
    }

    public function show(Project $project)
    {
        $this->authorize('view', $project);
        return new ProjectResource($project->loadCount('tasks'));
    }

    public function update(Request $request, Project $project)
    {
        $this->authorize('update', $project);

        $data = $request->validate([
            'name' => 'sometimes|required|string|max:255',
            'description' => 'nullable|string',
        ]);

        $project->update($data);

        return new ProjectResource($project);
    }

    public function destroy(Project $project)
    {
        $this->authorize('delete', $project);
        $project->delete();

        return response()->json(['message' => 'Project deleted'], 200);
    }
}

The $request->user()->projects() relationship needs a projects() method on the User model β€” add public function projects() { return $this->hasMany(Project::class); }. Scoping queries through the authenticated user like this means a user physically cannot list someone else's projects, even before policies run.

The task controller

<?php
// app/Http/Controllers/API/TaskController.php
namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Http\Resources\TaskResource;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function index(Project $project)
    {
        $this->authorize('view', $project);

        return TaskResource::collection(
            $project->tasks()->with('assignee')->latest()->get()
        );
    }

    public function store(Request $request, Project $project)
    {
        $this->authorize('update', $project);

        $data = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'assigned_to' => 'nullable|exists:users,id',
            'due_date' => 'nullable|date',
            'status' => 'nullable|in:todo,in_progress,completed',
        ]);

        $task = $project->tasks()->create($data);

        return new TaskResource($task->load('assignee'));
    }

    public function show(Task $task)
    {
        $this->authorize('view', $task->project);
        return new TaskResource($task->load('assignee'));
    }

    public function update(Request $request, Task $task)
    {
        $this->authorize('update', $task->project);

        $data = $request->validate([
            'title' => 'sometimes|required|string|max:255',
            'description' => 'nullable|string',
            'assigned_to' => 'nullable|exists:users,id',
            'due_date' => 'nullable|date',
            'status' => 'nullable|in:todo,in_progress,completed',
        ]);

        $task->update($data);

        return new TaskResource($task->load('assignee'));
    }

    public function destroy(Task $task)
    {
        $this->authorize('update', $task->project);
        $task->delete();

        return response()->json(['message' => 'Task deleted'], 200);
    }

    public function updateStatus(Request $request, Task $task)
    {
        $this->authorize('update', $task->project);

        $data = $request->validate([
            'status' => 'required|in:todo,in_progress,completed',
        ]);

        $task->update($data);

        return new TaskResource($task->load('assignee'));
    }
}

βœ… Milestone 3 is done when…

You can create a project over the API, add a task to it, and both come back as clean JSON with the shape defined in your resources β€” no raw database columns leaking through.

Milestone 4 β€” Authorization & Routes

Goal: lock the doors. Right now query-scoping hides other users' data, but the policies make the rules explicit and cover edge cases like an admin override.

The project policy

Generate it with php artisan make:policy ProjectPolicy --model=Project. Laravel 11 auto-discovers policies by naming convention, so no manual registration is needed.

<?php
// app/Policies/ProjectPolicy.php
namespace App\Policies;

use App\Models\Project;
use App\Models\User;

class ProjectPolicy
{
    public function view(User $user, Project $project): bool
    {
        return $user->id === $project->user_id || $user->role === 'admin';
    }

    public function update(User $user, Project $project): bool
    {
        return $user->id === $project->user_id || $user->role === 'admin';
    }

    public function delete(User $user, Project $project): bool
    {
        return $user->id === $project->user_id || $user->role === 'admin';
    }
}

Register the routes

<?php
// routes/api.php
use App\Http\Controllers\API\AuthController;
use App\Http\Controllers\API\ProjectController;
use App\Http\Controllers\API\TaskController;
use Illuminate\Support\Facades\Route;

// Public
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Protected by a Passport token
Route::middleware('auth:api')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);

    Route::apiResource('projects', ProjectController::class);

    // Tasks nested under a project (list + create)
    Route::get('projects/{project}/tasks', [TaskController::class, 'index']);
    Route::post('projects/{project}/tasks', [TaskController::class, 'store']);

    // Individual tasks (everything except the nested index/store)
    Route::apiResource('tasks', TaskController::class)
        ->except(['index', 'store']);
    Route::patch('tasks/{task}/status', [TaskController::class, 'updateStatus']);
});

Testing an endpoint from the terminal

curl -X POST http://localhost:8000/api/projects \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name":"Launch site","description":"Marketing push"}'
{
  "data": {
    "id": 1,
    "name": "Launch site",
    "description": "Marketing push",
    "owner": { "id": 1, "name": "Ray" },
    "created_at": "2026-08-01T14:22:10.000000Z"
  }
}

βœ… Milestone 4 is done when…

A second user's token gets a 403 when it tries to view or edit a project it doesn't own, and every table row from Milestone 0 returns the response you promised.

Definition of Done

Before you call TaskMaster finished, walk this checklist. If every box is honestly checked, you have a solid, secure API.

πŸ“‹ Completion checklist

  • ☐ migrate:fresh runs with no errors on a clean database
  • ☐ Register and login both return a usable token
  • ☐ Every endpoint in the Milestone 0 table works and returns the promised shape
  • ☐ Validation failures return 422 with field-level messages
  • ☐ A non-owner gets 403, an unauthenticated request gets 401
  • ☐ Deleting a project removes its tasks (cascade verified)
  • ☐ No password or other hidden field ever appears in a response
  • ☐ At least one feature or route has an automated test (see below)
  • ☐ A README.md explains setup and lists the endpoints

A first feature test

You don't need full coverage this weekend, but write at least one test so you learn the workflow. Laravel 11 uses Pest by default; a feature test reads almost like plain English.

<?php
// tests/Feature/ProjectTest.php
use App\Models\User;

it('lets an authenticated user create a project', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user, 'api')->postJson('/api/projects', [
        'name' => 'Weekend build',
    ]);

    $response->assertCreated()
        ->assertJsonPath('data.name', 'Weekend build');

    $this->assertDatabaseHas('projects', [
        'name' => 'Weekend build',
        'user_id' => $user->id,
    ]);
});

it('blocks a user from viewing another user\'s project', function () {
    $owner = User::factory()->create();
    $intruder = User::factory()->create();
    $project = $owner->projects()->create(['name' => 'Private']);

    $this->actingAs($intruder, 'api')
        ->getJson("/api/projects/{$project->id}")
        ->assertForbidden();
});

Run it with php artisan test. Green output on both cases proves your happy path and your authorization are working.

What Good Looks Like

Two projects can both "work" and be worlds apart in quality. Here's how to tell whether yours is genuinely good, not just green.

DimensionGood enoughGreat
ControllersWork, but mix validation, logic, and queriesThin: validate β†’ Eloquent β†’ Resource, nothing more
JSON shapeReturns model columns directlyShaped by Resources; internal columns stay hidden
AuthorizationChecks ownership inline in a few placesCentralized in policies; consistent everywhere
QueriesLoads tasks with assignees one-by-one (N+1)Eager-loads with with() / withCount()
ErrorsGeneric 500s on bad inputMeaningful 422/403/404 with messages

⚠️ Common weekend-project traps

  • Forgetting the Accept: application/json header β€” without it, validation errors redirect instead of returning JSON.
  • The N+1 query problem β€” listing tasks without with('assignee') fires one extra query per task. Eager-load.
  • Mass-assignment holes β€” every model needs a considered $fillable; never blindly pass all request input.
  • Leaking hidden fields β€” rely on API Resources rather than $model->toJson() so you control exactly what ships.

βœ… The mark of a strong submission

Someone can clone your repo, follow the README, run php artisan test to green, and exercise every endpoint from your documented examples without you in the room. That self-sufficiency is what "done" really means in professional work.

Your Assignment

πŸ‹οΈ Build TaskMaster, then extend it

Objective: ship the full TaskMaster API described above, then add one feature of your own design using PΓ³lya's method.

Requirements

  1. Implement all four milestones so every checklist box is honestly ticked.
  2. Add one feature not covered here β€” for example: comments on tasks, task labels/tags, file attachments, or filtering tasks by status via a query parameter.
  3. Write at least two feature tests (one happy path, one authorization case).
  4. Document setup and endpoints in a README.md.
πŸ’‘ Hint β€” how to add "comments on tasks" the PΓ³lya way

Understand: a comment belongs to one task and one author. Plan: a comments migration (body, task_id, user_id), a Comment model, a hasMany on Task, a nested tasks/{task}/comments route, a CommentController, and a CommentResource. Execute: mirror the task controller pattern. Review: confirm only project owners can comment, and comments cascade-delete with their task.

βœ… Example β€” filtering tasks by status

In TaskController@index, read an optional query param and constrain the query:

$query = $project->tasks()->with('assignee');

if ($request->filled('status')) {
    $request->validate(['status' => 'in:todo,in_progress,completed']);
    $query->where('status', $request->string('status'));
}

return TaskResource::collection($query->latest()->get());

Now GET /api/projects/1/tasks?status=in_progress returns only in-progress tasks β€” a small, testable win.

Submission guidelines

  • Push to a public GitHub repository on a clearly named branch.
  • Include the README.md with setup steps and an endpoint table.
  • Ensure php artisan test passes on a clean clone.
  • Be ready to walk through your PΓ³lya notes: what you understood, planned, built, and would improve.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Plan before you code. PΓ³lya's four steps turn a vague brief into a schema and an endpoint list you can build against.
  • Milestones keep the app runnable at every stage, so you always have something to test and never a half-broken mess.
  • API Resources decouple your database from your public JSON β€” rename columns freely, hide secrets by default.
  • Policies centralize authorization, and query-scoping through the authenticated user adds a second layer of safety.
  • A definition of done and a "what good looks like" bar turn "it works on my machine" into professional-grade work.

πŸ“š Further Reading

🎯 Quick Quiz

Question 1: Why does TaskMaster return data through API Resource classes instead of returning Eloquent models directly?

Question 2: In Laravel 11, what must you do before $this->authorize(...) works inside a controller?

Question 3: A milestone's "definition of done" is best described as…

πŸš€ What's Next?

You've now built a complete PHP backend end-to-end. Next we step up a level to compare backend approaches across languages and cement the concepts that carry between every framework you'll meet.

πŸŽ‰ Great work!

You planned, built, and secured a real API in a weekend. That is exactly what shipping software feels like.